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 "; /* 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 = `

Opmaak mag: **vet**, *cursief*, [tekst](https://…), en een opsomming met een - aan het begin van de regel. Wie geen opmaak leest, krijgt precies de tekens die je intypte.

`; const veld = (naam, label, waarde, 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()) => `
${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")}
${p.left_on ? `` : ``} ${!p.left_on && p.email && !p.welcomed_at ? `` : ""} Wissen ${p.email && kapot.has(p.email.toLowerCase()) ? `adres bestaat niet · ` : ""}${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") : ""}`}
`; /* ---------- 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", `

Promotieteam

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.

${fout ? `

${esc(fout)}

` : ""} ${melding ? `

${esc(melding)}

` : ""} ${mailReady() ? "" : `

Er kan geen post uit: POSTMARK_SERVER_TOKEN ontbreekt in de omgeving.

`} ${syncFout ? `

${esc(syncFout)}

` : ""}

Iemand toevoegen

${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")}
met een e-mailadres gaat de welkomstmail er meteen uit

Doet mee${actief.length ? ` (${actief.length})` : ""}

${actief.length ? actief.map((p) => lidRij(p, kapot)).join("") : `

Nog niemand.

`}

Mail het team

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.

${veld("subject", "Onderwerp", mailing.subject ?? "", "required maxlength=120")}
{naam} wordt de naam van de ontvanger
${OPMAAK_UITLEG}

De welkomstmail

Gaat automatisch naar wie je toevoegt, als er een e-mailadres bij staat. Eén mail, geen afmeldlink — dit is post aan één persoon.

${veld("subject", "Onderwerp", sjabloon[0].subject, "required maxlength=120")}
${OPMAAK_UITLEG}

Hieronder komt dit er vast onder — wat de app is, in de woorden van de winkelvermelding. Die tekst staat in de code (src/content/store.js), zodat winkel, website en deze post niet uit elkaar gaan lopen.

${inhoud(WELKOM_STAART)}
${post.length ? `

Verstuurd

${post.map((m) => ``).join("")}
WanneerOnderwerpAan
${esc(m.wanneer)}${esc(m.subject)}${m.aantal}${ m.mislukt ? `
mislukt: ${esc(m.mislukt)}` : "" }
` : ""} ${gestopt.length ? `

Gestopt (${gestopt.length})

${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", `

${esc(rows[0].name)} wissen?

De gegevens verdwijnen dan helemaal. Wil je alleen dat diegene niet meer meedoet, ga dan terug en gebruik Gestopt — dan blijft de naam staan.

Nee, terug
`)); }); 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", `

Zo gaat hij eruit

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.

Van: ${esc(FROM)}
Aan: ${esc(eerste.to)}
Onderwerp: ${esc(subject)}
${voorbeeld}

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.

Nee, terug
`)); }); 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;