Compare commits
3 Commits
87c4e71025
...
feature/en
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d4cd4adae | |||
| 49a61fdafa | |||
| c208543044 |
@@ -47,6 +47,7 @@ import EngagementAudiences from './routes/admin/views/EngagementAudiences.jsx'
|
||||
import EngagementTemplates from './routes/admin/views/EngagementTemplates.jsx'
|
||||
import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx'
|
||||
import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
|
||||
import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx'
|
||||
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
|
||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||
@@ -208,6 +209,7 @@ export default function App() {
|
||||
<Route path="templates" element={<EngagementTemplates />} />
|
||||
<Route path="triggers" element={<EngagementTriggers />} />
|
||||
<Route path="sends" element={<EngagementSendLog />} />
|
||||
<Route path="suppressions" element={<EngagementSuppressions />} />
|
||||
</Route>
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
{/* Staff have an inbox and channel preferences like anyone else —
|
||||
|
||||
@@ -434,6 +434,25 @@ export const api = {
|
||||
return req(`/admin/engagement/sends${withQs(qs.toString())}`)
|
||||
},
|
||||
|
||||
// Suppressions (Phase 9). `unsuppressAddress` sends the address in the BODY
|
||||
// of a DELETE rather than in the path, and that is not style: a path
|
||||
// parameter lands in the access log, the browser history and every proxy in
|
||||
// front of the deployment, and this one is a real person's address. The list
|
||||
// never returns a hash to use instead.
|
||||
listEngagementSuppressions: ({ limit, offset, reason, channel, search } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (limit) qs.set('limit', String(limit))
|
||||
if (offset) qs.set('offset', String(offset))
|
||||
if (reason) qs.set('reason', reason)
|
||||
if (channel) qs.set('channel', channel)
|
||||
if (search) qs.set('search', search)
|
||||
return req(`/admin/engagement/suppressions${withQs(qs.toString())}`)
|
||||
},
|
||||
suppressAddress: (address, detail) =>
|
||||
req('/admin/engagement/suppressions', { method: 'POST', body: { address, detail } }),
|
||||
unsuppressAddress: (address, channel) =>
|
||||
req('/admin/engagement/suppressions', { method: 'DELETE', body: { address, channel } }),
|
||||
|
||||
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
|
||||
// different depending on who calls them: for a moderator, unhide and
|
||||
// setTeamDisplayName file a request and the response says `pending: true`.
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 1.8.0 - the ceiling lattice gains `admin` (ENGAGEMENT.md Phase 11). Nothing on
|
||||
// this half changed: a ceiling is declared on the server's `api` and enforced
|
||||
// there, and the admin screens that render one read the vocabulary from
|
||||
// `GET /admin/engagement/triggers` rather than holding a copy. This file bumps
|
||||
// anyway, for the reason at the top - the two halves state ONE version.
|
||||
// 1.7.0 — the engagement contract (docs/website/ENGAGEMENT.md Phase 2). Nothing
|
||||
// on this half changed: every member the version adds is on the server's `api`
|
||||
// and `ctx` (registerEventTriggers, registerAudiences, ctx.events.emit,
|
||||
@@ -53,4 +58,4 @@
|
||||
// but the two halves state ONE version: a module declares a single coreApi range
|
||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||
// answered 1.1.0 would be two answers to one question.
|
||||
export const MODULE_API_VERSION = '1.7.0'
|
||||
export const MODULE_API_VERSION = '1.8.0'
|
||||
|
||||
@@ -98,9 +98,9 @@ export const NAV = [
|
||||
},
|
||||
{
|
||||
// Its own top-level group (ENGAGEMENT.md §7.1 Q4), not a section of
|
||||
// Settings. Settings is already one long page of sections, and these five
|
||||
// screens are two editors, a catalog and a paged table, none of which is a
|
||||
// settings section. Email Delivery stays under Settings: configuring a
|
||||
// Settings. Settings is already one long page of sections, and these six
|
||||
// screens are two editors, a catalog and two paged tables, none of which is
|
||||
// a settings section. Email Delivery stays under Settings: configuring a
|
||||
// transport is not the same job as deciding who gets mail.
|
||||
title: 'Engagement',
|
||||
items: [
|
||||
@@ -109,6 +109,10 @@ export const NAV = [
|
||||
{ to: '/admin/engagement/templates', label: 'Templates', icon: IconTemplate, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/triggers', label: 'Triggers', icon: IconSpark, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/sends', label: 'Send Log', icon: IconLog, roles: ['admin'] },
|
||||
// Beside the Send Log rather than inside it (Phase 9): the log answers
|
||||
// "did that message go out", and this answers "why is this person not
|
||||
// getting any" - and it is the only screen that can lift a suppression.
|
||||
{ to: '/admin/engagement/suppressions', label: 'Suppressions', icon: IconLog, roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -190,6 +194,7 @@ const TITLES = {
|
||||
'/admin/engagement/audiences': 'Engagement Audiences',
|
||||
'/admin/engagement/templates': 'Message Templates',
|
||||
'/admin/engagement/triggers': 'Triggers',
|
||||
'/admin/engagement/suppressions': 'Suppressions',
|
||||
'/admin/engagement/sends': 'Send Log',
|
||||
}
|
||||
|
||||
|
||||
@@ -494,6 +494,15 @@ function RuleEditor({ catalog, segments, rule, onSaved, onCancel }) {
|
||||
// It reads the RULES rather than a flag, so it disappears the moment one is
|
||||
// switched on and comes back if every one is switched off again. A deployment
|
||||
// that deleted them all sees nothing, which is right: they made that choice.
|
||||
//
|
||||
// **Phase 11 added a second notice of exactly the same shape, for news**
|
||||
// (ENGAGEMENT.md §7.1 Q9). Publishing a news post used to tickle every subscriber
|
||||
// directly, and that call is now an emit through the engine, so news push stops
|
||||
// on upgrade until the seeded `news.post` rule is switched on. Two notices rather
|
||||
// than one generalised "some rules are off" banner, deliberately: each names a
|
||||
// capability that USED to work without configuration and now does not, which is
|
||||
// a different statement from "you have a disabled rule" — and a rule an operator
|
||||
// created and disabled themselves must never produce a warning.
|
||||
const TEAM_TRIGGERS = [
|
||||
'team.forum.post',
|
||||
'team.announcement',
|
||||
@@ -501,11 +510,32 @@ const TEAM_TRIGGERS = [
|
||||
'team.leadership.changed',
|
||||
]
|
||||
|
||||
function teamRulesAllOff(rules) {
|
||||
const team = rules.filter((r) => TEAM_TRIGGERS.includes(r.triggerId || r.trigger_id))
|
||||
return team.length > 0 && team.every((r) => !r.enabled)
|
||||
const NEWS_TRIGGERS = ['news.post']
|
||||
|
||||
// One style for both notices, so the pair reads as one kind of message rather
|
||||
// than two that happen to look alike.
|
||||
const NOTICE_STYLE = {
|
||||
fontSize: '0.85rem',
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
marginBottom: 16,
|
||||
border: '1px solid #7a6440',
|
||||
color: '#e0b070',
|
||||
}
|
||||
|
||||
const triggerOf = (rule) => rule.triggerId || rule.trigger_id
|
||||
|
||||
// True only when rules for these triggers EXIST and every one of them is off.
|
||||
// Zero matching rules means the operator deleted them, which is a choice, not a
|
||||
// regression to warn about.
|
||||
function allOff(rules, triggers) {
|
||||
const group = rules.filter((r) => triggers.includes(triggerOf(r)))
|
||||
return group.length > 0 && group.every((r) => !r.enabled)
|
||||
}
|
||||
|
||||
const teamRulesAllOff = (rules) => allOff(rules, TEAM_TRIGGERS)
|
||||
const newsRulesAllOff = (rules) => allOff(rules, NEWS_TRIGGERS)
|
||||
|
||||
export default function EngagementRules() {
|
||||
const [catalog, setCatalog] = useState(null)
|
||||
const [segments, setSegments] = useState([])
|
||||
@@ -583,13 +613,7 @@ export default function EngagementRules() {
|
||||
return (
|
||||
<section>
|
||||
{teamRulesAllOff(rules) && (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.85rem', borderRadius: 8, padding: '10px 12px', marginBottom: 16,
|
||||
border: '1px solid #7a6440', color: '#e0b070',
|
||||
}}
|
||||
>
|
||||
<div className="sans" style={NOTICE_STYLE}>
|
||||
<strong>Team notification emails are off.</strong> They used to be sent automatically; they
|
||||
are now rules, and the four below arrived switched off so that nothing starts mailing on its
|
||||
own. Switch on the ones this deployment wants — per-member preferences and per-Team mutes
|
||||
@@ -597,6 +621,16 @@ export default function EngagementRules() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newsRulesAllOff(rules) && (
|
||||
<div className="sans" style={NOTICE_STYLE}>
|
||||
<strong>News notifications are off.</strong> Publishing a news post used to send a push
|
||||
notification to everyone subscribed to it. That is now the “News posts” rule below, and it
|
||||
arrived switched off for the same reason the Team rules did. Switch it on to resume news
|
||||
push — it also carries email and the in-app inbox, each still subject to each person’s own
|
||||
preferences. The in-game town crier and the Discord announcement are unaffected either way.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 640 }}>
|
||||
A rule turns an event into mail: which event, who hears about it, on which channels, and how
|
||||
|
||||
259
client/src/routes/admin/views/EngagementSuppressions.jsx
Normal file
259
client/src/routes/admin/views/EngagementSuppressions.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Engagement → Suppressions (ENGAGEMENT.md §4.5 gap G16, Phase 9).
|
||||
//
|
||||
// **This screen is the only way out of the suppression list**, which is the whole
|
||||
// reason it exists rather than the list living as a filter on the Send Log. A
|
||||
// hard bounce is written by a background worker with no human in the loop, so
|
||||
// without a lift button a mistyped-then-corrected mailbox is silenced for good
|
||||
// and nobody ever finds out why that person stopped hearing from the deployment.
|
||||
//
|
||||
// **Addresses are shown masked, and the mask is deliberate on both ends.** The
|
||||
// table holds a sha256 and an `address_masked` — `d***@example.com` — and the
|
||||
// route never returns the hash, for the same reason the Send Log strips it: a
|
||||
// digest of every address on the deployment, handed to a browser, is an offline
|
||||
// dictionary attack waiting to be run. The domain survives because the signal an
|
||||
// operator is actually hunting is domain-shaped ("everything to this company is
|
||||
// bouncing" is a different problem from three people mistyping their own
|
||||
// address), and the local part is destroyed rather than shortened so the list can
|
||||
// never be read back as an address book.
|
||||
//
|
||||
// The consequence to keep in mind while reading this file: **lifting a
|
||||
// suppression needs the WHOLE address typed in**, because the screen genuinely
|
||||
// does not have it. That is not a rough edge to be smoothed later — it is the
|
||||
// privacy design working, and the confirm dialog says so.
|
||||
|
||||
const REASON_LABEL = {
|
||||
bounce: 'Hard bounce',
|
||||
complaint: 'Marked as spam',
|
||||
manual: 'Added by an admin',
|
||||
unverified: 'Unverified',
|
||||
}
|
||||
|
||||
const REASON_HELP = {
|
||||
bounce: 'The receiving server said this mailbox does not exist.',
|
||||
complaint: 'The recipient reported a message as spam.',
|
||||
manual: 'Somebody here added it — usually a bounce reported another way.',
|
||||
unverified: 'Reserved: the verification gate excludes these before a send is queued.',
|
||||
}
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
export default function EngagementSuppressions() {
|
||||
const [rows, setRows] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [byReason, setByReason] = useState({})
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [reason, setReason] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
// Debounced separately from `search` so typing a domain does not fire a request
|
||||
// per keystroke; `search` is what the input shows, `applied` is what was asked.
|
||||
const [applied, setApplied] = useState('')
|
||||
const [adding, setAdding] = useState('')
|
||||
const [note, setNote] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const load = useCallback(async (nextOffset, nextReason, nextSearch) => {
|
||||
const result = await api.admin.listEngagementSuppressions({
|
||||
limit: PAGE,
|
||||
offset: nextOffset,
|
||||
reason: nextReason || undefined,
|
||||
search: nextSearch || undefined,
|
||||
})
|
||||
setRows(result.suppressions || [])
|
||||
setTotal(result.total || 0)
|
||||
setByReason(result.byReason || {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => { setOffset(0); setApplied(search.trim()) }, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load(offset, reason, applied)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [load, offset, reason, applied])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
async function addByHand(e) {
|
||||
e.preventDefault()
|
||||
const address = adding.trim()
|
||||
if (!address) return
|
||||
setNote(null)
|
||||
try {
|
||||
const result = await api.admin.suppressAddress(address)
|
||||
// `created: false` is not a failure — the operator asked for the address to
|
||||
// be suppressed and it is. Saying so plainly beats an error dialog for an
|
||||
// outcome that is exactly what was wanted.
|
||||
setNote(result.created
|
||||
? `${result.address} will no longer be mailed.`
|
||||
: `${result.address} was already suppressed.`)
|
||||
setAdding('')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setNote(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function lift() {
|
||||
// The address cannot come from the row — the screen has only the mask. Asking
|
||||
// for it in full is the cost of not storing it, and the prompt says why so it
|
||||
// does not read as a missing feature.
|
||||
const address = window.prompt(
|
||||
'Type the full address to let it be mailed again.\n\n'
|
||||
+ 'Suppressed addresses are stored one-way, so this screen never has the address itself.',
|
||||
)
|
||||
if (!address || !address.trim()) return
|
||||
setNote(null)
|
||||
try {
|
||||
await api.admin.unsuppressAddress(address.trim())
|
||||
setNote(`${address.trim()} can be mailed again.`)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setNote(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && rows.length === 0 && !applied && !reason) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
const to = Math.min(offset + PAGE, total)
|
||||
const summary = Object.entries(byReason).filter(([, n]) => n > 0)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 620 }}>
|
||||
Addresses this deployment has stopped mailing. Engagement rules skip them; password resets,
|
||||
invites and verification mails still go out, because those are asked for by the person
|
||||
themselves. Addresses are stored one-way and shown masked.
|
||||
</p>
|
||||
|
||||
{summary.length > 0 && (
|
||||
<div className="panel-flat" style={{ display: 'flex', gap: 24, flexWrap: 'wrap', padding: '12px 16px', marginBottom: 16 }}>
|
||||
{summary.map(([r, n]) => (
|
||||
<div key={r}>
|
||||
<div className="sans" style={{ fontSize: '1.1rem', fontWeight: 600 }}>{n}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem' }} title={REASON_HELP[r] || ''}>
|
||||
{REASON_LABEL[r] || r}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<label style={{ flex: '1 1 220px' }}>
|
||||
<span className="field-label">Search</span>
|
||||
<input
|
||||
className="input"
|
||||
value={search}
|
||||
placeholder="a domain, or part of one"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Reason</span>
|
||||
<select className="select" value={reason} onChange={(e) => { setOffset(0); setReason(e.target.value) }}>
|
||||
<option value="">Any</option>
|
||||
{Object.keys(REASON_LABEL).map((r) => (
|
||||
<option key={r} value={r}>{REASON_LABEL[r]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<form onSubmit={addByHand} style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flex: '1 1 280px' }}>
|
||||
<label style={{ flex: 1 }}>
|
||||
<span className="field-label">Suppress an address</span>
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
value={adding}
|
||||
placeholder="someone@example.com"
|
||||
onChange={(e) => setAdding(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="pill" style={{ fontSize: '0.74rem' }} disabled={!adding.trim()}>
|
||||
Suppress
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} onClick={lift}>
|
||||
Lift a suppression
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{note && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', margin: '0 0 14px' }}>{note}</p>
|
||||
)}
|
||||
|
||||
{total === 0 ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||
{reason || applied ? 'Nothing matches that filter.' : 'No addresses are suppressed.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Address</th>
|
||||
<th className="adm-th">Reason</th>
|
||||
<th className="adm-th">Detail</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">Since</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={`${r.channel}:${r.address_masked}:${r.created_at}`}>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{r.address_masked
|
||||
? <code style={{ fontSize: '0.8rem' }}>{r.address_masked}</code>
|
||||
: <span className="dim">not recorded</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }} title={REASON_HELP[r.reason] || ''}>
|
||||
{REASON_LABEL[r.reason] || r.reason}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
|
||||
{r.detail || ''}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{r.channel}</td>
|
||||
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
|
||||
{new Date(r.created_at).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
|
||||
{offset + 1}–{to} of {total}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
|
||||
Newer
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
disabled={to >= total} onClick={() => setOffset(offset + PAGE)}>
|
||||
Older
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const CEILING_NOTE = {
|
||||
members: 'only members of the thing it is about',
|
||||
subscribers: 'only people who opted in',
|
||||
staff: 'only staff',
|
||||
admin: 'only administrators',
|
||||
authenticated: 'any signed-in account',
|
||||
everyone: 'anyone',
|
||||
}
|
||||
|
||||
@@ -1994,3 +1994,45 @@ CREATE TABLE IF NOT EXISTS user_notifications (
|
||||
-- notification this deployment has ever written.
|
||||
INDEX idx_un_prune (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Deliverability: suppression and bounces (ENGAGEMENT.md §4.5 G16 — Phase 9) ──
|
||||
|
||||
-- The addresses this deployment has stopped mailing, and why.
|
||||
--
|
||||
-- **Keyed on the ADDRESS, not the user** (§4.5), and after Phase 1b that is a
|
||||
-- deliberate choice rather than a workaround for a missing unique index. Two
|
||||
-- accounts can no longer share an address, but a bounce arrives as an ADDRESS —
|
||||
-- it does not know which account was behind it, and it stays true after the
|
||||
-- account that held it changed its address or was deleted. Keying on the user
|
||||
-- would forget a dead mailbox the moment anybody moved.
|
||||
--
|
||||
-- `address_masked` is Phase 9's one addition to §4.5's DDL, and it exists because
|
||||
-- the hash-only table cannot be operated. An operator looking at a screen of
|
||||
-- sha256 digests cannot tell whether the list is three typos or a whole domain
|
||||
-- refusing mail, and un-suppressing somebody who fixed their mailbox is the one
|
||||
-- action this table has to support. `d***@example.com` is enough to act on and to
|
||||
-- see a domain-wide pattern in, and — the reason it is safe — the local part is
|
||||
-- destroyed rather than shortened, so the column is not an address book and
|
||||
-- cannot be turned back into one. It is NULLable because a row written from a
|
||||
-- correlation that only ever held a hash has nothing to mask.
|
||||
--
|
||||
-- **`reason` is not a synonym for "the send failed".** `mailer.PERMANENT_CODES`
|
||||
-- classifies a failure as not-worth-retrying, and that set contains EAUTH and 554
|
||||
-- — an authentication failure and a relay-wide policy refusal, neither of which
|
||||
-- is a fact about the recipient. Writing a suppression on every terminal failure
|
||||
-- would mean one wrong SMTP password suppresses every address the worker touches
|
||||
-- before anyone notices. Only recipient-scoped evidence reaches this table; see
|
||||
-- `src/engagement/bounceClassify.js`.
|
||||
CREATE TABLE IF NOT EXISTS engagement_suppressions (
|
||||
address_hash CHAR(64) NOT NULL PRIMARY KEY, -- sha256 of the lowercased address
|
||||
address_masked VARCHAR(190) NULL, -- d***@example.com; never the local part
|
||||
channel VARCHAR(32) NOT NULL DEFAULT 'email',
|
||||
reason ENUM('bounce','complaint','manual','unverified') NOT NULL,
|
||||
detail VARCHAR(500) NULL,
|
||||
created_by INT NULL, -- the admin, for a manual row; NULL for automatic
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_engsup_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- The screen's two orderings: newest first, and filtered by reason.
|
||||
INDEX idx_engsup_created (created_at),
|
||||
INDEX idx_engsup_reason (reason, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@@ -5,7 +5,7 @@ const wikiDb = require('../src/model/wiki/wiki.db')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const { ensureSchema, close } = require('../src/utils/db')
|
||||
const { seedTemplates } = require('../src/engagement/templates')
|
||||
const { seedTeamRules } = require('../src/engagement/coreRules')
|
||||
const { seedCoreRules } = require('../src/engagement/coreRules')
|
||||
const brand = require('../src/config/brand')
|
||||
|
||||
const log = require('../src/utils/logger')('seed')
|
||||
@@ -82,10 +82,13 @@ async function seedDefaults() {
|
||||
// failed to seed costs the shipped default, which `renderByKey` falls back to
|
||||
// anyway, and must not stop a boot.
|
||||
await seedTemplates()
|
||||
// The four Team rules, seeded ONCE and all disabled (ENGAGEMENT.md Phase 6).
|
||||
// Guarded by a settings key rather than re-ensured, so a rule an operator
|
||||
// deleted stays deleted and one they enabled stays enabled.
|
||||
await seedTeamRules()
|
||||
// Core's five rules — the four Team ones (Phase 6) and news (Phase 11) —
|
||||
// seeded ONCE and all disabled. Each GROUP carries its own settings-key guard
|
||||
// rather than re-ensured, so a rule an operator deleted stays deleted and one
|
||||
// they enabled stays enabled; and so the news rule reaches the deployments that
|
||||
// were already stamped for Teams, which are exactly the ones that lose their
|
||||
// raw news push to the engine (ENGAGEMENT.md §7.1 Q9).
|
||||
await seedCoreRules()
|
||||
log.info('settings and wiki defaults ensured')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
|
||||
"moduleApiVersion": "1.7.0",
|
||||
"moduleApiVersion": "1.8.0",
|
||||
"triggers": [
|
||||
{
|
||||
"id": "news.post",
|
||||
@@ -38,8 +38,8 @@
|
||||
"name": "postUrl",
|
||||
"type": "url",
|
||||
"required": true,
|
||||
"example": "/news/five-on-friday-yew-invasion",
|
||||
"description": "Site-relative path to the post."
|
||||
"example": "/site/news",
|
||||
"description": "Site-relative path to the post. The news list today — the site has no per-post route."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -293,6 +293,33 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/suppressions",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/suppressions",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/suppressions",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates",
|
||||
|
||||
@@ -129,6 +129,18 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/sends"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/suppressions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/suppressions"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/suppressions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates"
|
||||
|
||||
@@ -49,8 +49,15 @@ const TRIGGERS = [
|
||||
description: 'A plain-text summary, already stripped of markup.' },
|
||||
{ name: 'category', type: 'string', required: false, example: 'Five on Friday',
|
||||
description: 'The post category, when it has one.' },
|
||||
{ name: 'postUrl', type: 'url', required: true, example: '/news/five-on-friday-yew-invasion',
|
||||
description: 'Site-relative path to the post.' },
|
||||
// **`/site/news`, the LIST, and not a per-post path.** The example said
|
||||
// `/news/<slug>` when this was declared with no caller; Phase 11 gave it
|
||||
// one and the path turned out not to exist — `App.jsx` mounts `/site/news`
|
||||
// and nothing under it, which is why `announceJobs.logic.js` links the list
|
||||
// from the Discord and town-crier announcements too. An `example` is what
|
||||
// the template editor previews and test-sends with (§4.3 property 3), so an
|
||||
// example naming a 404 is a preview that looks right and a mail that is not.
|
||||
{ name: 'postUrl', type: 'url', required: true, example: '/site/news',
|
||||
description: 'Site-relative path to the post. The news list today — the site has no per-post route.' },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ── Resolving a rule's audience to recipients ──────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §5.1a / §4.5, Phase 4a. A rule names an audience two ways and
|
||||
// only ever one at a time: a **plain ceiling name** (`owner`, `staff`,
|
||||
// only ever one at a time: a **plain ceiling name** (`owner`, `staff`, `admin`,
|
||||
// `subscribers`, `authenticated`, `everyone`) resolved from core's own tables, or
|
||||
// an **`audience_segment_id`** pointing at an operator-composed tree of
|
||||
// module-declared audiences (segments.js). This file turns either into user ids.
|
||||
@@ -88,9 +88,14 @@ async function resolveForRule(rule, event) {
|
||||
}
|
||||
}
|
||||
case 'staff':
|
||||
case 'admin':
|
||||
// Both role-gated, and resolved through the ONE query rather than two.
|
||||
// `ceilings.ROLE_CEILINGS` holds which roles each names, so the day a
|
||||
// third is added the resolver does not need a third case — and, more to
|
||||
// the point, cannot get one of them wrong while the others stay right.
|
||||
return {
|
||||
userIds: await recipients.staff(ceilings.STAFF_CEILING_ROLES),
|
||||
ceiling: 'staff',
|
||||
userIds: await recipients.staff(ceilings.ROLE_CEILINGS[rule.audience].roles),
|
||||
ceiling: rule.audience,
|
||||
dormant: false,
|
||||
reason: null,
|
||||
}
|
||||
|
||||
216
server/src/engagement/bounceClassify.js
Normal file
216
server/src/engagement/bounceClassify.js
Normal file
@@ -0,0 +1,216 @@
|
||||
// ── Which send failures are facts about the RECIPIENT ───────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 9. The suppression list's whole value is that an address on
|
||||
// it is genuinely undeliverable; the moment it fills with addresses that were
|
||||
// fine, an operator learns to ignore it and it may as well not exist. This file
|
||||
// is the one place that judgement is made.
|
||||
//
|
||||
// **It is deliberately NOT `mailer.PERMANENT_CODES`, and reusing that set would
|
||||
// have been a mass-suppression bug.** That set answers "is retrying pointless?"
|
||||
// and holds `EAUTH` and `554` alongside `550` — an authentication failure and a
|
||||
// relay-wide policy refusal. Both are permanent and neither says anything about
|
||||
// the person: one wrong SMTP password would suppress every address the outbox
|
||||
// worker touched before anybody noticed the mail had stopped. "Do not retry" and
|
||||
// "this mailbox does not exist" are different questions, and this file only
|
||||
// answers the second.
|
||||
//
|
||||
// **The primary signal is the enhanced status code (RFC 3463), not the reply
|
||||
// code.** `550` alone is the catch-all every refusal arrives as; `5.1.1` means
|
||||
// one specific thing — no such mailbox. Every relay worth configuring emits an
|
||||
// enhanced code, so it is read first and, when present, decides on its own.
|
||||
//
|
||||
// **The fallback is narrow on purpose.** Without an enhanced code a phrase match
|
||||
// is all that is left, and phrase matching is how a classifier quietly starts
|
||||
// suppressing everything. So it applies only after the reply code has already
|
||||
// narrowed the failure to the recipient address — 550, 551 and 553 are RFC 5321's
|
||||
// recipient-address codes — and only for phrases that cannot mean anything else,
|
||||
// with a veto list checked first. `552` (storage exceeded) and `554` (transaction
|
||||
// failed) are excluded from even that: a full mailbox gets emptied, and a generic
|
||||
// transaction failure is generic.
|
||||
//
|
||||
// Anything this file is unsure about is NOT suppressed. The cost of a false
|
||||
// negative is mailing a dead address again next month; the cost of a false
|
||||
// positive is a person who silently stops hearing from the deployment and has no
|
||||
// way to find out.
|
||||
|
||||
// RFC 3463 subject.detail pairs that mean "this address will not accept mail,
|
||||
// today or ever". Kept as strings because `5.1.10` and `5.1.1` are different
|
||||
// codes and numeric parsing loses that.
|
||||
const PERMANENT_RECIPIENT = new Set([
|
||||
'1.1', // bad destination mailbox address — no such user
|
||||
'1.2', // bad destination system address — the domain does not take mail
|
||||
'1.3', // bad destination mailbox address syntax
|
||||
'1.6', // mailbox has moved, no forwarding address
|
||||
'1.10', // recipient address has a null MX (RFC 7505)
|
||||
'2.1', // mailbox disabled, not accepting messages
|
||||
])
|
||||
|
||||
// Enhanced subjects that are permanent but are NOT about the recipient. Listed
|
||||
// rather than merely omitted, because each is a plausible-looking 5.x.y that a
|
||||
// later edit would otherwise be tempted to add:
|
||||
// 2.2 — mailbox full. Permanent-coded by some relays, emptied by every user.
|
||||
// 7.x — policy. Our sending reputation, our SPF, our content; the recipient is
|
||||
// the one party it is not about.
|
||||
// 3.x — the destination MAIL SYSTEM is full or refusing. Not the mailbox.
|
||||
// 5.x — protocol failure. A bug at one end or the other.
|
||||
const NEVER_RECIPIENT_SUBJECTS = new Set(['3', '5', '7'])
|
||||
|
||||
// RFC 5321 reply codes that name the recipient address specifically. 554 is
|
||||
// absent deliberately: "transaction failed" is what a relay reaches for when it
|
||||
// does not want to say why, and it is the commonest shape of a content or policy
|
||||
// rejection.
|
||||
const RECIPIENT_REPLY_CODES = new Set([550, 551, 553])
|
||||
|
||||
// Phrases that only ever mean "no such mailbox", checked only once a reply code
|
||||
// above has established the failure is about the address. Each is a substring of
|
||||
// a real refusal from a widely deployed MTA (Postfix, Exim, Exchange, Google,
|
||||
// Microsoft 365).
|
||||
const NO_SUCH_MAILBOX = [
|
||||
'user unknown',
|
||||
'unknown user',
|
||||
'no such user',
|
||||
'no such recipient',
|
||||
'unknown recipient',
|
||||
'invalid recipient',
|
||||
'recipient address rejected',
|
||||
'recipient not found',
|
||||
'address does not exist',
|
||||
'does not exist',
|
||||
'mailbox unavailable',
|
||||
'mailbox not found',
|
||||
'no mailbox',
|
||||
'user does not exist',
|
||||
'address rejected',
|
||||
]
|
||||
|
||||
// Phrases that appear alongside the ones above and mean the opposite, checked
|
||||
// FIRST. "Mailbox unavailable" is a substring of the sentence a relay sends when
|
||||
// a mailbox is merely full, so a substring match with no veto list would read a
|
||||
// temporary condition as a dead address.
|
||||
const NOT_A_DEAD_MAILBOX = [
|
||||
'full',
|
||||
'quota',
|
||||
'storage',
|
||||
'temporar',
|
||||
'try again',
|
||||
'greylist',
|
||||
'rate limit',
|
||||
'too many',
|
||||
'spam',
|
||||
'blocked',
|
||||
'blacklist',
|
||||
'blocklist',
|
||||
'reputation',
|
||||
'policy',
|
||||
'authentication',
|
||||
'not authorized',
|
||||
]
|
||||
|
||||
/**
|
||||
* The enhanced status code in an SMTP response, as `{ class, subject, detail }`,
|
||||
* or null.
|
||||
*
|
||||
* Anchored to the start of the line rather than searched for anywhere in it: a
|
||||
* bounce that quotes another server's answer ("...said: 550 5.1.1...") contains
|
||||
* two, and the one that matters is the one this relay just gave us. A free search
|
||||
* finds whichever comes first, which is not the same thing.
|
||||
*/
|
||||
function parseEnhanced(response) {
|
||||
if (!response) return null
|
||||
const m = /^\s*(\d{3})[\s-]+(\d)\.(\d{1,3})\.(\d{1,3})\b/.exec(String(response))
|
||||
if (!m) return null
|
||||
return { class: m[2], subject: m[3], detail: m[4] }
|
||||
}
|
||||
|
||||
/** The three-digit reply code, off the error object or out of the response text. */
|
||||
function replyCode(err) {
|
||||
const direct = Number(err && err.responseCode)
|
||||
if (Number.isInteger(direct) && direct >= 400 && direct <= 599) return direct
|
||||
const m = /^\s*(\d{3})\b/.exec(String((err && err.response) || ''))
|
||||
return m ? Number(m[1]) : null
|
||||
}
|
||||
|
||||
const lower = (s) => String(s || '').toLowerCase()
|
||||
|
||||
/**
|
||||
* Should this send failure suppress the address?
|
||||
*
|
||||
* @param {object} err the error a transport's send threw, or an object carrying
|
||||
* the `responseCode` / `response` / `code` lifted off one
|
||||
* @returns {{ suppress: boolean, reason: string, evidence: string|null }}
|
||||
*
|
||||
* `reason` is populated on a refusal too, and that is not decoration: it becomes
|
||||
* the send log's `detail`, so "not suppressed: 554 does not name the recipient
|
||||
* address" is the line that stops somebody re-deriving this decision from an
|
||||
* unexplained non-event six months from now.
|
||||
*/
|
||||
function classify(err) {
|
||||
const e = err || {}
|
||||
const response = e.response || e.message || ''
|
||||
const enhanced = parseEnhanced(response)
|
||||
const code = replyCode(e)
|
||||
|
||||
// No reply code at all means the failure happened before or outside the SMTP
|
||||
// transaction: the connection, the credentials, the socket. Never the
|
||||
// recipient. `EAUTH` lands here, which is the whole reason this file exists.
|
||||
if (!code) {
|
||||
return {
|
||||
suppress: false,
|
||||
reason: `no SMTP reply code (${e.code || 'transport failure'}); not a recipient failure`,
|
||||
evidence: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (code < 500) {
|
||||
return { suppress: false, reason: `${code} is a temporary failure`, evidence: null }
|
||||
}
|
||||
|
||||
if (enhanced) {
|
||||
const pair = `${enhanced.subject}.${enhanced.detail}`
|
||||
if (enhanced.class !== '5') {
|
||||
return { suppress: false, reason: `enhanced status ${enhanced.class}.${pair} is not permanent`, evidence: null }
|
||||
}
|
||||
if (PERMANENT_RECIPIENT.has(pair)) {
|
||||
return { suppress: true, reason: 'bounce', evidence: `5.${pair}` }
|
||||
}
|
||||
if (NEVER_RECIPIENT_SUBJECTS.has(enhanced.subject)) {
|
||||
return {
|
||||
suppress: false,
|
||||
reason: `5.${pair} is about the server or our standing with it, not the address`,
|
||||
evidence: `5.${pair}`,
|
||||
}
|
||||
}
|
||||
// A permanent 5.x.y this file has no opinion on. Unknown means no.
|
||||
return {
|
||||
suppress: false,
|
||||
reason: `5.${pair} is not a known recipient failure`,
|
||||
evidence: `5.${pair}`,
|
||||
}
|
||||
}
|
||||
|
||||
// No enhanced code: the narrow fallback.
|
||||
if (!RECIPIENT_REPLY_CODES.has(code)) {
|
||||
return { suppress: false, reason: `${code} does not name the recipient address`, evidence: null }
|
||||
}
|
||||
const text = lower(response)
|
||||
const veto = NOT_A_DEAD_MAILBOX.find((p) => text.includes(p))
|
||||
if (veto) {
|
||||
return { suppress: false, reason: `${code}, but the response says "${veto}"`, evidence: null }
|
||||
}
|
||||
const hit = NO_SUCH_MAILBOX.find((p) => text.includes(p))
|
||||
if (hit) {
|
||||
return { suppress: true, reason: 'bounce', evidence: `${code} "${hit}"` }
|
||||
}
|
||||
return { suppress: false, reason: `${code} with no enhanced status and no recognised reason`, evidence: null }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classify,
|
||||
parseEnhanced,
|
||||
replyCode,
|
||||
PERMANENT_RECIPIENT,
|
||||
NEVER_RECIPIENT_SUBJECTS,
|
||||
RECIPIENT_REPLY_CODES,
|
||||
NO_SUCH_MAILBOX,
|
||||
NOT_A_DEAD_MAILBOX,
|
||||
}
|
||||
@@ -53,6 +53,24 @@ const isMode = (value) => MODES.includes(value)
|
||||
* without one is declared but not yet deliverable, which is exactly what
|
||||
* `inapp` is until Phase 7; the worker finishes such a row `failed` and
|
||||
* says so in the send log rather than pretending it was sent.
|
||||
* @param {(userIds: number[]) => Promise<{userIds: number[], excluded: object}>} [def.eligible]
|
||||
* Phase 9. Narrow an already-resolved audience to the users this channel
|
||||
* may write an outbox row for, and say how many it dropped and why.
|
||||
*
|
||||
* **It exists so the engine can stay channel-agnostic.** The verification
|
||||
* gate is an email fact — an unverified address is a reason not to mail
|
||||
* somebody and no reason at all not to put an item in their inbox — and a
|
||||
* rule may name both channels. Filtering the shared audience before the
|
||||
* per-channel loop would have silenced the wrong sink; an `if (channel ===
|
||||
* 'email')` in `engine.js` would have put a channel's rule inside the
|
||||
* generic engine. This is the seam that is neither.
|
||||
*
|
||||
* Distinct from `deliver`'s refusals on purpose: this runs at ENQUEUE and
|
||||
* is for standing properties of a user (is this address verified), which
|
||||
* are stable across a delay window and are worth not writing a row for.
|
||||
* A suppression is not one of those — it can appear between the enqueue
|
||||
* and the send — so it is checked in `deliver`, where it produces a
|
||||
* `suppressed` row in the send log the acceptance criterion asks for.
|
||||
*/
|
||||
function registerDeliveryChannel(def) {
|
||||
if (!def || typeof def !== 'object') throw new Error('registerDeliveryChannel: definition required')
|
||||
@@ -81,7 +99,7 @@ function registerDeliveryChannel(def) {
|
||||
// silently never delivers — the failure Phase 3 deferred the whole behavioural
|
||||
// half to avoid freezing, and the one the worker's "no delivery implementation
|
||||
// yet" branch would report as if it were by design.
|
||||
for (const fn of ['addressFor', 'deliver']) {
|
||||
for (const fn of ['addressFor', 'deliver', 'eligible']) {
|
||||
if (def[fn] !== undefined && typeof def[fn] !== 'function') {
|
||||
throw new Error(`registerDeliveryChannel(${id}): ${fn} must be a function`)
|
||||
}
|
||||
@@ -96,6 +114,7 @@ function registerDeliveryChannel(def) {
|
||||
supportsDigest,
|
||||
addressFor: def.addressFor,
|
||||
deliver: def.deliver,
|
||||
eligible: def.eligible,
|
||||
})
|
||||
return id
|
||||
}
|
||||
@@ -103,13 +122,14 @@ function registerDeliveryChannel(def) {
|
||||
/**
|
||||
* Every channel, in registration order. The preferences screen's column set.
|
||||
*
|
||||
* **Declarative fields only** — `addressFor` and `deliver` are stripped. This is
|
||||
* what a route serializes, and a function on an object bound for `res.json` is a
|
||||
* key that silently disappears rather than an error; keeping the boundary here
|
||||
* means the API shape is decided in one place instead of by JSON.stringify.
|
||||
* **Declarative fields only** — `addressFor`, `deliver` and `eligible` are
|
||||
* stripped. This is what a route serializes, and a function on an object bound
|
||||
* for `res.json` is a key that silently disappears rather than an error; keeping
|
||||
* the boundary here means the API shape is decided in one place instead of by
|
||||
* JSON.stringify.
|
||||
*/
|
||||
const all = () =>
|
||||
[...channels.values()].map(({ addressFor, deliver, ...declared }) => ({ ...declared }))
|
||||
[...channels.values()].map(({ addressFor, deliver, eligible, ...declared }) => ({ ...declared }))
|
||||
|
||||
/** Just the ids. */
|
||||
const ids = () => [...channels.keys()]
|
||||
@@ -137,6 +157,25 @@ const modesFor = (id) => {
|
||||
/** Is `mode` a mode this channel accepts? The gate on every preference write. */
|
||||
const acceptsMode = (id, mode) => modesFor(id).includes(mode)
|
||||
|
||||
/**
|
||||
* Narrow an audience to the users this channel may enqueue for (Phase 9).
|
||||
*
|
||||
* The default for a channel that declares no `eligible` is "everyone the
|
||||
* audience resolved to", which is what every channel but email does. It lives
|
||||
* here rather than at each call site so the two consumers — the engine and the
|
||||
* admin reach preview — cannot answer the question differently, which is exactly
|
||||
* how a preview comes to promise a number the engine will not deliver.
|
||||
*/
|
||||
async function eligibleFor(id, userIds) {
|
||||
const c = channels.get(id)
|
||||
if (!c || typeof c.eligible !== 'function') return { userIds: userIds.slice(), excluded: {} }
|
||||
const result = await c.eligible(userIds)
|
||||
return {
|
||||
userIds: (result && result.userIds) || [],
|
||||
excluded: (result && result.excluded) || {},
|
||||
}
|
||||
}
|
||||
|
||||
// Test-only: the registry is module-level state.
|
||||
function _reset() {
|
||||
channels.clear()
|
||||
@@ -153,5 +192,6 @@ module.exports = {
|
||||
defaultMode,
|
||||
modesFor,
|
||||
acceptsMode,
|
||||
eligibleFor,
|
||||
_reset,
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ const CHANNELS = [
|
||||
// time (§4.2b), which is a different delivery path rather than a batched one.
|
||||
addressFor: emailChannel.addressFor,
|
||||
deliver: emailChannel.deliver,
|
||||
// Phase 9: the only channel that declares one. The Phase 1b verification
|
||||
// gate is an email fact, and this is the seam that keeps it out of the
|
||||
// generic engine - see channels.js's `eligible` docs.
|
||||
eligible: emailChannel.eligible,
|
||||
},
|
||||
{
|
||||
id: 'inapp',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ── The four Team rules core ships, all of them OFF ────────────────────────
|
||||
// ── The five rules core ships, all of them OFF ─────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 6, decision 3. Before this phase the Team pipeline mailed
|
||||
// people with no operator configuration at all: the code decided who was mailed
|
||||
@@ -24,6 +24,17 @@
|
||||
// that has seen this seed never sees it again — deleted rules stay deleted, and
|
||||
// an enabled rule stays enabled rather than being reset to off.
|
||||
|
||||
// **Phase 11 added a fifth, for `news.post`, and it needed its OWN one-shot key
|
||||
// rather than an entry in the list above.** The Team key is already stamped on
|
||||
// every deployment that has booted since Phase 6, and the guard reads its
|
||||
// presence — so appending to `RULES` would have seeded the news rule on fresh
|
||||
// installs only, and on exactly the upgrades that need it, never. Those are the
|
||||
// deployments where `pushDispatch.publish('news.post', …)` used to run and no
|
||||
// longer does (§7.1 Q9): they would have lost news push with no rule to switch
|
||||
// on and no way to tell why. One key per seed GROUP is the rule this establishes;
|
||||
// a sixth rule for a new trigger takes a sixth key, and a rule added to an
|
||||
// existing group is a rule that only fresh installs will ever see.
|
||||
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const settingsDb = require('../model/settings/settings.db')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
@@ -32,6 +43,9 @@ const log = require('../utils/logger')('engagement')
|
||||
// the settings table can tell when it ran; only its presence is read.
|
||||
const SEEDED_KEY = 'engagement_team_rules_seeded'
|
||||
|
||||
// Phase 11's, and separate for the reason above. Same shape, same semantics.
|
||||
const NEWS_SEEDED_KEY = 'engagement_news_rule_seeded'
|
||||
|
||||
const RULES = [
|
||||
{
|
||||
trigger_id: 'team.forum.post',
|
||||
@@ -98,20 +112,57 @@ const RULES = [
|
||||
},
|
||||
]
|
||||
|
||||
// Phase 11's one rule, in its own list so it can carry its own one-shot key.
|
||||
const NEWS_RULES = [
|
||||
{
|
||||
trigger_id: 'news.post',
|
||||
name: 'News posts',
|
||||
// `subscribers`, which is the trigger's declared default and the population
|
||||
// `pushDispatch.publish('news.post', …)` used to reach directly: users who
|
||||
// opted into this id on at least one channel. Not `authenticated`, even
|
||||
// though the trigger's ceiling permits it — a news post is worth telling
|
||||
// people who asked to be told, and mailing the whole user table on every
|
||||
// publish is how a notification feature earns a spam complaint.
|
||||
audience: 'subscribers',
|
||||
// **All three channels, unlike the Team rules' `email` alone**, and that is
|
||||
// the continuity half of §7.1 Q9's answer. Push is on this rule because push
|
||||
// is what the raw tickle did; leaving it off would mean an operator who
|
||||
// enabled the rule to restore news push got mail instead. In-app rides along
|
||||
// because the inbox is the surface a tickle deep-links into (Phase 7).
|
||||
channels: ['email', 'inapp', 'push'],
|
||||
// The generic body plus the structural projection (§4.6.1 property 1):
|
||||
// `news.post` declares its own `title` and `postUrl`, which the projection
|
||||
// leaves exactly as emitted, so an unauthored mail already names the post and
|
||||
// links it. `inapp.event` is the in-app renderer's; push carries no content
|
||||
// by construction and needs no template.
|
||||
template_keys: { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' },
|
||||
// An hour, per USER — `news.post` declares no `subjectKey`, so the cooldown
|
||||
// subject is the recipient. "Do not tell me about news more than once an
|
||||
// hour" is the useful rule; keying it per post would make it a no-op, since
|
||||
// every post is a new subject.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 1000,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Seed the four rules, once. Returns a small summary for the boot log.
|
||||
* Seed one group of rules, once, under its own guard key.
|
||||
*
|
||||
* Never throws: it is on the boot path beside `seedTemplates`, and a rule that
|
||||
* failed to seed costs an operator one visit to the "new rule" form, not a
|
||||
* deployment.
|
||||
*
|
||||
* @param {string} key the one-shot settings guard for THIS group
|
||||
* @param {object[]} rules
|
||||
* @param {string} note what the boot log should say when it inserts
|
||||
*/
|
||||
async function seedTeamRules() {
|
||||
async function seedGroup(key, rules, note) {
|
||||
const summary = { inserted: 0, skipped: 0 }
|
||||
try {
|
||||
const seen = await settingsDb.get(SEEDED_KEY)
|
||||
if (seen) return { ...summary, skipped: RULES.length }
|
||||
const seen = await settingsDb.get(key)
|
||||
if (seen) return { ...summary, skipped: rules.length }
|
||||
|
||||
for (const rule of RULES) {
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
await rulesDb.insert({
|
||||
audience_segment_id: null,
|
||||
@@ -133,17 +184,46 @@ async function seedTeamRules() {
|
||||
// Stamped even on a partial run. Re-running would duplicate the rules that
|
||||
// did insert, and a duplicate rule is two mails per event — a worse outcome
|
||||
// than the one missing rule an operator can add from the screen.
|
||||
await settingsDb.set(SEEDED_KEY, new Date().toISOString())
|
||||
await settingsDb.set(key, new Date().toISOString())
|
||||
if (summary.inserted) {
|
||||
log.info('seeded Team engagement rules, all disabled', {
|
||||
rules: summary.inserted,
|
||||
note: 'Team email stays off until an operator enables one',
|
||||
})
|
||||
log.info('seeded engagement rules, all disabled', { rules: summary.inserted, note })
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('team rule seeding failed', { message: err.message })
|
||||
log.error('rule seeding failed', { key, message: err.message })
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
module.exports = { seedTeamRules, RULES, SEEDED_KEY }
|
||||
/** The four Team rules (Phase 6). */
|
||||
const seedTeamRules = () =>
|
||||
seedGroup(SEEDED_KEY, RULES, 'Team email stays off until an operator enables one')
|
||||
|
||||
/** The one news rule (Phase 11). */
|
||||
const seedNewsRule = () =>
|
||||
seedGroup(NEWS_SEEDED_KEY, NEWS_RULES, 'News notifications stay off until an operator enables this rule')
|
||||
|
||||
/**
|
||||
* Both groups, which is what the boot path calls.
|
||||
*
|
||||
* Sequential rather than concurrent, and not for correctness — each group has its
|
||||
* own guard key and its own rows — but so the boot log reads in a fixed order and
|
||||
* a failure names one group rather than an interleaving of two.
|
||||
*/
|
||||
async function seedCoreRules() {
|
||||
const team = await seedTeamRules()
|
||||
const news = await seedNewsRule()
|
||||
return {
|
||||
inserted: team.inserted + news.inserted,
|
||||
skipped: team.skipped + news.skipped,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
seedCoreRules,
|
||||
seedTeamRules,
|
||||
seedNewsRule,
|
||||
RULES,
|
||||
NEWS_RULES,
|
||||
SEEDED_KEY,
|
||||
NEWS_SEEDED_KEY,
|
||||
}
|
||||
|
||||
@@ -29,11 +29,12 @@
|
||||
// `teamName` (a display string the cooldown keys on) and the scope is `team:12`.
|
||||
// A Team renamed between the mail and the click must not orphan the link in it.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const recipients = require('../model/engagement/engagementRecipients.db')
|
||||
const templates = require('./templates')
|
||||
const projection = require('./projection')
|
||||
const suppressions = require('./suppressions')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const unsubscribeToken = require('../utils/unsubscribeToken')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
@@ -54,12 +55,10 @@ const DEFAULT_TEMPLATE = 'notify.event'
|
||||
|
||||
const baseUrl = () => templates.baseUrl()
|
||||
|
||||
// Lower-cased first: a bounce reported for "Darrow@example.com" has to match the
|
||||
// row written for "darrow@example.com", and a hash of two spellings is two
|
||||
// different rows. The local part is technically case-sensitive per RFC 5321 and
|
||||
// no relay anybody deploys treats it that way.
|
||||
const hashAddress = (address) =>
|
||||
crypto.createHash('sha256').update(String(address).trim().toLowerCase()).digest('hex')
|
||||
// Re-exported rather than defined here since Phase 9: the send log's hash and
|
||||
// the suppression list's key have to be the same function or a bounce never finds
|
||||
// the row it belongs to. `suppressions.js` owns it, next to the masking.
|
||||
const hashAddress = suppressions.hashAddress
|
||||
|
||||
/**
|
||||
* The two unsubscribe URLs for one recipient of one scope, or nulls.
|
||||
@@ -94,6 +93,52 @@ function unsubscribeUrls(userId, scopeKey) {
|
||||
/** Where this channel would send to, or null. */
|
||||
const addressFor = (userId) => recipients.addressFor(userId)
|
||||
|
||||
/**
|
||||
* Narrow an audience to the users this channel may write an outbox row for
|
||||
* (Phase 9, decision 4).
|
||||
*
|
||||
* **One gate, and it is the Phase 1b verification setting.** With
|
||||
* `email_verification_required` on, a user whose address is unverified is
|
||||
* excluded here rather than refused at delivery, and the org lead settled it that
|
||||
* way for two reasons. It is a STANDING property — unlike a suppression, which
|
||||
* can appear inside a `delay_seconds` window and therefore has to be re-checked
|
||||
* at send time — so the outbox row would be written only to be thrown away. And a
|
||||
* deployment that upgraded before verifying anybody has an audience that is
|
||||
* almost entirely unverified: excluding at delivery would write a `suppressed`
|
||||
* row per person per rule firing, which is a send log nobody can read.
|
||||
*
|
||||
* The count comes back so the admin reach preview can say "1,204 excluded:
|
||||
* unverified" instead of quietly promising a number the engine will not deliver.
|
||||
*
|
||||
* **It fails OPEN, and the try/catch is load-bearing rather than defensive
|
||||
* habit.** `settings.isEmailVerificationRequired` swallows its own errors and
|
||||
* answers `off`, but `unverifiedAmong` does not, and an uncaught throw here does
|
||||
* not fail one recipient — `applyRule` awaits this before the per-user loop, so
|
||||
* it would abandon the whole rule for every channel it names. A database having
|
||||
* a bad minute would become a rule that silently sent nothing, with a clean send
|
||||
* log and nothing in the outbox to retry. Same direction as the suppression
|
||||
* check, for the same reason: the recoverable mistake is mail going out, not mail
|
||||
* silently stopping.
|
||||
*/
|
||||
async function eligible(userIds) {
|
||||
const list = userIds || []
|
||||
if (!list.length) return { userIds: [], excluded: {} }
|
||||
try {
|
||||
if (!(await settings.isEmailVerificationRequired())) {
|
||||
return { userIds: list.slice(), excluded: {} }
|
||||
}
|
||||
const unverified = await recipients.unverifiedAmong(list)
|
||||
if (!unverified.size) return { userIds: list.slice(), excluded: {} }
|
||||
return {
|
||||
userIds: list.filter((id) => !unverified.has(Number(id))),
|
||||
excluded: { unverified: unverified.size },
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('verification gate could not be evaluated; not excluding anyone', { message: err.message })
|
||||
return { userIds: list.slice(), excluded: {} }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one claimed outbox row.
|
||||
*
|
||||
@@ -130,11 +175,60 @@ async function deliver(row) {
|
||||
log.debug('template variables had no value', { key, missing: rendered.missing })
|
||||
}
|
||||
|
||||
// **The suppression check is HERE and not at enqueue** (Phase 9). An outbox
|
||||
// row can sit through a `delay_seconds` grace window, and an address can hard
|
||||
// bounce inside it — so the only check that can be correct is the one taken
|
||||
// immediately before the transport call. It is also the check the acceptance
|
||||
// criterion describes: a `suppressed` row in the send log, and no transport
|
||||
// call at all.
|
||||
const blocked = await suppressions.isSuppressed(to.address)
|
||||
if (blocked) {
|
||||
return {
|
||||
ok: false,
|
||||
suppressed: true,
|
||||
detail: `address is suppressed (${blocked.reason})`,
|
||||
addressHash: hashAddress(to.address),
|
||||
}
|
||||
}
|
||||
|
||||
const result = await mailer().sendNotification({ to: to.address, rendered, ...unsub })
|
||||
|
||||
// A failed send is where a hard bounce enters the system on SMTP, and the
|
||||
// reason it is worth catching rather than waiting for an API provider: a
|
||||
// single-recipient send refused at RCPT TO is a synchronous 5.1.1, which is
|
||||
// the most valuable deliverability signal there is and it was already being
|
||||
// thrown away. `considerFailure` is narrow — see bounceClassify.js — and its
|
||||
// note goes into the log on BOTH outcomes, so "this failed and was not
|
||||
// suppressed" says why.
|
||||
if (result && !result.ok && result.smtp) {
|
||||
const verdict = await suppressions.considerFailure({ address: to.address, error: result.smtp })
|
||||
// A bounce is terminal by definition. Overriding `retry` matters because
|
||||
// `PERMANENT_CODES` does not contain every code that can carry a 5.1.x, so
|
||||
// without this a genuine dead mailbox could still be retried four more
|
||||
// times — each one another refusal on our record with the relay.
|
||||
if (verdict.suppressed) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
// `engagement_sends.status` has carried 'bounced' since §4.5 and
|
||||
// nothing wrote it until here, so the Send Log's "Bounced" filter
|
||||
// matched nothing — the live rig is what showed that. It is a distinct
|
||||
// status rather than a flavour of 'failed' because the two need
|
||||
// different actions: a failure means look at the relay, and a bounce
|
||||
// means that person's address is gone.
|
||||
bounced: true,
|
||||
transport: result.transport,
|
||||
detail: `${result.detail} — ${verdict.note}`,
|
||||
addressHash: hashAddress(to.address),
|
||||
}
|
||||
}
|
||||
return { ...result, detail: `${result.detail} — ${verdict.note}`, addressHash: hashAddress(to.address) }
|
||||
}
|
||||
|
||||
// The send log stores a sha256 of the address and never the address itself
|
||||
// (schema.sql): enough to correlate a bounce in Phase 9, useless as a mailing
|
||||
// list. Attached on every outcome, because a failure is exactly the row a
|
||||
// bounce would need to be matched against.
|
||||
// (schema.sql): enough to correlate a bounce, useless as a mailing list.
|
||||
// Attached on every outcome, because a failure is exactly the row a bounce
|
||||
// would need to be matched against.
|
||||
return { ...result, addressHash: hashAddress(to.address) }
|
||||
} catch (err) {
|
||||
// See the header: a throw here would be retried as if it were the relay's
|
||||
@@ -144,4 +238,4 @@ async function deliver(row) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { addressFor, deliver, unsubscribeUrls, hashAddress, DEFAULT_TEMPLATE }
|
||||
module.exports = { addressFor, eligible, deliver, unsubscribeUrls, hashAddress, DEFAULT_TEMPLATE }
|
||||
|
||||
@@ -120,7 +120,18 @@ async function subscribedTo(userIds, streamId, channel, scopeKey = null) {
|
||||
* for tests; it is not read by the caller for control flow.
|
||||
*/
|
||||
async function applyRule(rule, event, now) {
|
||||
const summary = { ruleId: rule.id, enqueued: 0, deduped: 0, cooled: 0, capped: 0, skipped: null }
|
||||
const summary = {
|
||||
ruleId: rule.id,
|
||||
enqueued: 0,
|
||||
deduped: 0,
|
||||
cooled: 0,
|
||||
capped: 0,
|
||||
// Phase 9: people a CHANNEL refused to enqueue for, by reason. Counted
|
||||
// separately from `cooled` and `capped` because those are the engine holding
|
||||
// a message back and this is a channel saying it cannot carry one at all.
|
||||
ineligible: {},
|
||||
skipped: null,
|
||||
}
|
||||
|
||||
if (!conditions.evaluate(rule.conditions, event.data)) {
|
||||
summary.skipped = 'conditions'
|
||||
@@ -176,7 +187,17 @@ async function applyRule(rule, event, now) {
|
||||
const dueAt = new Date(now.getTime() + Math.max(0, rule.delay_seconds) * 1000)
|
||||
|
||||
for (const channel of live) {
|
||||
const eligible = await subscribedTo(resolved.userIds, event.triggerId, channel, event.scopeKey)
|
||||
// Phase 9, and it runs BEFORE the preference filter rather than after. Both
|
||||
// orders reach the same recipients; this one costs one query against the
|
||||
// narrower set only when the channel declares an `eligible` at all, and it
|
||||
// means `summary.ineligible` counts people the CHANNEL cannot reach rather
|
||||
// than people who happened to also be opted in. Channels that declare none —
|
||||
// push and in-app — pass straight through.
|
||||
const gated = await channels.eligibleFor(channel, resolved.userIds)
|
||||
for (const [why, n] of Object.entries(gated.excluded)) {
|
||||
summary.ineligible[why] = (summary.ineligible[why] || 0) + n
|
||||
}
|
||||
const eligible = await subscribedTo(gated.userIds, event.triggerId, channel, event.scopeKey)
|
||||
for (const userId of eligible) {
|
||||
if (budget <= 0) {
|
||||
summary.capped += 1
|
||||
|
||||
160
server/src/engagement/suppressions.js
Normal file
160
server/src/engagement/suppressions.js
Normal file
@@ -0,0 +1,160 @@
|
||||
// ── The suppression list ───────────────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md G16, Phase 9. Addresses this deployment has stopped mailing,
|
||||
// and the two questions asked of them: "may I send to this one?" at delivery
|
||||
// time, and "why did this one stop?" on the admin screen.
|
||||
//
|
||||
// **Scope: the engagement email channel only** (Phase 9 decision 2). A password
|
||||
// reset, an invite, a verification mail and the contact form are all
|
||||
// user-INITIATED and still attempt, exactly as they still attempt to an
|
||||
// unverified address (`passwordReset.controller.js`). The posture is the same one
|
||||
// that file already states: a background system's opinion about an address must
|
||||
// not be able to lock somebody out of their own account. One reset to a dead
|
||||
// mailbox is not a reputation problem; a rule mailing three thousand people every
|
||||
// week is, and that is what this list guards.
|
||||
//
|
||||
// **The table holds a hash and a mask, never an address.** The hash is what
|
||||
// correlates a bounce back to an `engagement_sends` row (Phase 6 was already
|
||||
// writing `address_hash` on every outcome for this). The mask —
|
||||
// `d***@example.com` — is Phase 9's one addition to §4.5's DDL and exists because
|
||||
// a screen of sha256 digests cannot be operated: an operator has to be able to
|
||||
// see that a whole domain is refusing mail, and to find the person who fixed
|
||||
// their mailbox and let them back in. The local part is DESTROYED rather than
|
||||
// shortened, so the column cannot be turned back into an address book.
|
||||
//
|
||||
// **Nothing here writes a suppression from "the send failed".** What may write
|
||||
// one is `bounceClassify.classify`, which is a much narrower question — see that
|
||||
// file's header for why reusing `mailer.PERMANENT_CODES` would have suppressed
|
||||
// every address the moment an SMTP password went stale.
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const db = require('../model/engagement/engagementSuppressions.db')
|
||||
const bounceClassify = require('./bounceClassify')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
const REASONS = ['bounce', 'complaint', 'manual', 'unverified']
|
||||
|
||||
/**
|
||||
* The key an address is stored under.
|
||||
*
|
||||
* Lower-cased first, and that matters more here than anywhere else in the
|
||||
* subsystem: a bounce reported for `Darrow@example.com` has to find the row
|
||||
* written for `darrow@example.com`, and a hash of two spellings is two rows that
|
||||
* never meet. RFC 5321 says the local part is technically case-sensitive; no
|
||||
* relay anybody deploys treats it that way.
|
||||
*/
|
||||
const hashAddress = (address) =>
|
||||
crypto.createHash('sha256').update(String(address).trim().toLowerCase()).digest('hex')
|
||||
|
||||
/**
|
||||
* `darrow@example.com` → `d***@example.com`. Null for anything that is not an
|
||||
* address.
|
||||
*
|
||||
* The domain survives intact because domain-level patterns are the signal an
|
||||
* operator is actually looking for — "everything to this company is bouncing" is
|
||||
* a different problem from three people mistyping their own address, and only the
|
||||
* domain distinguishes them.
|
||||
*
|
||||
* The first character of the local part survives only when there are at least
|
||||
* three, which is not fussiness: for a two-letter local part, one revealed
|
||||
* character plus the domain is most of the address.
|
||||
*/
|
||||
function maskAddress(address) {
|
||||
const s = String(address || '').trim()
|
||||
const at = s.lastIndexOf('@')
|
||||
if (at <= 0 || at === s.length - 1) return null
|
||||
const local = s.slice(0, at)
|
||||
const domain = s.slice(at + 1).toLowerCase()
|
||||
const head = local.length >= 3 ? local[0].toLowerCase() : ''
|
||||
return `${head}***@${domain}`.slice(0, 190)
|
||||
}
|
||||
|
||||
/** Is this address suppressed on this channel? */
|
||||
async function isSuppressed(address, channel = 'email') {
|
||||
if (!address) return null
|
||||
try {
|
||||
return await db.get(hashAddress(address), channel)
|
||||
} catch (err) {
|
||||
// Fail OPEN, and the direction is deliberate. A database that cannot answer
|
||||
// "is this suppressed" must not stop the deployment's mail; the failure mode
|
||||
// it would otherwise produce is total silence with a clean send log, which is
|
||||
// exactly G22's shape. Mailing one dead address during an outage is the
|
||||
// cheaper mistake.
|
||||
log.error('suppression check failed; sending anyway', { message: err.message })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress an address. Returns true when this call created the row.
|
||||
*
|
||||
* `reason` is validated rather than trusted: it is an ENUM in the schema, so an
|
||||
* unknown value is a 500 from the driver at the worst possible moment (inside a
|
||||
* failure handler), and the callers include an admin route.
|
||||
*/
|
||||
async function suppress({ address, reason, detail = null, channel = 'email', createdBy = null }) {
|
||||
if (!address) return false
|
||||
if (!REASONS.includes(reason)) throw new Error(`suppress: unknown reason "${reason}"`)
|
||||
const created = await db.add({
|
||||
address_hash: hashAddress(address),
|
||||
address_masked: maskAddress(address),
|
||||
channel,
|
||||
reason,
|
||||
detail,
|
||||
created_by: createdBy,
|
||||
})
|
||||
if (created) {
|
||||
// Masked, never the address — the same rule every other log line in this
|
||||
// subsystem follows. It is logged at all because an address dropping off the
|
||||
// mailing list is the kind of change an operator finds out about weeks later
|
||||
// otherwise.
|
||||
log.info('address suppressed', { address: maskAddress(address), reason, channel })
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
/** Un-suppress. Returns true when a row was removed. */
|
||||
async function unsuppress(address, channel = 'email') {
|
||||
if (!address) return false
|
||||
const removed = await db.remove(hashAddress(address), channel)
|
||||
if (removed) log.info('suppression lifted', { address: maskAddress(address), channel })
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Consider a failed send for suppression, and say what was decided.
|
||||
*
|
||||
* The seam between a delivery failure and this list, and the only one — nothing
|
||||
* else in the codebase writes a `bounce` row. Called from `emailChannel.deliver`
|
||||
* with the error the transport threw.
|
||||
*
|
||||
* @returns {Promise<{suppressed: boolean, note: string}>} `note` goes into the
|
||||
* send log's detail, on both outcomes.
|
||||
*/
|
||||
async function considerFailure({ address, error, channel = 'email' }) {
|
||||
const verdict = bounceClassify.classify(error)
|
||||
if (!verdict.suppress) {
|
||||
return { suppressed: false, note: `not suppressed (${verdict.reason})` }
|
||||
}
|
||||
try {
|
||||
const detail = verdict.evidence ? `hard bounce: ${verdict.evidence}` : 'hard bounce'
|
||||
await suppress({ address, reason: 'bounce', detail, channel })
|
||||
return { suppressed: true, note: detail }
|
||||
} catch (err) {
|
||||
// A failure to record the suppression must not change how the send itself is
|
||||
// reported. The mail failed either way, and that is the row the log owes.
|
||||
log.error('could not record a suppression', { message: err.message })
|
||||
return { suppressed: false, note: `hard bounce, not recorded: ${err.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hashAddress,
|
||||
maskAddress,
|
||||
isSuppressed,
|
||||
suppress,
|
||||
unsuppress,
|
||||
considerFailure,
|
||||
REASONS,
|
||||
}
|
||||
@@ -132,11 +132,14 @@ const storedModes = async (userIds, streamId, channel) => {
|
||||
* between the emit and the send is exactly the case this catches. The cost is one
|
||||
* primary-key lookup on a path that is about to open an SMTP conversation.
|
||||
*
|
||||
* **It does not gate on `email_verified`.** Whether an unverified address may
|
||||
* receive opt-in mail is §7.1 Q1's narrower half, and it is a Phase 9 decision
|
||||
* with the suppression list in front of it; deciding it here by accident would
|
||||
* mean every deployment that upgraded before verifying its users stopped mailing
|
||||
* them.
|
||||
* **It still does not gate on `email_verified`, and Phase 9 kept it that way.**
|
||||
* §7.1 Q1's narrower half was settled by the org lead 2026-08-31: the gate
|
||||
* excludes an unverified user at ENQUEUE, in `emailChannel.eligible`, so no
|
||||
* outbox row is written and the admin reach preview can say how many were
|
||||
* dropped. Adding the same condition here as well would look like defence in
|
||||
* depth and would in fact be a second, invisible answer to the question — this
|
||||
* function is also what a password reset would reach if it ever routed through
|
||||
* the channel, and reset mail is deliberately ungated (`passwordReset.controller.js`).
|
||||
*/
|
||||
const addressFor = async (userId) => {
|
||||
const rows = await query(
|
||||
@@ -147,4 +150,63 @@ const addressFor = async (userId) => {
|
||||
return rows.length ? { address: rows[0].email } : null
|
||||
}
|
||||
|
||||
module.exports = { active, staff, subscribers, filterActive, storedModes, addressFor, MAX_AUDIENCE }
|
||||
/**
|
||||
* Which of these users hold an UNVERIFIED address - the set the Phase 1b gate
|
||||
* excludes when it is on (Phase 9).
|
||||
*
|
||||
* A user with no address at all is in this set, and that is not incidental: the
|
||||
* gate's question is "may we mail this person", and nowhere to send is a stronger
|
||||
* no than an unconfirmed somewhere. `addressFor` refuses them at delivery either
|
||||
* way; including them here is what stops an outbox row being written for a send
|
||||
* that is already known to be impossible.
|
||||
*
|
||||
* One query for a whole audience. The engine calls it once per rule per event
|
||||
* with up to MAX_AUDIENCE ids, so a per-user lookup would be five thousand round
|
||||
* trips on the path that is supposed to be the cheap one.
|
||||
*/
|
||||
const unverifiedAmong = async (userIds) => {
|
||||
const wanted = [...new Set(userIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||||
if (!wanted.length) return new Set()
|
||||
const capped = wanted.slice(0, MAX_AUDIENCE)
|
||||
const rows = await query(
|
||||
`SELECT id FROM users
|
||||
WHERE id IN (${marks(capped)})
|
||||
AND (email_verified = 0 OR email IS NULL OR email = '')`,
|
||||
capped,
|
||||
)
|
||||
return new Set(ids(rows))
|
||||
}
|
||||
|
||||
/**
|
||||
* The addresses for a set of active users, as a Map - what the admin reach
|
||||
* preview hashes to count how many of them are suppressed.
|
||||
*
|
||||
* It returns PLAINTEXT, which is the one thing this subsystem otherwise avoids,
|
||||
* and there is no way around it: a suppression is keyed on the sha256 of an
|
||||
* address, so answering "how many of these people are suppressed" requires
|
||||
* hashing each one. The caller (`engagement.controller`) hashes immediately and
|
||||
* returns only a count - no route ever serializes what this returns.
|
||||
*/
|
||||
const addressesFor = async (userIds) => {
|
||||
const wanted = [...new Set(userIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||||
if (!wanted.length) return new Map()
|
||||
const capped = wanted.slice(0, MAX_AUDIENCE)
|
||||
const rows = await query(
|
||||
`SELECT id, email FROM users
|
||||
WHERE id IN (${marks(capped)}) AND status = 'active' AND email IS NOT NULL AND email <> ''`,
|
||||
capped,
|
||||
)
|
||||
return new Map(rows.map((r) => [Number(r.id), r.email]))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
active,
|
||||
staff,
|
||||
subscribers,
|
||||
filterActive,
|
||||
storedModes,
|
||||
addressFor,
|
||||
unverifiedAmong,
|
||||
addressesFor,
|
||||
MAX_AUDIENCE,
|
||||
}
|
||||
|
||||
139
server/src/model/engagement/engagementSuppressions.db.js
Normal file
139
server/src/model/engagement/engagementSuppressions.db.js
Normal file
@@ -0,0 +1,139 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
/**
|
||||
* The suppression list (ENGAGEMENT.md G16, Phase 9).
|
||||
*
|
||||
* Every function here takes an ALREADY HASHED address. Hashing lives in
|
||||
* `engagement/suppressions.js` beside the masking, so the two can never disagree
|
||||
* about what a row for one address looks like; this file only reads and writes.
|
||||
*/
|
||||
|
||||
/** Is this address suppressed on this channel? The row, or null. */
|
||||
const get = async (addressHash, channel = 'email') => {
|
||||
const rows = await query(
|
||||
'SELECT * FROM engagement_suppressions WHERE address_hash = ? AND channel = ?',
|
||||
[addressHash, channel],
|
||||
)
|
||||
return rows.length ? rows[0] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress an address, or leave an existing row exactly as it is.
|
||||
*
|
||||
* `INSERT IGNORE`, deliberately, rather than an upsert. The FIRST reason an
|
||||
* address was suppressed is the true one and the one an operator needs: an
|
||||
* address that hard-bounced in March and was then manually re-added in June
|
||||
* should still read `bounce`, because that is the fact that explains the mail
|
||||
* stopping. An upsert would let the most recent write overwrite the diagnosis.
|
||||
*
|
||||
* @returns {Promise<boolean>} true when this call created the row
|
||||
*/
|
||||
const add = async (entry) => {
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO engagement_suppressions
|
||||
(address_hash, address_masked, channel, reason, detail, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
entry.address_hash,
|
||||
entry.address_masked ?? null,
|
||||
entry.channel || 'email',
|
||||
entry.reason,
|
||||
entry.detail ? String(entry.detail).slice(0, 500) : null,
|
||||
entry.created_by ?? null,
|
||||
],
|
||||
)
|
||||
return Number(result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Un-suppress an address. The one way out of this table, which is why the admin
|
||||
* screen exists at all (Phase 9 decision 3).
|
||||
*
|
||||
* @returns {Promise<boolean>} true when a row was removed
|
||||
*/
|
||||
const remove = async (addressHash, channel = 'email') => {
|
||||
const result = await query(
|
||||
'DELETE FROM engagement_suppressions WHERE address_hash = ? AND channel = ?',
|
||||
[addressHash, channel],
|
||||
)
|
||||
return Number(result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/** WHERE-clause builder shared by `list` and `count`, so the two cannot disagree. */
|
||||
const filters = ({ reason = null, channel = null, search = null } = {}) => {
|
||||
const where = []
|
||||
const params = []
|
||||
if (reason) {
|
||||
where.push('reason = ?')
|
||||
params.push(reason)
|
||||
}
|
||||
if (channel) {
|
||||
where.push('channel = ?')
|
||||
params.push(channel)
|
||||
}
|
||||
if (search) {
|
||||
// Against the MASKED column only. Searching the hash would need the caller to
|
||||
// hash first, which makes a partial search impossible, and there is no
|
||||
// plaintext column to search — that is the point of the table. A domain
|
||||
// ("example.com") is what an operator actually types here, and the mask keeps
|
||||
// the domain intact precisely so this works.
|
||||
where.push('address_masked LIKE ?')
|
||||
params.push(`%${String(search).slice(0, 120)}%`)
|
||||
}
|
||||
return { clause: where.length ? `WHERE ${where.join(' AND ')}` : '', params }
|
||||
}
|
||||
|
||||
/** The admin list, newest first. */
|
||||
const list = (opts = {}) => {
|
||||
const { clause, params } = filters(opts)
|
||||
return query(
|
||||
`SELECT * FROM engagement_suppressions ${clause} ORDER BY created_at DESC, address_hash LIMIT ? OFFSET ?`,
|
||||
[...params, opts.limit || 50, opts.offset || 0],
|
||||
)
|
||||
}
|
||||
|
||||
/** How many rows match the same filters — the paged screen's total. */
|
||||
const count = async (opts = {}) => {
|
||||
const { clause, params } = filters(opts)
|
||||
const [row] = await query(`SELECT COUNT(*) AS n FROM engagement_suppressions ${clause}`, params)
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* How many suppressions per reason — the summary the screen leads with.
|
||||
*
|
||||
* A count by reason is the difference between "eleven addresses are suppressed"
|
||||
* and "eleven addresses hard-bounced", and only the second tells an operator
|
||||
* whether to go and look at their relay.
|
||||
*/
|
||||
const countsByReason = async () => {
|
||||
const rows = await query(
|
||||
'SELECT reason, COUNT(*) AS n FROM engagement_suppressions GROUP BY reason',
|
||||
)
|
||||
const out = {}
|
||||
for (const r of rows) out[r.reason] = Number(r.n || 0)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of these hashes that is suppressed, as a Set.
|
||||
*
|
||||
* One query for a whole audience rather than one per recipient. Not used by the
|
||||
* delivery path — that checks a single address at send time, after the delay
|
||||
* window, which is the only check that can be correct — but by the admin reach
|
||||
* preview, which is asked about thousands of users at once and must not become
|
||||
* thousands of round trips.
|
||||
*/
|
||||
const suppressedAmong = async (addressHashes, channel = 'email') => {
|
||||
const wanted = [...new Set((addressHashes || []).filter(Boolean))]
|
||||
if (!wanted.length) return new Set()
|
||||
const marks = wanted.map(() => '?').join(', ')
|
||||
const rows = await query(
|
||||
`SELECT address_hash FROM engagement_suppressions
|
||||
WHERE channel = ? AND address_hash IN (${marks})`,
|
||||
[channel, ...wanted],
|
||||
)
|
||||
return new Set(rows.map((r) => r.address_hash))
|
||||
}
|
||||
|
||||
module.exports = { get, add, remove, list, count, countsByReason, suppressedAmong }
|
||||
Binary file not shown.
@@ -20,16 +20,29 @@
|
||||
// ├── subscribers logged-in users who opted into this id
|
||||
// ├── members a module-declared list (a Team, the governors)
|
||||
// ├── staff admin / editor / moderator
|
||||
// │ └── admin admins only
|
||||
// └── owner the one user the event is about
|
||||
//
|
||||
// The four leaves are mutually INCOMPARABLE, deliberately. `owner` is not a
|
||||
// The four branches are mutually INCOMPARABLE, deliberately. `owner` is not a
|
||||
// subset of `subscribers` (an owner need not have subscribed), `staff` is not a
|
||||
// subset of `members`, and no pair of them has a common descendant. That is what
|
||||
// makes `meet()` below return null rather than guessing, and a null meet is a
|
||||
// refused save (§5.1a rule 3) rather than a silent widening.
|
||||
//
|
||||
// **`admin` was added in Phase 11 and it is the one genuine refinement in the
|
||||
// tree.** Phase 2 shipped six values, and §8.6 then turned out to describe three
|
||||
// triggers as admin-audience — `uo.audit.staff_action`, `uo.economy.milestone`,
|
||||
// `uo.world.saved` — for which the narrowest available value was `staff`, i.e.
|
||||
// admin / editor / moderator. Ceilinging them there would have permitted a rule
|
||||
// that mails the staff audit digest to every editor, which is the same class of
|
||||
// mistake the whole file exists to prevent. It is a CHILD rather than a seventh
|
||||
// leaf because every admin is staff — the containment the other pairs lack — and
|
||||
// that is why `permits`, `meet` and `meetAll` needed no change at all beyond the
|
||||
// new `PARENT` entry. It is a module-contract change (a module may now declare
|
||||
// `ceiling: 'admin'`) and took MODULE_API_VERSION to 1.8.0.
|
||||
//
|
||||
// Nothing here reaches the database, the network or a user record. It is
|
||||
// arithmetic over six constants, so it is safe to require anywhere.
|
||||
// arithmetic over seven constants, so it is safe to require anywhere.
|
||||
|
||||
// child → parent. A tree, which is what makes `permits` a walk to the root and
|
||||
// `meet` a comparison rather than a search: two nodes in a tree have a greatest
|
||||
@@ -40,6 +53,7 @@ const PARENT = {
|
||||
subscribers: 'authenticated',
|
||||
members: 'authenticated',
|
||||
staff: 'authenticated',
|
||||
admin: 'staff',
|
||||
owner: 'authenticated',
|
||||
}
|
||||
|
||||
@@ -51,6 +65,7 @@ const LABELS = {
|
||||
subscribers: 'Signed-in users subscribed to this event',
|
||||
members: 'Members of a module-declared list',
|
||||
staff: 'Staff only',
|
||||
admin: 'Administrators only',
|
||||
owner: 'Only the user the event is about',
|
||||
}
|
||||
|
||||
@@ -66,9 +81,50 @@ const LABELS = {
|
||||
// TOLD, which is the tier gate's population.
|
||||
const STAFF_CEILING_ROLES = ['admin', 'editor', 'moderator']
|
||||
|
||||
// And which the `admin` ceiling names. One role, and it is written as a list for
|
||||
// the same reason `STAFF_CEILING_ROLES` is: `recipients.staff(roles)` takes a
|
||||
// list, so the two ceilings resolve through one query rather than through two
|
||||
// that could drift.
|
||||
const ADMIN_CEILING_ROLES = ['admin']
|
||||
|
||||
/** Does this user fall inside the `staff` ceiling? */
|
||||
const isStaffRole = (role) => STAFF_CEILING_ROLES.includes(role)
|
||||
|
||||
/** Does this user fall inside the `admin` ceiling? */
|
||||
const isAdminRole = (role) => ADMIN_CEILING_ROLES.includes(role)
|
||||
|
||||
// The ceilings that name a ROLE, and the test for each. Two consumers read this
|
||||
// rather than asking about `staff` by name: the audience resolver, and the
|
||||
// preferences catalog's `visibleTo`.
|
||||
//
|
||||
// **`visibleTo` is why this is a table and not two `if`s.** Its rule is that an
|
||||
// id nobody outside a role can ever be reached by must not appear by name in a
|
||||
// player's preferences screen — `uo.cheat.detected` is the case the lattice was
|
||||
// written for. That rule was expressed as `ceiling !== 'staff'` when `staff` was
|
||||
// the only role-gated value; the day `admin` was added, that spelling would have
|
||||
// silently published every admin-ceilinged id to every player. A table cannot
|
||||
// drift the same way: adding a role-gated ceiling without adding it here is a
|
||||
// registration that fails its own test, not a leak.
|
||||
const ROLE_CEILINGS = {
|
||||
staff: { roles: STAFF_CEILING_ROLES, test: isStaffRole },
|
||||
admin: { roles: ADMIN_CEILING_ROLES, test: isAdminRole },
|
||||
}
|
||||
|
||||
/** Is this ceiling one that only certain roles can ever be reached by? */
|
||||
const isRoleCeiling = (ceiling) => Object.prototype.hasOwnProperty.call(ROLE_CEILINGS, ceiling)
|
||||
|
||||
/**
|
||||
* Could a viewer with this role EVER be reached by an id ceilinged here?
|
||||
*
|
||||
* The question a catalog asks before offering a toggle, and it fails closed: a
|
||||
* role-gated ceiling with no role, or an unknown role, is a no.
|
||||
*/
|
||||
function reachableBy(ceiling, role) {
|
||||
const gate = ROLE_CEILINGS[ceiling]
|
||||
if (!gate) return true
|
||||
return gate.test(role)
|
||||
}
|
||||
|
||||
const CEILINGS = Object.keys(PARENT)
|
||||
|
||||
/** Is this one of the six? The gate every registration and every rule save runs. */
|
||||
@@ -119,4 +175,18 @@ function meetAll(list) {
|
||||
return list.reduce((acc, next) => (acc === null ? null : meet(acc, next)), list[0])
|
||||
}
|
||||
|
||||
module.exports = { CEILINGS, LABELS, STAFF_CEILING_ROLES, isStaffRole, isCeiling, permits, meet, meetAll }
|
||||
module.exports = {
|
||||
CEILINGS,
|
||||
LABELS,
|
||||
STAFF_CEILING_ROLES,
|
||||
ADMIN_CEILING_ROLES,
|
||||
ROLE_CEILINGS,
|
||||
isStaffRole,
|
||||
isAdminRole,
|
||||
isRoleCeiling,
|
||||
reachableBy,
|
||||
isCeiling,
|
||||
permits,
|
||||
meet,
|
||||
meetAll,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,26 @@
|
||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
||||
// has nothing to say about a website module) and from any module's own version.
|
||||
|
||||
// 1.8.0 - a seventh value in the audience ceiling lattice: `admin`, a child of
|
||||
// `staff` (docs/website/ENGAGEMENT.md Phase 11, decision 1). A module may now
|
||||
// declare `ceiling: 'admin'` on a trigger or an audience, so the set of values
|
||||
// `registerEventTriggers` and `registerAudiences` accept grew. Additions only,
|
||||
// so minor: every declaration valid before is valid now, no stored value
|
||||
// changes, and module-uo's `coreApi: "^1.3.0"` still resolves.
|
||||
//
|
||||
// It exists because Phase 11's operator-facing triggers - `uo.audit.staff_action`,
|
||||
// `uo.economy.milestone`, `uo.world.saved` - are described everywhere as
|
||||
// admin-audience, and the narrowest value the lattice had was `staff`, which
|
||||
// means admin / editor / moderator. Ceilinging them there would have permitted a
|
||||
// rule that mails the staff audit digest to every editor.
|
||||
//
|
||||
// **What a module has to know about it beyond the new name.** `admin` is the one
|
||||
// pair in the tree with real containment - every admin is staff - so it is the
|
||||
// only place `permits` is true between two non-`authenticated` values:
|
||||
// `permits('staff', 'admin')` holds and nothing else of that shape does. A
|
||||
// trigger ceilinged `staff` therefore accepts an `admin` audience, which is the
|
||||
// intended narrowing, and the reverse is refused as it should be.
|
||||
|
||||
// 1.7.0 — the engagement contract (docs/website/ENGAGEMENT.md Phase 2).
|
||||
// Additions only, so minor: `api.registerEventTriggers([...])`,
|
||||
// `api.registerAudiences([...])`, `ctx.events.emit(triggerId, envelope)` and
|
||||
@@ -81,6 +101,6 @@
|
||||
// an admin action a module performs belongs in core's one audit log, the
|
||||
// extension slot needs the user its prefix names, and §2.7 forbids a module
|
||||
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
|
||||
const MODULE_API_VERSION = '1.7.0'
|
||||
const MODULE_API_VERSION = '1.8.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
|
||||
@@ -12,12 +12,12 @@ const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const emailConfig = require('../../../model/emailConfig/emailConfig.model')
|
||||
const emailDedupe = require('../../../model/emailDedupe/emailDedupe.model')
|
||||
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||
const pushDispatch = require('../../../utils/pushDispatch')
|
||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||
const { parseJsonSetting } = require('../../../utils/settingsJson')
|
||||
const { validateThemeVisual } = require('../../../utils/themeResolve')
|
||||
const { validateBrandAssets, resolveBrandAssets } = require('../../../utils/brandAssets')
|
||||
const { validateNavOverrides, resolveNavOverrides, NAV_KEYS } = require('../../../utils/navOverrides')
|
||||
const newsEmit = require('../../../utils/newsNotify')
|
||||
const htmlShell = require('../../../utils/htmlShell')
|
||||
|
||||
const log = require('../../../utils/logger')('admin')
|
||||
@@ -47,13 +47,36 @@ async function announceIfNewlyPublished(post, transition) {
|
||||
// sidecar hiccup cannot break saving a post: the same guarantee the enqueue
|
||||
// above gives.
|
||||
await registries.dispatchPostHook('onSaved', { post, transition })
|
||||
// Opt-in push tickle to news.post subscribers, on the same transition.
|
||||
// Fire-and-forget + self-guarding, so a dead ntfy relay never breaks saving.
|
||||
if (jobId) {
|
||||
Promise.resolve(pushDispatch.publish('news.post', { ref: String(post.id) })).catch((err) =>
|
||||
log.warn('news push failed', { postId: post.id, message: err.message }),
|
||||
)
|
||||
}
|
||||
// **The engagement engine, and it REPLACES the raw push tickle that used to be
|
||||
// here** (ENGAGEMENT.md §7.1 Q9, decided 2026-08-31 at the start of Phase 11).
|
||||
//
|
||||
// `news.post` has been a declared payload contract with no caller since Phase 2
|
||||
// — a rule naming it could never fire — so on a real deployment the only mail
|
||||
// or inbox item a rule could produce came from Teams. This is the call that
|
||||
// fixes that, and it is deliberately the only thing about this function that
|
||||
// changed: the announce legs above (a one-shot DELIVERY to a channel of the
|
||||
// deployment, with retry) and the post hooks (idempotent STATE mirroring, which
|
||||
// also runs on delete) are different KINDS of thing and both still fire exactly
|
||||
// as they did. `registries.js` already states those two apart; this adds a third
|
||||
// distinction of the same kind rather than replacing either.
|
||||
//
|
||||
// **What it replaced, and what that costs.** `pushDispatch.publish('news.post',
|
||||
// …)` stood here and tickled every subscriber directly. It is gone, so push now
|
||||
// rides the engine like every other channel — which means it goes nowhere until
|
||||
// an operator enables the `news.post` rule core seeds `enabled = 0` beside the
|
||||
// four Team ones (engagement/coreRules.js). That IS a behaviour change on
|
||||
// upgrade and it is the org lead's decision, taken over keeping the raw call
|
||||
// beside the emit "for one release": that is an exception with a deadline
|
||||
// nobody owns, and Phase 6 refused the analogous carve-out for Teams. The admin
|
||||
// Rules screen says so, and the release note names it.
|
||||
//
|
||||
// **Gated on `jobId`, the same value the push was gated on.** That is the single
|
||||
// "newly published news" transition test and re-deriving it here would be a
|
||||
// second chance to disagree with the first — an edit or a re-publish must not
|
||||
// re-fire. Fire-and-forget, like everything else in this function: `emit` does
|
||||
// not await delivery by design, and a rule lookup must not be able to fail
|
||||
// saving a post.
|
||||
if (jobId) newsEmit.emitNewsPost(post)
|
||||
}
|
||||
|
||||
// ── Dashboard & site mode ─────────────────────────────────────────────
|
||||
|
||||
@@ -32,6 +32,8 @@ const segments = require('../../../model/engagement/engagementSegments.model')
|
||||
const recipients = require('../../../model/engagement/engagementRecipients.db')
|
||||
const templates = require('../../../model/engagement/engagementTemplates.model')
|
||||
const sendsDb = require('../../../model/engagement/engagementSends.db')
|
||||
const suppressionsDb = require('../../../model/engagement/engagementSuppressions.db')
|
||||
const suppressions = require('../../../engagement/suppressions')
|
||||
|
||||
// The lattice, flattened for a client: for each ceiling, the ones a rule may
|
||||
// choose under it. Served with the catalog rather than hardcoded in the admin
|
||||
@@ -245,6 +247,37 @@ exports.deleteSegment = async (req, res, next) => {
|
||||
|
||||
// ── Reach preview ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* How many of a resolved audience would actually receive an email, and what
|
||||
* removed the rest (Phase 9).
|
||||
*
|
||||
* The two mechanisms are asked in the order the engine applies them, and the
|
||||
* order is what makes the numbers add up: the verification gate runs at enqueue,
|
||||
* so a user it excludes is never checked for suppression, and counting both
|
||||
* independently would double-count anybody who is unverified AND bounced.
|
||||
*
|
||||
* **It never returns an address.** Addresses are read only to hash them, and only
|
||||
* the counts leave this function — the send log route already refuses to return
|
||||
* `address_hash` for exactly this reason, and a reach preview that leaked a list
|
||||
* would be the same hole through a different door.
|
||||
*/
|
||||
async function emailReach(userIds) {
|
||||
const gated = await channels.eligibleFor('email', userIds)
|
||||
const addresses = await recipients.addressesFor(gated.userIds)
|
||||
const hashes = [...addresses.values()].map((a) => suppressions.hashAddress(a))
|
||||
const blocked = await suppressionsDb.suppressedAmong(hashes)
|
||||
return {
|
||||
// Everyone the audience resolved to who is not excluded by the gate and is
|
||||
// not suppressed. Users with no address at all are already out: the gate
|
||||
// drops them when it is on, and `addressesFor` does not return them when it
|
||||
// is off, so they never reach the count either way.
|
||||
deliverable: Math.max(0, addresses.size - blocked.size),
|
||||
excluded: gated.excluded,
|
||||
suppressed: blocked.size,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/engagement/audience-preview
|
||||
*
|
||||
@@ -270,6 +303,16 @@ exports.deleteSegment = async (req, res, next) => {
|
||||
* at all. Without it the editor shows a healthy count beside a save the server
|
||||
* will refuse, which reads as a bug in the save rather than as the G24 ceiling
|
||||
* doing its job.
|
||||
*
|
||||
* **A fourth arrived with Phase 9: `email`.** `count` is how many people the
|
||||
* audience resolves to, and that has never been how many will get a mail — the
|
||||
* verification gate drops unverified users at enqueue and the suppression list
|
||||
* drops bounced addresses at send. An operator reading "3,000" beside a rule that
|
||||
* will mail 1,796 people has been told something false by a screen whose only job
|
||||
* is that number, so the breakdown is computed the same way the engine computes
|
||||
* it: `channels.eligibleFor('email', ...)` is the identical call `applyRule`
|
||||
* makes. It is reported for the email channel only because it is the only channel
|
||||
* either mechanism applies to.
|
||||
*/
|
||||
exports.previewAudience = async (req, res, next) => {
|
||||
try {
|
||||
@@ -297,6 +340,7 @@ exports.previewAudience = async (req, res, next) => {
|
||||
dormant: resolved.dormant,
|
||||
reason: resolved.reason,
|
||||
permitted: triggerId && resolved.ceiling ? audiences.permitted(triggerId, resolved.ceiling) : null,
|
||||
email: await emailReach(resolved.userIds),
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
@@ -444,3 +488,110 @@ exports.listSends = async (req, res, next) => {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Suppressions (Phase 9) ─────────────────────────────────────────────────
|
||||
//
|
||||
// The one surface that can take an address OUT of the suppression list, which is
|
||||
// why it exists at all: a hard bounce is written by a background worker with no
|
||||
// human in the loop, and without a way back a mistyped-then-corrected mailbox is
|
||||
// silenced permanently.
|
||||
//
|
||||
// **The list returns `address_masked`, never `address_hash`.** The send log route
|
||||
// above strips the hash for a stated reason — shipping a sha256 of every address
|
||||
// on the deployment to a browser is an offline dictionary attack waiting to be
|
||||
// run — and the same reasoning applies twice over here, where the rows are
|
||||
// exactly the addresses somebody would most want to confirm. The mask is what an
|
||||
// operator can act on and is not reversible.
|
||||
|
||||
/** GET /api/v1/admin/engagement/suppressions */
|
||||
exports.listSuppressions = async (req, res, next) => {
|
||||
try {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200)
|
||||
const offset = Math.max(Number(req.query.offset) || 0, 0)
|
||||
const reason = typeof req.query.reason === 'string' ? req.query.reason : null
|
||||
if (reason && !suppressions.REASONS.includes(reason)) {
|
||||
return res.status(400).json({ message: `reason must be one of ${suppressions.REASONS.join(', ')}` })
|
||||
}
|
||||
const filters = {
|
||||
reason,
|
||||
channel: typeof req.query.channel === 'string' ? req.query.channel : null,
|
||||
search: typeof req.query.search === 'string' ? req.query.search.trim() || null : null,
|
||||
}
|
||||
const [rows, total, byReason] = await Promise.all([
|
||||
suppressionsDb.list({ ...filters, limit, offset }),
|
||||
suppressionsDb.count(filters),
|
||||
// Unfiltered on purpose: it is the summary strip above the table, and a
|
||||
// count that moved with the filter would say "0 bounces" while the operator
|
||||
// was looking at the manual ones.
|
||||
suppressionsDb.countsByReason(),
|
||||
])
|
||||
res.json({
|
||||
suppressions: rows.map(({ address_hash: _hash, ...row }) => row),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
byReason,
|
||||
reasons: suppressions.REASONS,
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/engagement/suppressions
|
||||
*
|
||||
* Suppress an address by hand — the operator-side half of a bounce they were
|
||||
* told about out of band (a person emailing to say "stop", a relay's dashboard).
|
||||
*
|
||||
* The reason is forced to `manual` rather than taken from the body. An admin
|
||||
* typing an address is not evidence of a bounce or a complaint, and a list where
|
||||
* `reason` sometimes means "the relay said so" and sometimes means "somebody
|
||||
* chose this word" cannot be used to diagnose anything.
|
||||
*/
|
||||
exports.createSuppression = async (req, res, next) => {
|
||||
try {
|
||||
const address = typeof req.body?.address === 'string' ? req.body.address.trim() : ''
|
||||
// The same shape check the rest of the codebase uses for an address, and no
|
||||
// more: this is not a deliverability test, it is a guard against storing a
|
||||
// hash of a typo that can never be matched or found again.
|
||||
if (!address || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)) {
|
||||
return res.status(400).json({ message: 'A valid email address is required' })
|
||||
}
|
||||
const detail = typeof req.body?.detail === 'string' ? req.body.detail.slice(0, 500) : null
|
||||
const created = await suppressions.suppress({
|
||||
address,
|
||||
reason: 'manual',
|
||||
detail,
|
||||
createdBy: req.user?.id ?? null,
|
||||
})
|
||||
// 200 rather than 409 for an address already on the list: the operator asked
|
||||
// for it to be suppressed and it is, which is the outcome they wanted. `created`
|
||||
// says which of the two happened, so the screen can say "already suppressed"
|
||||
// without it reading as a failure.
|
||||
res.status(created ? 201 : 200).json({ created, address: suppressions.maskAddress(address) })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/engagement/suppressions
|
||||
*
|
||||
* Un-suppress. The address goes in the BODY rather than the path, and that is
|
||||
* not a REST preference: a path parameter lands in the access log, the browser's
|
||||
* history and any proxy in front of the deployment, and this one is a real
|
||||
* address belonging to a real person. The hash cannot be used instead — the
|
||||
* screen never receives one.
|
||||
*/
|
||||
exports.deleteSuppression = async (req, res, next) => {
|
||||
try {
|
||||
const address = typeof req.body?.address === 'string' ? req.body.address.trim() : ''
|
||||
if (!address) return res.status(400).json({ message: 'An email address is required' })
|
||||
const removed = await suppressions.unsuppress(address, req.body?.channel || 'email')
|
||||
if (!removed) return res.status(404).json({ message: 'That address is not suppressed' })
|
||||
res.json({ removed: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,4 +331,54 @@ engagementRouter.get(
|
||||
controller.listSends,
|
||||
)
|
||||
|
||||
// -- Suppressions (Phase 9) ------------------------------------------------
|
||||
|
||||
engagementRouter.get(
|
||||
'/suppressions',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'Addresses this deployment has stopped mailing'
|
||||
// #swagger.description = 'G16. Rows carry `address_masked` (`d***@example.com`) and never `address_hash` - the same rule the send log follows, and for the same reason: a sha256 of every address on the deployment, handed to a browser, is an offline dictionary attack. The mask keeps the domain intact so a whole-domain delivery failure is visible, and destroys the local part so the list cannot be turned back into an address book. `byReason` is deliberately unfiltered - it is the summary strip above the table.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['limit'] = { in: 'query', description: 'Page size, 1-200 (default 50)', required: false, schema: { type: 'integer' } }
|
||||
// #swagger.parameters['offset'] = { in: 'query', description: 'Rows to skip', required: false, schema: { type: 'integer' } }
|
||||
// #swagger.parameters['reason'] = { in: 'query', description: 'bounce, complaint, manual or unverified', required: false, schema: { type: 'string' } }
|
||||
// #swagger.parameters['channel'] = { in: 'query', description: 'Only this channel (default: all)', required: false, schema: { type: 'string' } }
|
||||
// #swagger.parameters['search'] = { in: 'query', description: 'Substring of the masked address - a domain is what this is for', required: false, schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'One page of the list, with per-reason totals', content: { "application/json": { schema: { type: "object", properties: { suppressions: { type: "array", items: { type: "object", additionalProperties: true } }, total: { type: "integer" }, limit: { type: "integer" }, offset: { type: "integer" }, byReason: { type: "object", additionalProperties: { type: "integer" } }, reasons: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Unknown reason', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.listSuppressions,
|
||||
)
|
||||
|
||||
engagementRouter.post(
|
||||
'/suppressions',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'Suppress an address by hand'
|
||||
// #swagger.description = 'For a bounce or a complaint reported out of band. The reason is forced to `manual` rather than read from the body: an admin typing an address is not evidence of a bounce, and a `reason` column that sometimes means "the relay said so" and sometimes means "somebody chose this word" cannot diagnose anything. An address already on the list answers 200 with `created: false` rather than 409 - the operator asked for it to be suppressed and it is.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { address: { type: "string" }, detail: { type: "string", nullable: true } }, required: ["address"] } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Suppressed', content: { "application/json": { schema: { type: "object", properties: { created: { type: "boolean" }, address: { type: "string", nullable: true } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Already suppressed; nothing changed', content: { "application/json": { schema: { type: "object", properties: { created: { type: "boolean" }, address: { type: "string", nullable: true } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Not a valid address', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.createSuppression,
|
||||
)
|
||||
|
||||
engagementRouter.delete(
|
||||
'/suppressions',
|
||||
// #swagger.tags = ['Admin - Engagement']
|
||||
// #swagger.summary = 'Lift a suppression'
|
||||
// #swagger.description = 'The only way out of the list, and the reason the screen exists: a hard bounce is written by a background worker with no human in the loop, so a mistyped-then-corrected mailbox would otherwise be silenced permanently. The address goes in the BODY, not the path - a path parameter lands in the access log, the browser history and every proxy in front of the deployment, and this one belongs to a real person. The hash cannot be used instead because the screen is never given one.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { address: { type: "string" }, channel: { type: "string", nullable: true } }, required: ["address"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Lifted', content: { "application/json": { schema: { type: "object", properties: { removed: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'No address given', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'That address is not suppressed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.deleteSuppression,
|
||||
)
|
||||
|
||||
module.exports = engagementRouter
|
||||
|
||||
@@ -56,7 +56,7 @@ const STALE_MS = 15 * 60 * 1000
|
||||
/**
|
||||
* Deliver one claimed row.
|
||||
*
|
||||
* @returns {{ outcome: 'sent'|'retry'|'terminal', detail?: string, transport?: string, addressHash?: string }}
|
||||
* @returns {{ outcome: 'sent'|'retry'|'terminal'|'suppressed'|'bounced', detail?: string, transport?: string, addressHash?: string }}
|
||||
*/
|
||||
async function deliver(row) {
|
||||
const channel = channels.get(row.channel)
|
||||
@@ -78,6 +78,21 @@ async function deliver(row) {
|
||||
if (result && result.ok) {
|
||||
return { outcome: 'sent', transport: result.transport, detail: result.detail, addressHash: hash }
|
||||
}
|
||||
// Its own outcome rather than a flavour of 'terminal' (Phase 9). Both statuses
|
||||
// the ENUMs already carried for it say something a 'failed' row cannot: the
|
||||
// outbox row was not attempted, and the send log's `suppressed` is the
|
||||
// difference between "we tried and the relay refused" and "we declined to
|
||||
// try". An operator reading a screen of failures needs those separated, and
|
||||
// so does anybody counting deliverability.
|
||||
if (result && result.suppressed) {
|
||||
return { outcome: 'suppressed', detail: result.detail || 'suppressed', addressHash: hash }
|
||||
}
|
||||
// A hard bounce. Terminal like any other refusal, but recorded under its own
|
||||
// name: "the relay would not take this" and "this mailbox does not exist"
|
||||
// send an operator to two different places.
|
||||
if (result && result.bounced) {
|
||||
return { outcome: 'bounced', detail: result.detail || 'hard bounce', addressHash: hash }
|
||||
}
|
||||
if (result && result.retry) {
|
||||
return { outcome: 'retry', detail: result.detail || 'transient failure', addressHash: hash }
|
||||
}
|
||||
@@ -107,8 +122,18 @@ async function processRow(row, now = new Date(), deliverFn = deliver) {
|
||||
return 'retry'
|
||||
}
|
||||
|
||||
const status = result.outcome === 'sent' ? 'sent' : 'failed'
|
||||
await outboxDb.finish(row.id, status, status === 'failed' ? result.detail : null)
|
||||
// **The two tables diverge here, deliberately.** `engagement_outbox.status` is
|
||||
// the ROW's lifecycle and its ENUM has no 'bounced' - from the queue's point of
|
||||
// view a bounced message is a row that finished unsuccessfully, which is
|
||||
// 'failed'. `engagement_sends.status` is what happened to the MESSAGE, and
|
||||
// there 'bounced' is the whole point: it is the difference between "look at
|
||||
// your relay" and "this person's mailbox is gone".
|
||||
let status = 'failed'
|
||||
if (result.outcome === 'sent') status = 'sent'
|
||||
else if (result.outcome === 'suppressed') status = 'suppressed'
|
||||
else if (result.outcome === 'bounced') status = 'bounced'
|
||||
const outboxStatus = status === 'bounced' ? 'failed' : status
|
||||
await outboxDb.finish(row.id, outboxStatus, status === 'sent' ? null : result.detail)
|
||||
// The send log is written for every terminal outcome, not only success. G15's
|
||||
// question is "did user X get the mail?", and "no, and here is why" is an
|
||||
// answer that table has to be able to give.
|
||||
@@ -142,7 +167,7 @@ async function tick(now = new Date()) {
|
||||
}
|
||||
if (!due || !due.length) return
|
||||
|
||||
const counts = { sent: 0, failed: 0, retry: 0, taken: 0 }
|
||||
const counts = { sent: 0, failed: 0, suppressed: 0, bounced: 0, retry: 0, taken: 0 }
|
||||
for (const row of due) {
|
||||
try {
|
||||
const outcome = await processRow(row, now)
|
||||
|
||||
@@ -411,7 +411,17 @@ const PERMANENT_CODES = new Set([550, 553, 554, 'EENVELOPE', 'EAUTH'])
|
||||
* itself. A FAILURE is still recorded, because a relay that has started refusing
|
||||
* mail is precisely what that screen exists to show.
|
||||
*
|
||||
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>}
|
||||
* **`smtp` carries the refusal itself, and Phase 9 is why.** `retry` says whether
|
||||
* to try again; `detail` is a sentence for a human. Neither can answer "was this
|
||||
* the recipient's fault?", which is the question the suppression list turns on —
|
||||
* `550 5.1.1` and `550 5.7.1` produce an identical `retry: false` and mean
|
||||
* completely different things. So the reply code, the enhanced status and the
|
||||
* error code ride back untouched for `bounceClassify` to read. Three scalar
|
||||
* fields rather than the error object: an `Error` from nodemailer carries the
|
||||
* whole failed message, envelope included, and this return value is logged.
|
||||
*
|
||||
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string,
|
||||
* smtp?: {code: string|null, responseCode: number|null, response: string|null}}>}
|
||||
*/
|
||||
async function sendNotification({ to, rendered, unsubscribeUrl, unsubscribeApiUrl }) {
|
||||
const built = await buildTransport()
|
||||
@@ -445,7 +455,20 @@ async function sendNotification({ to, rendered, unsubscribeUrl, unsubscribeApiUr
|
||||
const code = err && (err.responseCode || err.code)
|
||||
log.warn('engagement send failed', { message: err.message })
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: detail }).catch(() => {})
|
||||
return { ok: false, retry: !PERMANENT_CODES.has(code), transport: config.transport, detail }
|
||||
return {
|
||||
ok: false,
|
||||
retry: !PERMANENT_CODES.has(code),
|
||||
transport: config.transport,
|
||||
detail,
|
||||
smtp: {
|
||||
code: err && err.code ? String(err.code) : null,
|
||||
responseCode: err && Number.isInteger(err.responseCode) ? err.responseCode : null,
|
||||
// Truncated: a relay may answer with a multi-line essay, and this string
|
||||
// reaches a 500-character log column. The reply code and the enhanced
|
||||
// status are both at the front, which is where the classifier reads them.
|
||||
response: err && err.response ? String(err.response).slice(0, 400) : null,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
112
server/src/utils/newsNotify.js
Normal file
112
server/src/utils/newsNotify.js
Normal file
@@ -0,0 +1,112 @@
|
||||
// ── The `news.post` emitter (ENGAGEMENT.md §7.1 Q9, Phase 11) ──────────────
|
||||
//
|
||||
// Core's own trigger, and until this phase the only one of its five with no
|
||||
// caller at all: `config/coreTriggers.js` declared the payload contract in Phase
|
||||
// 2 and said in as many words that nothing emitted yet, and Phase 6 migrated
|
||||
// only the four `team.*` ones onto the engine. So an operator could write a rule
|
||||
// on `news.post` and it could never fire.
|
||||
//
|
||||
// It is one call, and every line of care here is about what it must NOT disturb.
|
||||
// `announceIfNewlyPublished` fans one publish three ways now, and they are
|
||||
// different in kind:
|
||||
//
|
||||
// • `announceJobs.enqueueIfNeeded` — a one-shot DELIVERY to a channel of the
|
||||
// deployment (the in-game town crier, Discord #news), with retry and
|
||||
// classification. Not per-person. Not the engine's.
|
||||
// • `registries.dispatchPostHook('onSaved')` — idempotent STATE mirroring,
|
||||
// which also runs on delete and refreshes silently on an edit. Not the
|
||||
// engine's either.
|
||||
// • this — a PER-PERSON notification, subject to a rule, a preference, a
|
||||
// suppression and a channel. The engine's, and the only one that was missing.
|
||||
//
|
||||
// A module keeps both of its doors onto a publish (the announce leg and the post
|
||||
// hook) and gains no third: `news.post` is core's id, `ctx.events.emit` binds the
|
||||
// owner at the call and never reads it from the arguments, and §7.2's one
|
||||
// namespace means an id has exactly one owner across both facets. A module that
|
||||
// wants a person-facing notification of its own declares its own trigger.
|
||||
//
|
||||
// **Nothing here throws.** It is called from a path that has already saved the
|
||||
// post; a notification is a courtesy and a courtesy that can fail the write
|
||||
// behind it is a defect. Same posture as `teamNotify.js`, for the same reason.
|
||||
|
||||
const engagementEmit = require('./engagementEmit')
|
||||
const log = require('./logger')('news-notify')
|
||||
|
||||
// How much of a post body the mail carries when the post has no excerpt of its
|
||||
// own. Same length as the Team excerpt: long enough to tell whether it is worth
|
||||
// opening, short enough that the mail is not a copy of the article.
|
||||
const EXCERPT_CHARS = 200
|
||||
|
||||
// **The site has no per-post route.** `App.jsx` mounts `/site/news` (the list)
|
||||
// and nothing under it, which is why `announceJobs.logic.js` links the list from
|
||||
// the Discord and town-crier announcements too. So the mail links what exists.
|
||||
// Declared `required: true` on the trigger, so this is a constant rather than an
|
||||
// optional — a variable that is sometimes absent is a template that sometimes
|
||||
// renders a dead button.
|
||||
//
|
||||
// Site-RELATIVE, because `engagementEmit.RELATIVE_URL` validates `url` variables
|
||||
// that way: a payload value that ends up in an href must not be able to carry an
|
||||
// absolute one somewhere else. The mail renderer absolutizes it against the
|
||||
// deployment's base.
|
||||
const NEWS_PATH = '/site/news'
|
||||
|
||||
/** Markup out, whitespace collapsed, truncated. The mail is plain text. */
|
||||
function excerptFrom(post) {
|
||||
const source = post.excerpt || post.body || ''
|
||||
const text = String(source)
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!text) return null
|
||||
return text.length > EXCERPT_CHARS ? `${text.slice(0, EXCERPT_CHARS - 1)}…` : text
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `news.post` for a post that has just transitioned into published news.
|
||||
*
|
||||
* The caller gates on `announceJobs.enqueueIfNeeded`'s job id — the single
|
||||
* transition signal — so this does not re-derive it. Passing a post that did not
|
||||
* transition would produce a duplicate mail, which is why this function does not
|
||||
* take the transition and cannot be tempted to read it differently.
|
||||
*
|
||||
* @param {object} post the saved post row
|
||||
* @returns {boolean} whether the emit was accepted (for tests; callers ignore it)
|
||||
*/
|
||||
function emitNewsPost(post) {
|
||||
try {
|
||||
if (!post || post.id == null) return false
|
||||
const excerpt = excerptFrom(post)
|
||||
const result = engagementEmit.emit('core', 'news.post', {
|
||||
data: {
|
||||
title: post.title || 'A new post',
|
||||
// Omitted rather than sent empty when the post has neither an excerpt nor
|
||||
// a body worth quoting. `excerpt` is `required: false`, and an absent
|
||||
// optional renders as absent; an empty string renders as a blank line
|
||||
// where a summary should be.
|
||||
...(excerpt ? { excerpt } : {}),
|
||||
...(post.category ? { category: String(post.category) } : {}),
|
||||
postUrl: NEWS_PATH,
|
||||
},
|
||||
// **The cooldown subject is the USER, not the post**, which is why there is
|
||||
// no `subject` here and no `subjectKey` on the declaration. "Do not mail me
|
||||
// about news more than once an hour" is the useful rule; keying it per post
|
||||
// would make every cooldown a no-op, since every post is a new subject.
|
||||
//
|
||||
// `dedupeKey` IS per post, and that is the other half of the same thought:
|
||||
// the engine must not write two outbox rows for one publish if this is ever
|
||||
// reached twice, and the post id is the only stable name for "this publish".
|
||||
dedupeKey: `news.post:${post.id}`,
|
||||
})
|
||||
return Boolean(result && result.ok)
|
||||
} catch (err) {
|
||||
log.warn('news event not emitted', { postId: post && post.id, message: err.message })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { emitNewsPost, excerptFrom, NEWS_PATH, EXCERPT_CHARS }
|
||||
@@ -2183,6 +2183,313 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/engagement/suppressions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin - Engagement"
|
||||
],
|
||||
"summary": "Addresses this deployment has stopped mailing",
|
||||
"description": "G16. Rows carry `address_masked` (`d***@example.com`) and never `address_hash` - the same rule the send log follows, and for the same reason: a sha256 of every address on the deployment, handed to a browser, is an offline dictionary attack. The mask keeps the domain intact so a whole-domain delivery failure is visible, and destroys the local part so the list cannot be turned back into an address book. `byReason` is deliberately unfiltered - it is the summary strip above the table.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "Page size, 1-200 (default 50)",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"description": "Rows to skip",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reason",
|
||||
"in": "query",
|
||||
"description": "bounce, complaint, manual or unverified",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "channel",
|
||||
"in": "query",
|
||||
"description": "Only this channel (default: all)",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"in": "query",
|
||||
"description": "Substring of the masked address - a domain is what this is for",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "One page of the list, with per-reason totals",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suppressions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer"
|
||||
},
|
||||
"byReason": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"reasons": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Unknown reason",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin - Engagement"
|
||||
],
|
||||
"summary": "Suppress an address by hand",
|
||||
"description": "For a bounce or a complaint reported out of band. The reason is forced to `manual` rather than read from the body: an admin typing an address is not evidence of a bounce, and a `reason` column that sometimes means \"the relay said so\" and sometimes means \"somebody chose this word\" cannot diagnose anything. An address already on the list answers 200 with `created: false` rather than 409 - the operator asked for it to be suppressed and it is.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Already suppressed; nothing changed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"address": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"201": {
|
||||
"description": "Suppressed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"address": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Not a valid address",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string"
|
||||
},
|
||||
"detail": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"address"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Admin - Engagement"
|
||||
],
|
||||
"summary": "Lift a suppression",
|
||||
"description": "The only way out of the list, and the reason the screen exists: a hard bounce is written by a background worker with no human in the loop, so a mistyped-then-corrected mailbox would otherwise be silenced permanently. The address goes in the BODY, not the path - a path parameter lands in the access log, the browser history and every proxy in front of the deployment, and this one belongs to a real person. The hash cannot be used instead because the screen is never given one.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Lifted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"removed": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "No address given",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "That address is not suppressed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string"
|
||||
},
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"address"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/engagement/templates": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -40,6 +40,9 @@ const ctrl = require('../src/router/v1/admin/engagement.controller')
|
||||
const rulesDb = require('../src/model/engagement/engagementRules.db')
|
||||
const segmentsDb = require('../src/model/engagement/engagementSegments.db')
|
||||
const recipients = require('../src/model/engagement/engagementRecipients.db')
|
||||
const suppressionsDb = require('../src/model/engagement/engagementSuppressions.db')
|
||||
const suppressions = require('../src/engagement/suppressions')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
@@ -48,7 +51,13 @@ after(() => db.close())
|
||||
|
||||
let store
|
||||
const originals = {}
|
||||
for (const [name, mod] of [['rulesDb', rulesDb], ['segmentsDb', segmentsDb], ['recipients', recipients]]) {
|
||||
for (const [name, mod] of [
|
||||
['rulesDb', rulesDb],
|
||||
['segmentsDb', segmentsDb],
|
||||
['recipients', recipients],
|
||||
['suppressionsDb', suppressionsDb],
|
||||
['settings', settings],
|
||||
]) {
|
||||
originals[name] = { mod, fns: { ...mod } }
|
||||
}
|
||||
const restoreOriginals = () => {
|
||||
@@ -56,7 +65,16 @@ const restoreOriginals = () => {
|
||||
}
|
||||
|
||||
function installStubs() {
|
||||
store = { rules: new Map(), segments: new Map(), users: new Map(), nextRule: 1, nextSegment: 1 }
|
||||
store = {
|
||||
rules: new Map(),
|
||||
segments: new Map(),
|
||||
users: new Map(),
|
||||
// Phase 9: address hashes, and the verification gate's setting.
|
||||
suppressed: new Set(),
|
||||
verificationRequired: false,
|
||||
nextRule: 1,
|
||||
nextSegment: 1,
|
||||
}
|
||||
|
||||
rulesDb.list = async () => [...store.rules.values()].map((r) => ({ ...r }))
|
||||
rulesDb.getById = async (id) => (store.rules.has(id) ? { ...store.rules.get(id) } : null)
|
||||
@@ -102,11 +120,40 @@ function installStubs() {
|
||||
recipients.subscribers = async () => []
|
||||
recipients.filterActive = async (ids) =>
|
||||
[...new Set(ids)].filter((id) => store.users.get(id)?.status === 'active')
|
||||
|
||||
// Phase 9: the reach preview now asks the email channel what it would actually
|
||||
// deliver, so the fake world has to be able to answer the two questions that
|
||||
// makes it ask - who is unverified, and who is suppressed.
|
||||
recipients.unverifiedAmong = async (ids) =>
|
||||
new Set(ids.filter((id) => store.users.get(Number(id))?.email_verified === 0))
|
||||
recipients.addressesFor = async (ids) =>
|
||||
new Map(
|
||||
ids
|
||||
.filter((id) => store.users.get(Number(id))?.status === 'active')
|
||||
.map((id) => [Number(id), store.users.get(Number(id)).email]),
|
||||
)
|
||||
suppressionsDb.suppressedAmong = async (hashes) =>
|
||||
new Set(hashes.filter((h) => store.suppressed.has(h)))
|
||||
settings.isEmailVerificationRequired = async () => store.verificationRequired
|
||||
}
|
||||
|
||||
// ── Fixtures ───────────────────────────────────────────────────────────────
|
||||
|
||||
const addUser = (id, over = {}) => store.users.set(id, { id, role: 'player', status: 'active', ...over })
|
||||
const addUser = (id, over = {}) =>
|
||||
store.users.set(id, {
|
||||
id,
|
||||
role: 'player',
|
||||
status: 'active',
|
||||
// Verified with an address by default: the reach preview counts deliverable
|
||||
// people, and a fixture that was unverified by accident would make every
|
||||
// count in this file read as zero.
|
||||
email: `u${id}@example.test`,
|
||||
email_verified: 1,
|
||||
...over,
|
||||
})
|
||||
|
||||
const suppressUser = (id) =>
|
||||
store.suppressed.add(suppressions.hashAddress(store.users.get(id).email))
|
||||
|
||||
function register(owner, fn) {
|
||||
const api = registries.stage(owner)
|
||||
@@ -438,6 +485,51 @@ test('an `owner` audience previews as 0 with the reason, because it resolves per
|
||||
assert.equal(res.body.permitted, true)
|
||||
})
|
||||
|
||||
// Phase 9. `count` has never been how many people get a mail, and after this
|
||||
// phase there are two mechanisms that make the gap real. An operator reading
|
||||
// "3,000" beside a rule that will mail 1,796 people has been told something false
|
||||
// by the one screen whose entire job is that number.
|
||||
test('the preview says how many would actually be mailed, not just how many resolved', async () => {
|
||||
addUser(1)
|
||||
addUser(2)
|
||||
addUser(3)
|
||||
suppressUser(2)
|
||||
|
||||
const res = await call(ctrl.previewAudience, { query: { audience: 'authenticated' } })
|
||||
|
||||
assert.equal(res.body.count, 3)
|
||||
assert.equal(res.body.email.deliverable, 2)
|
||||
assert.equal(res.body.email.suppressed, 1)
|
||||
})
|
||||
|
||||
test('with the verification gate on, the preview counts the exclusion rather than hiding it', async () => {
|
||||
addUser(1)
|
||||
addUser(2, { email_verified: 0 })
|
||||
store.verificationRequired = true
|
||||
|
||||
const res = await call(ctrl.previewAudience, { query: { audience: 'authenticated' } })
|
||||
|
||||
assert.equal(res.body.count, 2)
|
||||
assert.deepEqual(res.body.email.excluded, { unverified: 1 })
|
||||
assert.equal(res.body.email.deliverable, 1)
|
||||
})
|
||||
|
||||
// The two mechanisms are asked in the order the engine applies them, so somebody
|
||||
// who is both unverified and suppressed is removed once. Counting them
|
||||
// independently would report more exclusions than there are people.
|
||||
test('a user who is both unverified and suppressed is not counted twice', async () => {
|
||||
addUser(1)
|
||||
addUser(2, { email_verified: 0 })
|
||||
suppressUser(2)
|
||||
store.verificationRequired = true
|
||||
|
||||
const res = await call(ctrl.previewAudience, { query: { audience: 'authenticated' } })
|
||||
|
||||
assert.equal(res.body.email.deliverable, 1)
|
||||
assert.deepEqual(res.body.email.excluded, { unverified: 1 })
|
||||
assert.equal(res.body.email.suppressed, 0, 'the gate already removed them')
|
||||
})
|
||||
|
||||
test('the preview reports when the trigger ceiling would refuse what it just counted', async () => {
|
||||
addUser(1, { role: 'admin' })
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@ const assert = require('node:assert/strict')
|
||||
|
||||
const ceilings = require('../src/modules/ceilings')
|
||||
|
||||
test('the six ceilings are the vocabulary, and nothing else is', () => {
|
||||
test('the seven ceilings are the vocabulary, and nothing else is', () => {
|
||||
assert.deepEqual(
|
||||
[...ceilings.CEILINGS].sort(),
|
||||
['authenticated', 'everyone', 'members', 'owner', 'staff', 'subscribers'],
|
||||
['admin', 'authenticated', 'everyone', 'members', 'owner', 'staff', 'subscribers'],
|
||||
)
|
||||
for (const id of ceilings.CEILINGS) assert.ok(ceilings.LABELS[id], `${id} has an operator label`)
|
||||
assert.equal(ceilings.isCeiling('nobody'), false)
|
||||
@@ -33,9 +33,9 @@ test('everyone permits every ceiling; every ceiling permits itself', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('authenticated permits the four leaves but not everyone', () => {
|
||||
for (const leaf of ['subscribers', 'members', 'staff', 'owner']) {
|
||||
assert.equal(ceilings.permits('authenticated', leaf), true)
|
||||
test('authenticated permits every branch and admin beneath staff, but not everyone', () => {
|
||||
for (const below of ['subscribers', 'members', 'staff', 'owner', 'admin']) {
|
||||
assert.equal(ceilings.permits('authenticated', below), true)
|
||||
}
|
||||
assert.equal(ceilings.permits('authenticated', 'everyone'), false)
|
||||
})
|
||||
@@ -55,6 +55,70 @@ test('a staff ceiling does NOT permit owner — fewer people is not less exposur
|
||||
}
|
||||
})
|
||||
|
||||
// ── `admin`, added in Phase 11 ─────────────────────────────────────────────
|
||||
//
|
||||
// The one genuine refinement in the tree: every admin is staff, which is the
|
||||
// containment no other pair has. These assert that it is a NARROWING and not a
|
||||
// second way to widen — the failure this file exists to keep out, in its newest
|
||||
// possible costume.
|
||||
|
||||
test('staff permits admin and admin does not permit staff — the one true refinement', () => {
|
||||
assert.equal(ceilings.permits('staff', 'admin'), true)
|
||||
assert.equal(ceilings.permits('admin', 'staff'), false)
|
||||
assert.equal(ceilings.meet('staff', 'admin'), 'admin')
|
||||
assert.equal(ceilings.meet('admin', 'staff'), 'admin')
|
||||
})
|
||||
|
||||
test('admin is incomparable with every branch that is not staff', () => {
|
||||
for (const other of ['subscribers', 'members', 'owner']) {
|
||||
assert.equal(ceilings.permits('admin', other), false, `admin must not permit ${other}`)
|
||||
assert.equal(ceilings.permits(other, 'admin'), false, `${other} must not permit admin`)
|
||||
assert.equal(ceilings.meet('admin', other), null, `admin ∧ ${other} has no bound`)
|
||||
}
|
||||
})
|
||||
|
||||
test('an admin-ceilinged trigger refuses a staff audience', () => {
|
||||
// The acceptance criterion in as many words: a rule cannot give an
|
||||
// admin-ceiling trigger a `staff` audience. `permits` is what both the save
|
||||
// check and the send-time re-check call.
|
||||
assert.equal(ceilings.permits('admin', 'staff'), false)
|
||||
// …and the narrowing direction is allowed, which is what makes the node useful
|
||||
// rather than merely restrictive.
|
||||
assert.equal(ceilings.permits('staff', 'admin'), true)
|
||||
})
|
||||
|
||||
test('the role ceilings are a table, so a new one cannot be forgotten', () => {
|
||||
// `visibleTo` used to ask `ceiling !== 'staff'`. That spelling was correct
|
||||
// while `staff` was the only role-gated value and would have silently published
|
||||
// every admin-ceilinged id to every player's preferences screen the day `admin`
|
||||
// arrived. The table is what makes that impossible to get wrong quietly.
|
||||
assert.deepEqual(Object.keys(ceilings.ROLE_CEILINGS).sort(), ['admin', 'staff'])
|
||||
for (const id of Object.keys(ceilings.ROLE_CEILINGS)) {
|
||||
assert.ok(ceilings.isRoleCeiling(id), `${id} is a role ceiling`)
|
||||
assert.ok(ceilings.ROLE_CEILINGS[id].roles.length, `${id} names at least one role`)
|
||||
}
|
||||
assert.equal(ceilings.isRoleCeiling('subscribers'), false)
|
||||
})
|
||||
|
||||
test('reachableBy gates the role ceilings and lets everything else through', () => {
|
||||
assert.equal(ceilings.reachableBy('admin', 'admin'), true)
|
||||
assert.equal(ceilings.reachableBy('admin', 'editor'), false)
|
||||
assert.equal(ceilings.reachableBy('admin', 'moderator'), false)
|
||||
assert.equal(ceilings.reachableBy('admin', 'user'), false)
|
||||
assert.equal(ceilings.reachableBy('staff', 'editor'), true)
|
||||
assert.equal(ceilings.reachableBy('staff', 'user'), false)
|
||||
// Fails closed on a missing viewer, which is how a signed-out catalog read
|
||||
// reaches it.
|
||||
assert.equal(ceilings.reachableBy('staff', undefined), false)
|
||||
assert.equal(ceilings.reachableBy('admin', undefined), false)
|
||||
// Everything that is not role-gated is visible to anyone, including the `null`
|
||||
// a stream-only catalog item carries.
|
||||
for (const open of ['everyone', 'authenticated', 'subscribers', 'members', 'owner']) {
|
||||
assert.equal(ceilings.reachableBy(open, 'user'), true, `${open} is not role-gated`)
|
||||
}
|
||||
assert.equal(ceilings.reachableBy(null, 'user'), true)
|
||||
})
|
||||
|
||||
test('an unknown ceiling is permitted by nothing, on either side', () => {
|
||||
assert.equal(ceilings.permits('everyone', 'god'), false)
|
||||
assert.equal(ceilings.permits('god', 'owner'), false)
|
||||
@@ -70,6 +134,7 @@ test('A OR B takes the NARROWER of the two ceilings, not the wider', () => {
|
||||
|
||||
test('incomparable ceilings have no meet — the save is refused, not guessed', () => {
|
||||
assert.equal(ceilings.meet('staff', 'members'), null)
|
||||
assert.equal(ceilings.meet('admin', 'owner'), null)
|
||||
assert.equal(ceilings.meet('owner', 'subscribers'), null)
|
||||
assert.equal(ceilings.meet('staff', 'nonsense'), null)
|
||||
})
|
||||
|
||||
423
server/test/engagementDeliverability.test.js
Normal file
423
server/test/engagementDeliverability.test.js
Normal file
@@ -0,0 +1,423 @@
|
||||
// ── Deliverability: suppression and bounces (ENGAGEMENT.md Phase 9) ────────
|
||||
//
|
||||
// The phase's acceptance criteria, plus the two things building it showed were
|
||||
// worth pinning because getting either wrong is silent:
|
||||
//
|
||||
// • **the classifier is not `PERMANENT_CODES`** — an EAUTH or a 5.7.1 policy
|
||||
// refusal must not suppress anybody. Reusing that set would have meant one
|
||||
// stale SMTP password suppressing every address the worker touched, and
|
||||
// nothing would have said so.
|
||||
// • **the verification gate excludes at ENQUEUE, on the email channel only** —
|
||||
// a rule spanning email and in-app must still reach an unverified user's
|
||||
// inbox, which is the failure a shared audience filter would have shipped.
|
||||
//
|
||||
// Point the DB at a closed port before requiring anything: the registries reach
|
||||
// utils/discordAnnounce, which builds the pool at require time.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const registries = require('../src/modules/registries')
|
||||
const channels = require('../src/engagement/channels')
|
||||
const engine = require('../src/engagement/engine')
|
||||
const emailChannel = require('../src/engagement/emailChannel')
|
||||
const bounceClassify = require('../src/engagement/bounceClassify')
|
||||
const suppressions = require('../src/engagement/suppressions')
|
||||
const templates = require('../src/engagement/templates')
|
||||
const audiences = require('../src/engagement/audiences')
|
||||
const worker = require('../src/utils/engagementWorker')
|
||||
const mailer = require('../src/utils/mailer')
|
||||
const rulesDb = require('../src/model/engagement/engagementRules.db')
|
||||
const recipients = require('../src/model/engagement/engagementRecipients.db')
|
||||
const suppressionsDb = require('../src/model/engagement/engagementSuppressions.db')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
require('../src/engagement')
|
||||
registries.registerCore()
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const saved = new Map()
|
||||
function patch(mod, name, fn) {
|
||||
if (!saved.has(mod)) saved.set(mod, new Map())
|
||||
if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
|
||||
mod[name] = fn
|
||||
}
|
||||
function restore() {
|
||||
for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
|
||||
saved.clear()
|
||||
}
|
||||
|
||||
const TRIGGER = 'team.forum.post'
|
||||
|
||||
let world
|
||||
|
||||
beforeEach(() => {
|
||||
world = {
|
||||
mails: [],
|
||||
// address_hash → row
|
||||
suppressed: new Map(),
|
||||
verificationRequired: false,
|
||||
unverified: new Set(),
|
||||
sendResult: { ok: true, transport: 'smtp' },
|
||||
}
|
||||
|
||||
patch(mailer, 'sendNotification', async (msg) => {
|
||||
world.mails.push(msg)
|
||||
return world.sendResult
|
||||
})
|
||||
patch(templates, 'renderByKey', async (key) => ({
|
||||
subject: `[${key}]`,
|
||||
html: '<p>x</p>',
|
||||
text: 'x',
|
||||
missing: [],
|
||||
values: {},
|
||||
}))
|
||||
patch(recipients, 'addressFor', async (userId) => ({ address: `u${userId}@example.test` }))
|
||||
patch(recipients, 'filterActive', async (ids) => ids)
|
||||
patch(recipients, 'storedModes', async () => new Map())
|
||||
patch(recipients, 'unverifiedAmong', async (ids) =>
|
||||
new Set(ids.map(Number).filter((id) => world.unverified.has(id))))
|
||||
patch(recipients, 'addressesFor', async (ids) =>
|
||||
new Map(ids.map((id) => [Number(id), `u${id}@example.test`])))
|
||||
patch(rulesDb, 'getById', async () => ({ id: 1, trigger_id: TRIGGER, template_keys: {} }))
|
||||
patch(settings, 'isEmailVerificationRequired', async () => world.verificationRequired)
|
||||
|
||||
patch(suppressionsDb, 'get', async (hash) => world.suppressed.get(hash) || null)
|
||||
patch(suppressionsDb, 'add', async (entry) => {
|
||||
if (world.suppressed.has(entry.address_hash)) return false
|
||||
world.suppressed.set(entry.address_hash, entry)
|
||||
return true
|
||||
})
|
||||
patch(suppressionsDb, 'remove', async (hash) => world.suppressed.delete(hash))
|
||||
patch(suppressionsDb, 'suppressedAmong', async (hashes) =>
|
||||
new Set(hashes.filter((h) => world.suppressed.has(h))))
|
||||
})
|
||||
afterEach(restore)
|
||||
|
||||
const outboxRow = (over = {}) => ({
|
||||
id: 1,
|
||||
rule_id: 1,
|
||||
trigger_id: TRIGGER,
|
||||
user_id: 11,
|
||||
channel: 'email',
|
||||
subject_key: 'The Silver Hand',
|
||||
scope_key: 'team:1',
|
||||
payload: {},
|
||||
attempts: 0,
|
||||
...over,
|
||||
})
|
||||
|
||||
const suppress = (address, reason = 'bounce') =>
|
||||
suppressions.suppress({ address, reason, detail: 'seeded by the test' })
|
||||
|
||||
// ── The classifier: what counts as the recipient's fault ───────────────────
|
||||
//
|
||||
// The single most important group in this file. Everything below it assumes
|
||||
// `classify` is right about which failures are about a person.
|
||||
|
||||
test('a 5.1.1 is a hard bounce', () => {
|
||||
const v = bounceClassify.classify({
|
||||
responseCode: 550,
|
||||
response: '550 5.1.1 <a@b.test>: Recipient address rejected: User unknown',
|
||||
})
|
||||
assert.equal(v.suppress, true)
|
||||
assert.equal(v.reason, 'bounce')
|
||||
})
|
||||
|
||||
// The bug this whole file exists to prevent. `mailer.PERMANENT_CODES` contains
|
||||
// EAUTH, so "terminal failure → suppress" would have emptied the mailing list
|
||||
// the first time an operator's SMTP password expired — with a clean send log and
|
||||
// no warning anywhere.
|
||||
test('an authentication failure suppresses nobody', () => {
|
||||
const v = bounceClassify.classify({ code: 'EAUTH', message: 'Invalid login: 535 5.7.8' })
|
||||
assert.equal(v.suppress, false)
|
||||
assert.match(v.reason, /not a recipient failure/)
|
||||
})
|
||||
|
||||
test('a policy refusal suppresses nobody — 5.7.x is about us, not the address', () => {
|
||||
const v = bounceClassify.classify({ responseCode: 554, response: '554 5.7.1 Message rejected by policy' })
|
||||
assert.equal(v.suppress, false)
|
||||
})
|
||||
|
||||
test('a full mailbox is not a dead one, with or without an enhanced code', () => {
|
||||
assert.equal(bounceClassify.classify({ responseCode: 552, response: '552 5.2.2 Mailbox full' }).suppress, false)
|
||||
// The veto list earning its place: "mailbox unavailable" is in the
|
||||
// no-such-mailbox phrases, and this sentence contains it.
|
||||
assert.equal(
|
||||
bounceClassify.classify({ responseCode: 550, response: '550 Mailbox unavailable: mailbox is full' }).suppress,
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('a temporary failure suppresses nobody', () => {
|
||||
assert.equal(
|
||||
bounceClassify.classify({ responseCode: 451, response: '451 4.7.1 Greylisted, try again later' }).suppress,
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('a bare 550 with an unambiguous reason still bounces', () => {
|
||||
assert.equal(bounceClassify.classify({ responseCode: 550, response: '550 No such user here' }).suppress, true)
|
||||
})
|
||||
|
||||
test('a bare 554 does not, because "transaction failed" means nothing in particular', () => {
|
||||
assert.equal(bounceClassify.classify({ responseCode: 554, response: '554 Transaction failed' }).suppress, false)
|
||||
})
|
||||
|
||||
// A refusal this file has no opinion about must be a refusal, not a guess. The
|
||||
// asymmetry is deliberate: mailing a dead address again costs a retry, and
|
||||
// suppressing a live one costs a person who silently stops hearing from us.
|
||||
test('an unrecognised permanent code is not suppressed', () => {
|
||||
const v = bounceClassify.classify({ responseCode: 550, response: '550 5.4.1 Access denied' })
|
||||
assert.equal(v.suppress, false)
|
||||
assert.match(v.reason, /not a known recipient failure/)
|
||||
})
|
||||
|
||||
// A bounce that quotes another relay's answer carries two enhanced codes. The
|
||||
// one that matters is the one THIS relay gave us, which is the one at the front.
|
||||
test('the enhanced code is read from the front of the response, not found anywhere in it', () => {
|
||||
const v = bounceClassify.classify({
|
||||
responseCode: 554,
|
||||
response: '554 5.7.1 rejected; remote host said: 550 5.1.1 user unknown',
|
||||
})
|
||||
assert.equal(v.suppress, false)
|
||||
})
|
||||
|
||||
// ── The address key ────────────────────────────────────────────────────────
|
||||
|
||||
test('the hash is case- and whitespace-folded, so a bounce finds the row it belongs to', () => {
|
||||
assert.equal(suppressions.hashAddress(' Darrow@Example.TEST '), suppressions.hashAddress('darrow@example.test'))
|
||||
})
|
||||
|
||||
test('the mask keeps the domain and destroys the local part', () => {
|
||||
assert.equal(suppressions.maskAddress('darrow@example.test'), 'd***@example.test')
|
||||
// Two characters is most of a two-character local part, so nothing is kept.
|
||||
assert.equal(suppressions.maskAddress('ab@example.test'), '***@example.test')
|
||||
assert.equal(suppressions.maskAddress('not-an-address'), null)
|
||||
})
|
||||
|
||||
// ── The acceptance criteria ────────────────────────────────────────────────
|
||||
|
||||
test('a suppressed address is skipped with no transport call at all', async () => {
|
||||
await suppress('u11@example.test')
|
||||
const result = await emailChannel.deliver(outboxRow())
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.suppressed, true)
|
||||
assert.equal(world.mails.length, 0, 'the transport must not be called')
|
||||
assert.match(result.detail, /suppressed \(bounce\)/)
|
||||
})
|
||||
|
||||
test("the worker writes that as status='suppressed' in both tables, not as a failure", async () => {
|
||||
const finished = []
|
||||
const logged = []
|
||||
const outboxDb = require('../src/model/engagement/engagementOutbox.db')
|
||||
const sendsDb = require('../src/model/engagement/engagementSends.db')
|
||||
patch(outboxDb, 'claim', async () => true)
|
||||
patch(outboxDb, 'finish', async (id, status, detail) => finished.push({ id, status, detail }))
|
||||
patch(sendsDb, 'record', async (entry) => logged.push(entry))
|
||||
|
||||
await suppress('u11@example.test')
|
||||
const status = await worker.processRow(outboxRow())
|
||||
|
||||
assert.equal(status, 'suppressed')
|
||||
assert.equal(finished[0].status, 'suppressed')
|
||||
assert.equal(logged[0].status, 'suppressed')
|
||||
// The correlation key rides along even here: it is what a later manual audit
|
||||
// matches a relay's own bounce report against.
|
||||
assert.match(logged[0].address_hash, /^[0-9a-f]{64}$/)
|
||||
})
|
||||
|
||||
test('a hard bounce suppresses the address, and the next send never reaches the relay', async () => {
|
||||
world.sendResult = {
|
||||
ok: false,
|
||||
retry: false,
|
||||
transport: 'smtp',
|
||||
detail: 'the relay refused the message',
|
||||
smtp: { code: 'EENVELOPE', responseCode: 550, response: '550 5.1.1 User unknown' },
|
||||
}
|
||||
const first = await emailChannel.deliver(outboxRow())
|
||||
assert.equal(first.ok, false)
|
||||
assert.equal(world.mails.length, 1, 'the first attempt does reach the transport')
|
||||
assert.match(first.detail, /hard bounce: 5\.1\.1/)
|
||||
|
||||
world.sendResult = { ok: true, transport: 'smtp' }
|
||||
const second = await emailChannel.deliver(outboxRow())
|
||||
assert.equal(second.suppressed, true)
|
||||
assert.equal(world.mails.length, 1, 'the second attempt does not')
|
||||
})
|
||||
|
||||
// Without this a genuine dead mailbox could be retried four more times, because
|
||||
// PERMANENT_CODES does not contain every reply code that can carry a 5.1.x —
|
||||
// each retry another refusal on our record with the relay.
|
||||
// `engagement_sends.status` has carried 'bounced' since §4.5 and nothing wrote
|
||||
// it until this phase, so the Send Log's "Bounced" filter matched nothing. The
|
||||
// live rig is what showed that; this is what keeps it fixed. The outbox row is
|
||||
// still 'failed' — that ENUM has no 'bounced', and from the queue's point of
|
||||
// view a bounced row is simply one that finished unsuccessfully.
|
||||
test("a hard bounce is logged as 'bounced', while the outbox row is 'failed'", async () => {
|
||||
const finished = []
|
||||
const logged = []
|
||||
const outboxDb = require('../src/model/engagement/engagementOutbox.db')
|
||||
const sendsDb = require('../src/model/engagement/engagementSends.db')
|
||||
patch(outboxDb, 'claim', async () => true)
|
||||
patch(outboxDb, 'finish', async (id, status) => finished.push(status))
|
||||
patch(sendsDb, 'record', async (entry) => logged.push(entry))
|
||||
|
||||
world.sendResult = {
|
||||
ok: false,
|
||||
retry: true,
|
||||
transport: 'smtp',
|
||||
detail: 'refused',
|
||||
smtp: { code: 'EENVELOPE', responseCode: 550, response: '550 5.1.1 User unknown' },
|
||||
}
|
||||
const status = await worker.processRow(outboxRow())
|
||||
|
||||
assert.equal(status, 'bounced')
|
||||
assert.equal(logged[0].status, 'bounced')
|
||||
assert.equal(finished[0], 'failed')
|
||||
})
|
||||
|
||||
test('a bounce is terminal even when the mailer called the failure retryable', async () => {
|
||||
world.sendResult = {
|
||||
ok: false,
|
||||
retry: true,
|
||||
transport: 'smtp',
|
||||
detail: 'refused',
|
||||
smtp: { code: null, responseCode: 550, response: '550 5.1.1 User unknown' },
|
||||
}
|
||||
const result = await emailChannel.deliver(outboxRow())
|
||||
assert.equal(result.retry, false)
|
||||
})
|
||||
|
||||
test('a failure that is NOT a bounce stays retryable and suppresses nobody', async () => {
|
||||
world.sendResult = {
|
||||
ok: false,
|
||||
retry: true,
|
||||
transport: 'smtp',
|
||||
detail: 'connection refused',
|
||||
smtp: { code: 'ECONNECTION', responseCode: null, response: null },
|
||||
}
|
||||
const result = await emailChannel.deliver(outboxRow())
|
||||
assert.equal(result.retry, true)
|
||||
assert.equal(world.suppressed.size, 0)
|
||||
// The send log says why it was not suppressed, so the non-event is explained
|
||||
// rather than merely absent.
|
||||
assert.match(result.detail, /not suppressed/)
|
||||
})
|
||||
|
||||
// The suppression list must never be able to stop mail going out. A database
|
||||
// that cannot answer "is this suppressed" is a database problem, and turning it
|
||||
// into total silence with a clean send log is G22's shape all over again.
|
||||
test('a suppression check that throws sends the mail anyway', async () => {
|
||||
patch(suppressionsDb, 'get', async () => { throw new Error('db is down') })
|
||||
const result = await emailChannel.deliver(outboxRow())
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(world.mails.length, 1)
|
||||
})
|
||||
|
||||
test('the first reason an address was suppressed is the one that survives', async () => {
|
||||
await suppress('u11@example.test', 'bounce')
|
||||
await suppressions.suppress({ address: 'u11@example.test', reason: 'manual' })
|
||||
const row = world.suppressed.get(suppressions.hashAddress('u11@example.test'))
|
||||
assert.equal(row.reason, 'bounce')
|
||||
})
|
||||
|
||||
test('un-suppressing is the way back, and it is the only one', async () => {
|
||||
await suppress('u11@example.test')
|
||||
assert.equal(await suppressions.unsuppress('u11@example.test'), true)
|
||||
const result = await emailChannel.deliver(outboxRow())
|
||||
assert.equal(result.ok, true)
|
||||
})
|
||||
|
||||
// ── The verification gate (§7.1 Q1's narrower half) ────────────────────────
|
||||
|
||||
test('with the gate off, an unverified address is mailed', async () => {
|
||||
world.unverified.add(11)
|
||||
const gated = await channels.eligibleFor('email', [11, 12])
|
||||
assert.deepEqual(gated.userIds, [11, 12])
|
||||
assert.deepEqual(gated.excluded, {})
|
||||
})
|
||||
|
||||
test('with the gate on, an unverified user is excluded and counted', async () => {
|
||||
world.verificationRequired = true
|
||||
world.unverified.add(11)
|
||||
const gated = await channels.eligibleFor('email', [11, 12])
|
||||
assert.deepEqual(gated.userIds, [12])
|
||||
assert.deepEqual(gated.excluded, { unverified: 1 })
|
||||
})
|
||||
|
||||
// The reason the gate is a CHANNEL hook rather than a filter on the shared
|
||||
// audience. A rule spanning both channels must still put an item in an
|
||||
// unverified user's inbox — being unverified is a reason not to mail somebody
|
||||
// and no reason at all to hide their notifications from them.
|
||||
test('the gate is email-only: in-app still reaches an unverified user', async () => {
|
||||
world.verificationRequired = true
|
||||
world.unverified.add(11)
|
||||
const inapp = await channels.eligibleFor('inapp', [11, 12])
|
||||
assert.deepEqual(inapp.userIds, [11, 12])
|
||||
const push = await channels.eligibleFor('push', [11, 12])
|
||||
assert.deepEqual(push.userIds, [11, 12])
|
||||
})
|
||||
|
||||
// The gate must not be able to stop mail by failing, and the blast radius is
|
||||
// wider than one recipient: `applyRule` awaits this before the per-user loop, so
|
||||
// a throw would abandon the whole rule for every channel it names — a rule that
|
||||
// silently sent nothing, with a clean send log and an empty outbox.
|
||||
test('a gate that cannot be read is off, for every recipient and every channel', async () => {
|
||||
patch(settings, 'isEmailVerificationRequired', async () => { throw new Error('db is down') })
|
||||
const gated = await channels.eligibleFor('email', [11, 12])
|
||||
assert.deepEqual(gated.userIds, [11, 12])
|
||||
assert.deepEqual(gated.excluded, {})
|
||||
})
|
||||
|
||||
test('and so is a gate whose user lookup fails', async () => {
|
||||
world.verificationRequired = true
|
||||
patch(recipients, 'unverifiedAmong', async () => { throw new Error('db is down') })
|
||||
const gated = await channels.eligibleFor('email', [11, 12])
|
||||
assert.deepEqual(gated.userIds, [11, 12])
|
||||
})
|
||||
|
||||
// ── The engine end to end ──────────────────────────────────────────────────
|
||||
|
||||
test('the engine writes no outbox row for an excluded user, and counts the exclusion', async () => {
|
||||
const enqueued = []
|
||||
const outboxDb = require('../src/model/engagement/engagementOutbox.db')
|
||||
const cooldownsDb = require('../src/model/engagement/engagementCooldowns.db')
|
||||
const sendsDb = require('../src/model/engagement/engagementSends.db')
|
||||
patch(outboxDb, 'enqueue', async (row) => { enqueued.push(row); return enqueued.length })
|
||||
patch(cooldownsDb, 'claim', async () => true)
|
||||
patch(sendsDb, 'countSentSince', async () => 0)
|
||||
patch(audiences, 'resolveForRule', async () => ({
|
||||
userIds: [11, 12],
|
||||
ceiling: 'authenticated',
|
||||
dormant: false,
|
||||
}))
|
||||
patch(audiences, 'permitted', () => true)
|
||||
// Both channels default to a mode that enqueues only where the user opted in;
|
||||
// email is opt-in, so say so for both users.
|
||||
patch(recipients, 'storedModes', async (ids) => new Map(ids.map((id) => [Number(id), 'instant'])))
|
||||
|
||||
world.verificationRequired = true
|
||||
world.unverified.add(11)
|
||||
|
||||
const rule = {
|
||||
id: 1,
|
||||
trigger_id: TRIGGER,
|
||||
channels: ['email', 'inapp'],
|
||||
max_sends_per_hour: 100,
|
||||
cooldown_seconds: 0,
|
||||
delay_seconds: 0,
|
||||
conditions: null,
|
||||
}
|
||||
const summary = await engine.applyRule(rule, { triggerId: TRIGGER, data: {}, subject: 's' }, new Date())
|
||||
|
||||
assert.deepEqual(summary.ineligible, { unverified: 1 })
|
||||
const emailRows = enqueued.filter((r) => r.channel === 'email').map((r) => r.user_id)
|
||||
const inappRows = enqueued.filter((r) => r.channel === 'inapp').map((r) => r.user_id)
|
||||
assert.deepEqual(emailRows, [12], 'the unverified user gets no mail')
|
||||
assert.deepEqual(inappRows.sort(), [11, 12], 'and still gets an inbox item')
|
||||
})
|
||||
@@ -419,10 +419,14 @@ test('GET /admin/engagement/triggers serves core\'s declarations and the ceiling
|
||||
assert.ok(news.variables.some((v) => v.name === 'title' && v.example))
|
||||
// The lattice travels with the catalog so the rule editor never offers an
|
||||
// audience the server will refuse.
|
||||
// `staff` permits itself and `admin` beneath it — the one refinement in the
|
||||
// tree (Phase 11). Every other branch permits itself alone.
|
||||
const staff = res.body.ceilings.find((c) => c.id === 'staff')
|
||||
assert.deepEqual(staff.permits, ['staff'])
|
||||
assert.deepEqual(staff.permits, ['staff', 'admin'])
|
||||
const admin = res.body.ceilings.find((c) => c.id === 'admin')
|
||||
assert.deepEqual(admin.permits, ['admin'])
|
||||
const everyone = res.body.ceilings.find((c) => c.id === 'everyone')
|
||||
assert.equal(everyone.permits.length, 6)
|
||||
assert.equal(everyone.permits.length, 7)
|
||||
})
|
||||
|
||||
test('GET /admin/engagement/audiences never serves a resolver', () => {
|
||||
|
||||
127
server/test/newsNotify.test.js
Normal file
127
server/test/newsNotify.test.js
Normal file
@@ -0,0 +1,127 @@
|
||||
// ── Core's `news.post` emitter (ENGAGEMENT.md §7.1 Q9, Phase 11) ───────────
|
||||
//
|
||||
// `news.post` was a declared payload contract with NO CALLER from Phase 2 until
|
||||
// this phase — a rule naming it could never fire, so on a real deployment the
|
||||
// only mail or inbox item a rule could produce came from Teams. This is the test
|
||||
// for the call that fixes it, and for the two things about it that are decisions
|
||||
// rather than plumbing:
|
||||
//
|
||||
// 1. The emit REPLACED `pushDispatch.publish('news.post', …)`, so news push now
|
||||
// rides a rule. Core seeds that rule DISABLED, which is why news push stops
|
||||
// on upgrade — deliberately, on the Phase 6 Team precedent.
|
||||
// 2. The seed carries its OWN one-shot key. The Team key is already stamped on
|
||||
// every deployment that has booted since Phase 6, and those are exactly the
|
||||
// deployments that lose their raw push — so joining that group would have
|
||||
// seeded the news rule on fresh installs only.
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const registries = require('../src/modules/registries')
|
||||
const newsNotify = require('../src/utils/newsNotify')
|
||||
const coreRules = require('../src/engagement/coreRules')
|
||||
|
||||
registries.registerCore()
|
||||
|
||||
const POST = {
|
||||
id: 412,
|
||||
title: 'Five on Friday — the Yew invasion',
|
||||
excerpt: 'Four new champion spawns, and the fate of the Yew moongate.',
|
||||
category: 'Five on Friday',
|
||||
}
|
||||
|
||||
test('a publish emits news.post with the declaration\'s own variables', () => {
|
||||
const ok = newsNotify.emitNewsPost(POST)
|
||||
assert.equal(ok, true)
|
||||
})
|
||||
|
||||
test('the emitted payload satisfies the declared contract', () => {
|
||||
// `emit` throws in dev on a payload that misses a required variable, so the
|
||||
// assertion that it did not throw above is already most of this. This says
|
||||
// which variables, so a future declaration change breaks here with a name.
|
||||
const declaration = registries.eventTrigger('news.post')
|
||||
const required = declaration.variables.filter((v) => v.required).map((v) => v.name)
|
||||
assert.deepEqual(required.sort(), ['postUrl', 'title'])
|
||||
})
|
||||
|
||||
test('postUrl is the news LIST, because the site has no per-post route', () => {
|
||||
// `App.jsx` mounts `/site/news` and nothing under it, which is why
|
||||
// `announceJobs.logic.js` links the list from the Discord and town-crier
|
||||
// announcements too. The declaration's `example` used to name `/news/<slug>`,
|
||||
// a path that 404s — and an example is what the template editor previews and
|
||||
// test-sends with, so a wrong one is a preview that looks right.
|
||||
assert.equal(newsNotify.NEWS_PATH, '/site/news')
|
||||
assert.ok(newsNotify.NEWS_PATH.startsWith('/'), 'site-relative, because it ends up in an href')
|
||||
assert.ok(!newsNotify.NEWS_PATH.startsWith('//'), 'not protocol-relative')
|
||||
const declaration = registries.eventTrigger('news.post')
|
||||
const postUrl = declaration.variables.find((v) => v.name === 'postUrl')
|
||||
assert.equal(postUrl.example, newsNotify.NEWS_PATH, 'the example is the value the emitter sends')
|
||||
})
|
||||
|
||||
test('a post with no excerpt falls back to the body, stripped of markup', () => {
|
||||
const text = newsNotify.excerptFrom({ body: '<p>Hello <b>world</b></p>' })
|
||||
assert.equal(text, 'Hello world')
|
||||
})
|
||||
|
||||
test('an empty post omits the excerpt rather than sending a blank one', () => {
|
||||
// `excerpt` is declared optional. An absent optional renders as absent; an
|
||||
// empty string renders as a blank line where a summary should be.
|
||||
assert.equal(newsNotify.excerptFrom({}), null)
|
||||
assert.equal(newsNotify.excerptFrom({ body: '<p> </p>' }), null)
|
||||
})
|
||||
|
||||
test('a long body is truncated rather than reproduced in the mail', () => {
|
||||
const text = newsNotify.excerptFrom({ body: 'x'.repeat(500) })
|
||||
assert.equal(text.length, newsNotify.EXCERPT_CHARS)
|
||||
assert.ok(text.endsWith('…'))
|
||||
})
|
||||
|
||||
test('a malformed post is a no-op, never an exception on the publish path', () => {
|
||||
// This runs inside `announceIfNewlyPublished`, after the post has been saved.
|
||||
// A notification that can fail the write behind it is a defect.
|
||||
assert.equal(newsNotify.emitNewsPost(null), false)
|
||||
assert.equal(newsNotify.emitNewsPost({}), false)
|
||||
})
|
||||
|
||||
// ── The seed, and why it is its own group ──────────────────────────────────
|
||||
|
||||
test('the news rule is seeded, disabled, on all three channels', () => {
|
||||
assert.equal(coreRules.NEWS_RULES.length, 1)
|
||||
const [rule] = coreRules.NEWS_RULES
|
||||
assert.equal(rule.trigger_id, 'news.post')
|
||||
assert.equal(rule.audience, 'subscribers')
|
||||
// **Push is on this rule** because push is what the raw tickle did. Leaving it
|
||||
// off would mean an operator who enabled the rule to restore news push got
|
||||
// mail instead.
|
||||
assert.deepEqual(rule.channels, ['email', 'inapp', 'push'])
|
||||
// Nothing core seeds is ever enabled — `seedGroup` stamps `enabled: 0` over
|
||||
// every entry, so the rule cannot ship on even by accident.
|
||||
assert.equal(rule.enabled, undefined)
|
||||
})
|
||||
|
||||
test('the news seed has its own one-shot key, separate from the Team group\'s', () => {
|
||||
// The failure this prevents: the Team key is already stamped on every
|
||||
// deployment that has booted since Phase 6, and the guard reads its presence.
|
||||
// Appending to the Team list would have seeded the news rule on fresh installs
|
||||
// only — and on exactly the upgrades that lose their raw news push, never.
|
||||
assert.notEqual(coreRules.NEWS_SEEDED_KEY, coreRules.SEEDED_KEY)
|
||||
assert.equal(coreRules.SEEDED_KEY, 'engagement_team_rules_seeded')
|
||||
assert.equal(coreRules.NEWS_SEEDED_KEY, 'engagement_news_rule_seeded')
|
||||
})
|
||||
|
||||
test('the audience the rule names is one the trigger\'s ceiling permits', () => {
|
||||
const ceilings = require('../src/modules/ceilings')
|
||||
const declaration = registries.eventTrigger('news.post')
|
||||
for (const rule of [...coreRules.RULES, ...coreRules.NEWS_RULES]) {
|
||||
const d = registries.eventTrigger(rule.trigger_id)
|
||||
assert.ok(d, `${rule.trigger_id} is declared`)
|
||||
assert.ok(
|
||||
ceilings.permits(d.ceiling, rule.audience),
|
||||
`${rule.trigger_id}: audience "${rule.audience}" is within ceiling "${d.ceiling}"`,
|
||||
)
|
||||
}
|
||||
// Named explicitly, because it is the one seeded rule whose ceiling is wider
|
||||
// than its audience: a rule editor may widen news to `authenticated`, and
|
||||
// deliberately may not widen a Team event past `members`.
|
||||
assert.equal(declaration.ceiling, 'authenticated')
|
||||
})
|
||||
@@ -35,6 +35,9 @@ after(() => db.close())
|
||||
const USER = 7
|
||||
const PLAYER = { id: USER, role: 'player' }
|
||||
const ADMIN = { id: USER, role: 'admin' }
|
||||
// Phase 11 added the `admin` ceiling beneath `staff`, so the interesting viewer
|
||||
// is no longer "player vs staff" but the one INSIDE `staff` and outside `admin`.
|
||||
const EDITOR = { id: USER, role: 'editor' }
|
||||
|
||||
// ── In-memory stand-ins for the two tables ─────────────────────────────────
|
||||
//
|
||||
@@ -338,6 +341,60 @@ test('a staff-ceilinged trigger is not offered to a player, and is to staff', as
|
||||
assert.equal(prefRows.size, 0)
|
||||
})
|
||||
|
||||
|
||||
// **The Phase 11 ceiling, and the reason `visibleTo` stopped asking about `staff`
|
||||
// by name.** Its rule used to be `ceiling !== 'staff'`, which was correct while
|
||||
// `staff` was the only role-gated value and would have silently published every
|
||||
// admin-ceilinged id to every player the day `admin` arrived. An editor is the
|
||||
// viewer that tells the two apart: inside `staff`, outside `admin`.
|
||||
test('an admin-ceilinged trigger is hidden from a player AND from an editor', async () => {
|
||||
const api = registries.stage('uo')
|
||||
api.registerEventTriggers([{
|
||||
id: 'uo.audit.staff_action',
|
||||
label: 'A staff member acted in game',
|
||||
ceiling: 'admin',
|
||||
audience: 'admin',
|
||||
variables: [{ name: 'action', type: 'string', required: true, example: 'set' }],
|
||||
}])
|
||||
registries.apply(api.staged)
|
||||
|
||||
const asPlayer = await prefs.getForUser(USER, PLAYER)
|
||||
assert.equal(item(asPlayer, 'uo.audit.staff_action'), undefined, 'a player is not told it exists')
|
||||
|
||||
// The one an `!== staff` test would have got wrong: an editor IS staff, and a
|
||||
// digest of what staff did in game is not for them.
|
||||
const asEditor = await prefs.getForUser(USER, EDITOR)
|
||||
assert.equal(item(asEditor, 'uo.audit.staff_action'), undefined, 'an editor is not told either')
|
||||
|
||||
const asAdmin = await prefs.getForUser(USER, ADMIN)
|
||||
assert.ok(item(asAdmin, 'uo.audit.staff_action'), 'an admin is')
|
||||
|
||||
// A gate, not a display rule: an editor who knows the id still cannot store a
|
||||
// preference for it.
|
||||
await notifCtrl.putChannelPrefs(
|
||||
{ user: EDITOR, body: { prefs: [{ id: 'uo.audit.staff_action', channel: 'email', mode: 'instant' }] } },
|
||||
mockRes(),
|
||||
)
|
||||
assert.equal(prefRows.size, 0)
|
||||
})
|
||||
|
||||
// The counterpart, so the generalisation did not quietly hide more than it should.
|
||||
test('a staff-ceilinged trigger is still offered to an editor', async () => {
|
||||
const api = registries.stage('uo')
|
||||
api.registerEventTriggers([{
|
||||
id: 'uo.page.new',
|
||||
label: 'A player opened a help page',
|
||||
ceiling: 'staff',
|
||||
audience: 'staff',
|
||||
variables: [{ name: 'pageType', type: 'string', required: true, example: 'Stuck' }],
|
||||
}])
|
||||
registries.apply(api.staged)
|
||||
|
||||
const asEditor = await prefs.getForUser(USER, EDITOR)
|
||||
assert.ok(item(asEditor, 'uo.page.new'), 'an editor is inside the staff ceiling')
|
||||
assert.equal(item(await prefs.getForUser(USER, PLAYER), 'uo.page.new'), undefined)
|
||||
})
|
||||
|
||||
// ── The channel registry itself ────────────────────────────────────────────
|
||||
|
||||
test('the registry refuses a channel that under-declares', async () => {
|
||||
|
||||
Reference in New Issue
Block a user