Een dating-app op basis van brieven, niet van gezichten!
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import { Hono } from "hono";
import { createHash, timingSafeEqual } from "node:crypto";
import pg from "pg";
import { rateLimiter, clientIp, inWords } from "./ratelimit.js";
import { esc, page, SERIF } from "./huisstijl.js";
import { brief, vel, inhoud, letterlijk } from "./mail-html.js";
import { sendMail, sendBatch, mailReady, streamSuppressions, unsuppress } from "./mail.js";
import { STORE_TITLE, STORE_SHORT, STORE_KERN, STORE_AGE, STORE_URL } from "../src/content/store.js";
/*
* Het promotieteam: de vrienden die vrijwillig mond-tot-mondreclame maken. Eén afgeschermde
* pagina waar de beheerder de ledenlijst bijhoudt en het team mailt — server-gerenderde HTML,
* geen JavaScript, geen framework, op hetzelfde vel als de publieke pagina's (server/huisstijl.js).
*
* Drie dingen die dit bewust níet is:
* * Geen deel van de app-database. Die wordt bij elke schemawijziging opnieuw aangemaakt en
* haar gegevens zijn wegwerpbaar; de namen van je vrienden zijn dat niet. Eigen pool, eigen
* database (PROMOTIE_DATABASE_URL), eigen setup: db/promotie/.
* * Geen meetinstrument. Er is geen quotum en geen controle, dus er wordt niets geteld en
* niets toegeschreven — geen aanbrengcodes, geen koppeling met wie zich aanmeldde.
* * Geen publieke pagina. Er is geen link heen, er staat noindex op, en zonder het wachtwoord
* kom je er niet in. Vrienden vullen hier niets in; de beheerder typt ze zelf.
*
* Toegang: één wachtwoord uit PROMOTIE_PASSWORD, via HTTP Basic (de gebruikersnaam doet niet
* mee — vul in wat je wilt). Het wachtwoord staat met opzet níet in deze broncode: de
* repository is openbaar. Zonder die variabele blijft de pagina dicht in plaats van open.
*
* De post gaat via Postmark (server/mail.js), vanaf een adres op het platformdomein zodat de
* handtekening uitlijnt. Eén mail per persoon, met een eigen aanhef — geen bcc-blok waarin
* iedereen elkaars adres ziet. Het afmelden onder de groepspost is van Postmark (zie `staart`);
* wie klikt komt hier vanzelf op Gestopt te staan.
*/
export const PROMOTIE_PATH = "/promotieteam";
/*
* De afzender. Niet `yesreply@` (dat is het adres voor systeempost en landt bij de
* klantenservice) en niet `privacy@` (dat is het AVG-loket): post over het promotieteam is de
* marketingkant, dus `marketing@`, dat naar Platforms/Liefdesbrieven/Marketing sorteert. Ook
* niet `nieuwsbrief@` — dat sorteert naar dezelfde map, maar hoort bij de nieuwsbrief zelf en
* blijft daarvoor vrij. Het persoonlijke zit in de weergavenaam en in de tekst, niet in het adres.
*/
const FROM = process.env.PROMOTIE_FROM ?? "Bram van Liefdesbrieven <marketing@liefdesbrieven.bramluiken.org>";
/* Groepspost hoort in een broadcast-stream, post aan één persoon in de transactionele */
const STREAM_GROEP = process.env.PROMOTIE_STREAM ?? "liefdesbrieven-promotieteam";
const STREAM_EEN = "outbound";
const pool = new pg.Pool({
connectionString: process.env.PROMOTIE_DATABASE_URL ?? "postgres://postgres@localhost:5432/promotie",
});
const query = (text, params) => pool.query(text, params);
/* ---------- Toegang ---------- */
/* Constante tijd, en via sha256 zodat de vergelijking niet op lengte kan verschillen */
const sameSecret = (a, b) =>
timingSafeEqual(createHash("sha256").update(a).digest(), createHash("sha256").update(b).digest());
/*
* Twee remmen op het raden, en dat is met opzet niet één. Deze pagina heeft één wachtwoord en verder
* niets — geen tweede factor, geen account — en beheert de namen, adressen en telefoonnummers van
* vrienden én de knop die het hele team mailt. Dan hoort het slot niet af te hangen van of het
* adres van de bezoeker klopt: dat is precies wat er een keer misging (zie clientIp in
* server/ratelimit.js), en toen was dit de plek waar het het meest kostte.
*
* De tweede teller loopt over álle pogingen samen, ongeacht waar ze vandaan komen. Dat kan een
* aanhoudende aanval de pagina een kwartier lang dichtzetten, ook voor de beheerder — en dat is hier
* de goede kant om te falen: dit is een ledenlijst die kan wachten, en niet de app.
*/
const guessByIp = rateLimiter({ windowMs: 15 * 60_000, max: 10 });
const guessTotaal = rateLimiter({ windowMs: 15 * 60_000, max: 30 });
const passwordFrom = (c) => {
const m = /^Basic\s+(\S+)$/i.exec(c.req.header("authorization") ?? "");
if (!m) return null;
const decoded = Buffer.from(m[1], "base64").toString("utf8");
const i = decoded.indexOf(":");
return i === -1 ? null : decoded.slice(i + 1);
};
const promotie = new Hono();
promotie.use(async (c, next) => {
c.header("X-Robots-Tag", "noindex, nofollow");
c.header("Cache-Control", "no-store");
const secret = process.env.PROMOTIE_PASSWORD ?? "";
if (!secret) {
return c.text("Deze pagina is niet ingesteld: PROMOTIE_PASSWORD ontbreekt in de omgeving.", 503);
}
const given = passwordFrom(c);
if (given !== null && sameSecret(given, secret)) {
/*
* Een formulier dat vanaf een andere site wordt verstuurd, stuurt de Basic-inloggegevens
* van de browser gewoon mee. Vandaar deze controle: een POST hoort van deze pagina zelf
* te komen. Browsers zetten Origin op elke POST; is hij er niet, dan komt het niet uit
* een pagina (curl, een app) en dat mag.
*/
if (c.req.method === "POST") {
const origin = c.req.header("origin");
const host = c.req.header("host");
if (origin && new URL(origin).host !== host) return c.text("Verkeerde herkomst", 403);
}
return next();
}
/*
* Alleen een verkeerd wachtwoord telt als poging. Een verzoek zónder Authorization-header is
* de gewone eerste helft van HTTP Basic — de browser vraagt, krijgt 401, zet het venster neer —
* en wie dat meetelt sluit zichzelf buiten na tien keer de pagina openen.
*/
if (given !== null) {
const perAdres = guessByIp.hit(`promotie:${clientIp(c)}`);
const samen = guessTotaal.hit("promotie");
if (!perAdres.ok || !samen.ok) {
const wacht = Math.max(perAdres.retryAfterSec, samen.retryAfterSec);
c.header("Retry-After", String(Math.max(1, Math.ceil(wacht))));
return c.text(`Te veel pogingen — probeer het ${inWords(wacht)} opnieuw`, 429);
}
}
c.header("WWW-Authenticate", 'Basic realm="Promotieteam", charset="UTF-8"');
return c.text("Wachtwoord vereist", 401);
});
/* ---------- De pagina ---------- */
/*
* Hetzelfde vel als de publieke pagina's (server/huisstijl.js), met de velden van een beheerpagina
* erop. Alleen wat een formulier eigen is staat hier; palet, letters en donkere modus komen daar
* vandaan, en `noindex` gaat als meta mee naast de header die de middleware zet.
*/
const BEHEER_CSS = `
.vel{max-width:760px}
h2{font-size:1.1rem;margin:30px 0 10px}
.melding{margin:0 0 16px;padding:10px 12px;background:var(--panel-2);border-left:3px solid var(--accent)}
.fout{border-left-color:var(--alert);background:color-mix(in srgb,var(--alert) 12%,var(--panel))}
form.rij{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:0 0 10px;padding:12px;border:1px solid var(--rule)}
form.rij.gestopt{background:var(--paper)}
form.rij .breed{grid-column:1 / -1}
input,textarea{width:100%;padding:7px 9px;border:1px solid var(--rule);background:var(--paper);
color:var(--ink);font:inherit;font-size:15px}
textarea{min-height:150px;resize:vertical;line-height:1.5;font-family:${SERIF}}
input:focus,textarea:focus{outline:2px solid var(--accent);outline-offset:-1px}
.knoppen{grid-column:1 / -1;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
button{padding:7px 14px;border:1px solid var(--knop);background:var(--knop);color:var(--knop-ink);
font:inherit;font-size:15px;cursor:pointer}
button.stil{background:transparent;color:var(--accent);border-color:var(--accent)}
button[disabled]{opacity:.5;cursor:not-allowed}
a.stil{padding:7px 14px;border:1px solid var(--rule);color:var(--mute);text-decoration:none}
.knoppen .sinds{margin-left:auto}
.brief{padding:16px 18px;background:var(--paper);border:1px solid var(--rule);font-size:15px}
/* De voorbeelden dragen de kleuren van de post zelf; op deze pagina hoort de link die van hier te volgen */
.brief a{color:var(--accent)!important}
.post{padding:20px 16px;background:var(--paper);border:1px solid var(--rule);border-top:0}
.kop{padding:10px 18px;background:var(--panel-2);border:1px solid var(--rule);border-bottom:0;font-size:14px}
table{width:100%;border-collapse:collapse;font-size:14px}
td,th{text-align:left;padding:6px 8px;border-bottom:1px solid var(--rule);vertical-align:top}
@media (max-width:560px){form.rij{grid-template-columns:1fr}.knoppen .sinds{margin-left:0;width:100%}}
`;
const beheerPagina = (title, body) => page(title, body, { css: BEHEER_CSS, robots: "noindex, nofollow" });
const MELDINGEN = {
toegevoegd: "Toegevoegd aan het team.",
gemaild: "Toegevoegd, en de welkomstmail is onderweg.",
welkom: "Welkomstmail verstuurd.",
opgeslagen: "Opgeslagen.",
sjabloon: "De welkomstmail is bijgewerkt.",
gestopt: "Op gestopt gezet — de gegevens blijven staan.",
terug: "Doet weer mee.",
gewist: "Gewist.",
};
/*
* Onder elk tekstvak dat post wordt. Kort, want het is geen handleiding: wie niets van opmaak weet
* typt gewoon zinnen en krijgt zinnen. Zie server/mail-html.js voor wat er precies herkend wordt.
*/
const OPMAAK_UITLEG = `<p class="mute">Opmaak mag: <code>**vet**</code>, <code>*cursief*</code>,
<code>[tekst](https://…)</code>, en een opsomming met een <code>-</code> aan het begin van de regel.
Wie geen opmaak leest, krijgt precies de tekens die je intypte.</p>`;
const veld = (naam, label, waarde, extra = "") =>
`<input name="${naam}" value="${esc(waarde)}" placeholder="${esc(label)}" aria-label="${esc(label)}" ${extra}>`;
/*
* Eén lid: een formulier op zichzelf, zodat bewerken en opslaan zonder JavaScript kan. `kapot` is
* de verzameling adressen waar Postmark niets meer heen krijgt (hard bounce) — die staan er
* bijgeschreven, want een adres met een tikfout ziet er in de lijst precies goed uit.
*/
const lidRij = (p, kapot = new Set()) => `
<form class="rij ${p.left_on ? "gestopt" : ""}" method="post" action="${PROMOTIE_PATH}/${p.id}">
${veld("name", "Naam", p.name, "required maxlength=80")}
${veld("email", "E-mail", p.email ?? "", "type=email maxlength=254")}
${veld("phone", "Telefoon", p.phone ?? "", "maxlength=40")}
${veld("note", "Notitie", p.note, "maxlength=200")}
<div class="knoppen">
<button name="actie" value="opslaan">Opslaan</button>
${p.left_on
? `<button class="stil" name="actie" value="terug">Doet weer mee</button>`
: `<button class="stil" name="actie" value="gestopt">Gestopt</button>`}
${!p.left_on && p.email && !p.welcomed_at
? `<button class="stil" name="actie" value="welkom">Welkom sturen</button>`
: ""}
<a class="stil" href="${PROMOTIE_PATH}/${p.id}/wissen">Wissen</a>
<span class="mute mono sinds">${p.email && kapot.has(p.email.toLowerCase())
? `<strong>adres bestaat niet</strong> · `
: ""}${p.left_on
? `${esc(p.sinds)} – ${esc(p.tot)}${p.unsubscribed_at ? " · zelf afgemeld" : ""}`
: `sinds ${esc(p.sinds)}${p.email ? (p.welcomed_at ? " · welkom ✓" : " · geen welkomstmail") : ""}`}</span>
</div>
</form>`;
/* ---------- De post ---------- */
/*
* `{naam}` is de enige invulling: de aanhef. Zo blijft een mail schrijven het schrijven van een brief.
* In de HTML-versie gaat die naam langs `letterlijk`, zodat iemand die "Jan [de] *Vries*" heet daar
* geen opmaak van maakt; in de tekstversie staat hij zoals hij in de lijst staat.
*/
const vulIn = (tekst, p, hoe = (s) => s) => tekst.replaceAll("{naam}", hoe(p.name));
/* In de tekstversie scheidt een streepje wat er automatisch onder komt; in de HTML doet de lijn dat. */
const metStaart = (tekst, staart) => `${tekst}\n\n—\n${staart}`;
/*
* De staart onder de groepspost. Bij een broadcast-stream regelt Postmark het afmelden zelf: hun
* link, hun headers, en hun eigen regel eronder — dat laatste is alleen uit te zetten met
* toestemming voor "custom unsubscribe handling", die dit account niet heeft. Vandaar hun
* placeholder in onze eigen zin: dan staat er één nette regel in plaats van twee links onder
* elkaar. Wie klikt komt bij Postmark op de afmeldlijst; `synchroniseerAfmeldingen()` haalt die
* op en zet diezelfde persoon hier op Gestopt, zodat de lijst klopt.
*
* En daarom staat er "uit het promotieteam" en niet "deze post niet meer": afmelden ís hier uit
* het team stappen. Een zin die minder belooft dan er gebeurt, is een zin die niet klopt.
*/
const AFMELD = "{{{ pm:unsubscribe }}}"; // Postmark vult hier de persoonlijke link in
/*
* Twee versies van dezelfde zin, en dat verschil is precies waarvoor de HTML-versie bestaat: de
* afmeld-URL van Postmark is een regel of vijf lang, dus in de tekst staat hij voluit en in de brief
* achter woorden. Verder is de opmaak wat platte tekst ook doet.
*/
const AFMELD_TEKST = `Wil je uit het promotieteam? Meld je gerust af: ${AFMELD}`;
const AFMELD_BRIEF = `Wil je uit het promotieteam? [Meld je gerust af](${AFMELD}).`;
const groepsBericht = (p, subject, body) => ({
from: FROM,
to: p.email,
subject,
text: metStaart(vulIn(body, p), AFMELD_TEKST),
html: brief(vulIn(body, p, letterlijk), AFMELD_BRIEF),
stream: STREAM_GROEP,
});
/*
* De terugweg van die afmeldknop, en hij gaat twee kanten op. Afmelden en een spamklacht
* betekenen allebei "hier gaat geen post meer heen", en dat is precies wat Gestopt hier betekent.
* Maar Postmark zet op zijn afmeldpagina meteen "did you do that by accident? click here to
* resubscribe" — een misklik herstel je dus in dezelfde seconde, en dan hoort die persoon hier
* gewoon weer mee te doen zonder dat iemand het met de hand rechtzet. Vandaar `unsubscribed_at`:
* alleen wie via de mail vertrok komt via de mail terug. Wie jij op Gestopt zette staat niet op
* die lijst en blijft dus staan waar hij staat.
*
* Een hard bounce telt niet als vertrek: dat is geen besluit maar een adres dat niet bestaat, en
* dat hoort in beeld te komen zodat je het verbetert. Loopt het ophalen mis (Postmark plat, geen
* token), dan is dat geen reden om de pagina te laten vallen: je krijgt de lijst zoals hij hier
* bekend is.
*/
async function synchroniseerAfmeldingen() {
const lijst = await streamSuppressions(STREAM_GROEP);
const afgemeld = lijst.filter((s) => s.reden === "ManualSuppression" || s.reden === "SpamComplaint").map((s) => s.email);
await query(`
update promoters set left_on = current_date, unsubscribed_at = now()
where left_on is null and lower(email) = any($1)`, [afgemeld]);
/* Weer van de lijst af (zelf hersteld, of door jou bij Postmark weggehaald) → weer in het team */
await query(`
update promoters set left_on = null, unsubscribed_at = null
where unsubscribed_at is not null and (email is null or lower(email) <> all($1))`, [afgemeld]);
return new Set(lijst.filter((s) => s.reden === "HardBounce").map((s) => s.email));
}
/*
* Wat er onder elke welkomstmail komt: waar het over gaat, in de woorden van de winkelvermelding
* (src/content/store.js). Niet in de bewerkbare tekst hierboven maar eronder geplakt, en dat is
* met opzet: wát Liefdesbrieven is hoort overal hetzelfde te zijn — winkel, website, deze post —
* terwijl de zinnen erbóven van jou zijn en per keer mogen verschillen. Verandert de vermelding,
* dan verandert dit mee, zonder dat iemand een mailtekst hoeft na te lopen.
*
* Iemand die het gaat navertellen heeft geen volledige beschrijving nodig maar de punten waarop
* dit van de rest verschilt; vandaar STORE_KERN en niet STORE_FULL.
*/
const WELKOM_STAART = `Waar het over gaat, voor als je het navertelt:
${STORE_TITLE} — ${STORE_SHORT.toLowerCase()}
${STORE_KERN.map((r) => `- ${r}`).join("\n\n")}
Meer staat op ${STORE_URL}
${STORE_AGE}`;
/*
* De welkomstmail: aan één persoon, uitgelokt door het toevoegen — dus de transactionele stream.
* De staart is met streepjes geschreven en niet met bolletjes, want dat is waar de opmaak op let: in
* de brief wordt het een opsomming, in de tekstversie blijft het een streepje en dat leest zoals het
* altijd al las.
*/
async function stuurWelkom(p) {
const { rows } = await query("select subject, body from welcome");
const { subject, body } = rows[0];
await sendMail({
from: FROM,
to: p.email,
subject,
text: metStaart(vulIn(body, p), WELKOM_STAART),
html: brief(vulIn(body, p, letterlijk), WELKOM_STAART),
stream: STREAM_EEN,
});
await query("update promoters set welcomed_at = now() where id = $1", [p.id]);
}
/* ---------- De lijst ---------- */
const LEDEN = `
select id, name, email, phone, note, left_on, welcomed_at, unsubscribed_at,
to_char(joined_on, 'DD-MM-YYYY') as sinds,
to_char(left_on, 'DD-MM-YYYY') as tot
from promoters
order by left_on nulls first, lower(name)
`;
async function lijstHtml({ melding = "", fout = "", nieuw = {}, mailing = {} } = {}) {
/*
* Eerst de afmeldingen ophalen, dan pas de lijst lezen — anders zie je iemand die net geklikt
* heeft nog een paginaweergave lang tussen de actieve leden staan.
*/
let kapot = new Set(), syncFout = "";
if (mailReady()) {
try {
kapot = await synchroniseerAfmeldingen();
} catch (err) {
syncFout = `Afmeldingen niet opgehaald bij Postmark: ${err.message}`;
}
}
const [{ rows }, { rows: sjabloon }, { rows: post }] = await Promise.all([
query(LEDEN),
query("select subject, body from welcome"),
query("select id, subject, aantal, mislukt, to_char(sent_at, 'DD-MM-YYYY HH24:MI') as wanneer from mailings order by sent_at desc limit 8"),
]);
const actief = rows.filter((p) => !p.left_on);
const gestopt = rows.filter((p) => p.left_on);
const teMailen = actief.filter((p) => p.email);
return beheerPagina(
"Promotieteam",
`<h1>Promotieteam</h1>
<p class="mute">De vrienden die mond-tot-mondreclame maken. Er is geen quotum en er wordt niets bijgehouden
behalve dit: wie meedoet, en hoe je diegene bereikt als het feest er is.</p>
${fout ? `<p class="melding fout">${esc(fout)}</p>` : ""}
${melding ? `<p class="melding">${esc(melding)}</p>` : ""}
${mailReady() ? "" : `<p class="melding fout">Er kan geen post uit: POSTMARK_SERVER_TOKEN ontbreekt in de omgeving.</p>`}
${syncFout ? `<p class="melding fout">${esc(syncFout)}</p>` : ""}
<h2>Iemand toevoegen</h2>
<form class="rij" method="post" action="${PROMOTIE_PATH}">
${veld("name", "Naam", nieuw.name ?? "", "required maxlength=80 autofocus")}
${veld("email", "E-mail", nieuw.email ?? "", "type=email maxlength=254")}
${veld("phone", "Telefoon", nieuw.phone ?? "", "maxlength=40")}
${veld("note", "Notitie", nieuw.note ?? "", "maxlength=200")}
<div class="knoppen"><button>Toevoegen</button>
<span class="mute mono sinds">met een e-mailadres gaat de welkomstmail er meteen uit</span></div>
</form>
<h2>Doet mee${actief.length ? ` (${actief.length})` : ""}</h2>
${actief.length ? actief.map((p) => lidRij(p, kapot)).join("") : `<p class="mute">Nog niemand.</p>`}
<h2>Mail het team</h2>
<p class="mute">Gaat als ${teMailen.length} losse ${teMailen.length === 1 ? "mail" : "mails"} de deur uit — ieder zijn
eigen, niemand ziet een ander adres. Je krijgt eerst te zien hoe hij eruitziet.</p>
<form class="rij" method="post" action="${PROMOTIE_PATH}/mailing">
<div class="breed">${veld("subject", "Onderwerp", mailing.subject ?? "", "required maxlength=120")}</div>
<div class="breed"><textarea name="body" required placeholder="Hoi {naam}, …" aria-label="Bericht">${esc(mailing.body ?? "")}</textarea></div>
<div class="knoppen"><button ${teMailen.length ? "" : "disabled"}>Voorbeeld</button>
<span class="mute mono sinds">{naam} wordt de naam van de ontvanger</span></div>
</form>
${OPMAAK_UITLEG}
<h2>De welkomstmail</h2>
<p class="mute">Gaat automatisch naar wie je toevoegt, als er een e-mailadres bij staat. Eén mail, geen
afmeldlink — dit is post aan één persoon.</p>
<form class="rij" method="post" action="${PROMOTIE_PATH}/welkomstmail">
<div class="breed">${veld("subject", "Onderwerp", sjabloon[0].subject, "required maxlength=120")}</div>
<div class="breed"><textarea name="body" required aria-label="Welkomstmail">${esc(sjabloon[0].body)}</textarea></div>
<div class="knoppen"><button class="stil">Tekst opslaan</button></div>
</form>
${OPMAAK_UITLEG}
<p class="mute">Hieronder komt dit er vast onder — wat de app is, in de woorden van de
winkelvermelding. Die tekst staat in de code (<span class="mono">src/content/store.js</span>), zodat
winkel, website en deze post niet uit elkaar gaan lopen.</p>
<div class="brief">${inhoud(WELKOM_STAART)}</div>
${post.length ? `<h2>Verstuurd</h2>
<table>
<tr><th>Wanneer</th><th>Onderwerp</th><th>Aan</th></tr>
${post.map((m) => `<tr><td class="mono">${esc(m.wanneer)}</td><td>${esc(m.subject)}</td><td>${m.aantal}${
m.mislukt ? `<br><span class="mute">mislukt: ${esc(m.mislukt)}</span>` : ""
}</td></tr>`).join("")}
</table>` : ""}
${gestopt.length ? `<h2>Gestopt (${gestopt.length})</h2>${gestopt.map((p) => lidRij(p, kapot)).join("")}` : ""}`
);
}
/* ---------- Invoer ---------- */
/* Lege velden worden null: "" is geen telefoonnummer, en de check in het schema rekent erop */
const leeg = (v) => {
const s = typeof v === "string" ? v.trim() : "";
return s === "" ? null : s;
};
const uitFormulier = async (c) => {
const body = await c.req.parseBody();
return {
actie: typeof body.actie === "string" ? body.actie : "opslaan",
name: leeg(body.name),
email: leeg(body.email)?.toLowerCase() ?? null,
phone: leeg(body.phone),
note: leeg(body.note) ?? "",
};
};
/* Wat er mis kan zijn, in gewone zinnen — de checks in het schema vangen de rest af */
const fouten = (v) => {
if (!v.name) return "Vul een naam in.";
if (v.name.length > 80) return "Die naam is te lang (maximaal 80 tekens).";
if (!v.email && !v.phone) return "Vul een e-mailadres of een telefoonnummer in — anders kun je niemand uitnodigen.";
if (v.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email)) return "Dat e-mailadres klopt niet.";
return null;
};
/* ---------- Routes: de leden ---------- */
/* Object.hasOwn, want `?m=constructor` haalt anders iets uit de prototypeketen op */
promotie.get("/", async (c) => {
const m = c.req.query("m") ?? "";
return c.html(await lijstHtml({ melding: Object.hasOwn(MELDINGEN, m) ? MELDINGEN[m] : "" }));
});
promotie.post("/", async (c) => {
const v = await uitFormulier(c);
const fout = fouten(v);
if (fout) return c.html(await lijstHtml({ fout, nieuw: v }), 400);
const { rows } = await query(
"insert into promoters (name, email, phone, note) values ($1, $2, $3, $4) returning id, name, email",
[v.name, v.email, v.phone, v.note]);
if (!rows[0].email) return c.redirect(`${PROMOTIE_PATH}?m=toegevoegd`, 303);
try {
await stuurWelkom(rows[0]);
} catch (err) {
/* Het lid staat er nu; alleen de post ging niet weg. Dat hoort te zien te zijn, met de knop ernaast. */
return c.html(await lijstHtml({
fout: `${v.name} staat in de lijst, maar de welkomstmail ging niet weg: ${err.message}. Met "Welkom sturen" probeer je het opnieuw.`,
}));
}
return c.redirect(`${PROMOTIE_PATH}?m=gemaild`, 303);
});
promotie.post("/:id{[0-9]+}", async (c) => {
const id = c.req.param("id");
const v = await uitFormulier(c);
if (v.actie === "gestopt" || v.actie === "terug") {
/*
* Stoppen en terugkomen raken alleen left_on, niet de velden uit het formulier: je klikt
* "Gestopt" soms met een half getypte wijziging in beeld, en die hoort dan niet stilletjes
* mee te gaan. Terugkomen wist de datum weer — daar is geen tweede rij voor nodig.
*/
const { rows } = await query("select name, email, unsubscribed_at from promoters where id = $1", [id]);
if (!rows.length) return c.html(await lijstHtml({ fout: "Die is er niet (meer)." }), 404);
const p = rows[0];
/*
* Vertrok iemand via de afmeldknop, dan staat hij ook op de lijst bij Postmark. Hem hier
* terugzetten zonder dat daar weg te halen levert een lid op waar geen post heen kan — en de
* eerstvolgende synchronisatie zou hem meteen weer als vertrokken zien. Lukt dat weghalen
* niet (een spamklacht mag niet ongedaan gemaakt worden), dan blijft hij staan waar hij
* staat, met de reden erbij. Dat is eerlijker dan een naam in de lijst die niets ontvangt.
*/
if (v.actie === "terug" && p.unsubscribed_at && p.email) {
try {
const [r] = await unsuppress(STREAM_GROEP, [p.email]);
if (r && !r.ok) return c.html(await lijstHtml({
fout: `${p.name} staat bij Postmark op de afmeldlijst en die is niet weg te halen: ${r.fout}. Zolang dat zo is komt er geen post aan, dus laat ik het op Gestopt staan.`,
}));
} catch (err) {
return c.html(await lijstHtml({ fout: `De afmelding bij Postmark kon niet weggehaald worden: ${err.message}` }));
}
}
await query(v.actie === "gestopt"
? "update promoters set left_on = current_date where id = $1"
: "update promoters set left_on = null, unsubscribed_at = null where id = $1", [id]);
return c.redirect(`${PROMOTIE_PATH}?m=${v.actie}`, 303);
}
if (v.actie === "welkom") {
const { rows } = await query("select id, name, email from promoters where id = $1", [id]);
if (!rows.length || !rows[0].email) return c.html(await lijstHtml({ fout: "Die is er niet (meer), of heeft geen e-mailadres." }), 404);
try {
await stuurWelkom(rows[0]);
} catch (err) {
return c.html(await lijstHtml({ fout: `De welkomstmail ging niet weg: ${err.message}` }));
}
return c.redirect(`${PROMOTIE_PATH}?m=welkom`, 303);
}
const fout = fouten(v);
if (fout) return c.html(await lijstHtml({ fout }), 400);
const { rowCount } = await query(
"update promoters set name = $2, email = $3, phone = $4, note = $5 where id = $1",
[id, v.name, v.email, v.phone, v.note]);
if (!rowCount) return c.html(await lijstHtml({ fout: "Die is er niet (meer)." }), 404);
return c.redirect(`${PROMOTIE_PATH}?m=opgeslagen`, 303);
});
/*
* Wissen in twee stappen. Zonder JavaScript is er geen "weet je het zeker?"-venster, en één
* misklik hoort geen naam te kosten. Wie er echt uit wil (of het niet wilde) is hiermee weg —
* voor iemand die alleen even stopt is "Gestopt" de betere knop.
*/
promotie.get("/:id{[0-9]+}/wissen", async (c) => {
const id = c.req.param("id");
const { rows } = await query("select name from promoters where id = $1", [id]);
if (!rows.length) return c.html(await lijstHtml({ fout: "Die is er niet (meer)." }), 404);
return c.html(beheerPagina("Wissen — Promotieteam", `
<h1>${esc(rows[0].name)} wissen?</h1>
<p>De gegevens verdwijnen dan helemaal. Wil je alleen dat diegene niet meer meedoet, ga dan terug
en gebruik <strong>Gestopt</strong> — dan blijft de naam staan.</p>
<form class="rij" method="post" action="${PROMOTIE_PATH}/${esc(id)}/wissen">
<div class="knoppen">
<button>Ja, wissen</button>
<a class="stil" href="${PROMOTIE_PATH}">Nee, terug</a>
</div>
</form>`));
});
promotie.post("/:id{[0-9]+}/wissen", async (c) => {
await query("delete from promoters where id = $1", [c.req.param("id")]);
return c.redirect(`${PROMOTIE_PATH}?m=gewist`, 303);
});
/* ---------- Routes: de post ---------- */
promotie.post("/welkomstmail", async (c) => {
const body = await c.req.parseBody();
const subject = leeg(body.subject), tekst = leeg(body.body);
if (!subject || !tekst) return c.html(await lijstHtml({ fout: "De welkomstmail heeft een onderwerp en een tekst nodig." }), 400);
await query("update welcome set subject = $1, body = $2", [subject, tekst]);
return c.redirect(`${PROMOTIE_PATH}?m=sjabloon`, 303);
});
/*
* Groepspost gaat over twee stappen: eerst zien wat je stuurt, dan pas versturen. Post is niet
* terug te halen, en de mail die je in het voorbeeld leest is letterlijk de mail die de eerste
* op de lijst krijgt — met zijn naam erin en met de afmeldlink eronder.
*/
promotie.post("/mailing", async (c) => {
const body = await c.req.parseBody();
const subject = leeg(body.subject), tekst = leeg(body.body);
if (!subject || !tekst) return c.html(await lijstHtml({ fout: "Een bericht heeft een onderwerp en een tekst nodig." }), 400);
const { rows } = await query(LEDEN);
const ontvangers = rows.filter((p) => !p.left_on && p.email);
if (!ontvangers.length) return c.html(await lijstHtml({ fout: "Er is niemand om te mailen: geen actief lid met een e-mailadres.", mailing: { subject, body: tekst } }), 400);
const eerste = groepsBericht(ontvangers[0], subject, tekst);
/* Letterlijk het vel dat verstuurd wordt, alleen zonder de afmeld-plaatshouder die Postmark invult */
const voorbeeld = vel(vulIn(tekst, ontvangers[0], letterlijk), AFMELD_BRIEF).replaceAll(AFMELD, "#");
return c.html(beheerPagina("Voorbeeld — Promotieteam", `
<h1>Zo gaat hij eruit</h1>
<p class="mute">Naar ${ontvangers.length} ${ontvangers.length === 1 ? "persoon" : "mensen"}, ieder een eigen mail:
${esc(ontvangers.map((p) => p.name).join(", "))}. Achter "Meld je gerust af" zet Postmark bij het
versturen de persoonlijke afmeldlink van die ontvanger.</p>
<div class="kop mono">
Van: ${esc(FROM)}<br>
Aan: ${esc(eerste.to)}<br>
Onderwerp: ${esc(subject)}
</div>
<div class="post">${voorbeeld}</div>
<p class="mute mono">Zo ziet hij eruit in een lichte mailclient; in een donkere draait het vel mee.
Wie geen opmaak leest krijgt dezelfde woorden zonder, met de afmeldlink voluit.</p>
<form class="rij" method="post" action="${PROMOTIE_PATH}/mailing/versturen" style="margin-top:16px">
<input type="hidden" name="subject" value="${esc(subject)}">
<textarea name="body" hidden>${esc(tekst)}</textarea>
<div class="knoppen">
<button>Versturen naar ${ontvangers.length}</button>
<a class="stil" href="${PROMOTIE_PATH}">Nee, terug</a>
</div>
</form>`));
});
promotie.post("/mailing/versturen", async (c) => {
const body = await c.req.parseBody();
const subject = leeg(body.subject), tekst = leeg(body.body);
if (!subject || !tekst) return c.html(await lijstHtml({ fout: "Een bericht heeft een onderwerp en een tekst nodig." }), 400);
const { rows } = await query(LEDEN);
const ontvangers = rows.filter((p) => !p.left_on && p.email);
let uitslag;
try {
uitslag = await sendBatch(ontvangers.map((p) => groepsBericht(p, subject, tekst)));
} catch (err) {
return c.html(await lijstHtml({ fout: `Er ging niets weg: ${err.message}`, mailing: { subject, body: tekst } }));
}
const gelukt = uitslag.filter((r) => r.ok);
const mislukt = uitslag.filter((r) => !r.ok);
await query("insert into mailings (subject, body, aantal, mislukt) values ($1, $2, $3, $4)",
[subject, tekst, gelukt.length, mislukt.map((r) => `${r.to} (${r.fout})`).join("; ")]);
return c.html(await lijstHtml({
melding: mislukt.length ? "" : `Verstuurd naar ${gelukt.length} ${gelukt.length === 1 ? "persoon" : "mensen"}.`,
fout: mislukt.length ? `Verstuurd naar ${gelukt.length}, mislukt bij ${mislukt.length}: ${mislukt.map((r) => `${r.to} — ${r.fout}`).join("; ")}` : "",
}));
});
/*
* Voor `npm run doctor`. Drie dingen kunnen hier na een uitrol stil mislopen: het wachtwoord
* ontbreekt in de omgeving (dan staat de pagina dicht met 503 — vervelend, maar niet gevaarlijk),
* het staat er wél terwijl de database niet bestaat (dan loopt de pagina op een 500), of de post
* kan er niet uit doordat het Postmark-token ontbreekt. Het verschil bepaalt of de zelftest
* hierop faalt of alleen waarschuwt.
*/
export async function promotieSelfTest() {
const slot = Boolean(process.env.PROMOTIE_PASSWORD);
try {
const { rows } = await query(
"select count(*) filter (where left_on is null) as actief, count(*) as totaal from promoters");
const { actief, totaal } = rows[0];
if (!slot) return { ok: false, hard: false, uitleg: `de lijst staat er (${totaal}), maar PROMOTIE_PASSWORD ontbreekt — /promotieteam antwoordt met 503` };
if (!mailReady()) return { ok: false, hard: false, uitleg: `${actief} actieve leden, maar POSTMARK_SERVER_TOKEN ontbreekt — er kan geen post uit` };
return { ok: true, uitleg: `${actief} actieve leden (${totaal} in totaal), op slot, post gaat uit via ${STREAM_GROEP}` };
} catch (err) {
return slot
? { ok: false, hard: true, uitleg: `PROMOTIE_PASSWORD staat er, maar de database is onbereikbaar: ${err.message} — draai npm run db:promotie` }
: { ok: false, hard: false, uitleg: `niet in gebruik op deze installatie (geen wachtwoord, geen database)` };
}
}
/* De zelftest draait buiten de server; daarna hoort de verbinding weer dicht */
export const promotiePoolEnd = () => pool.end();
export default promotie;