/* * Post versturen via Postmark. Eén klein laagje boven hun API — geen bibliotheek, want het is * twee POST-verzoeken en `fetch` zit in Node. * * Waarom Postmark en niet de mailserver zelf: de box (bramluiken.nl) doet de inkomende post en * relayt zijn eigen uitgaande post ook al hierheen, terwijl deze app op een ándere machine draait. * Via Postmark is de handtekening bovendien uitgelijnd — de DKIM-sleutel staat op * liefdesbrieven.bramluiken.org zelf (sender-domain geverifieerd, return-path erbij), zodat DMARC * klopt en het BIMI-logo naast de afzender kan verschijnen. * * Streams: `outbound` is transactioneel (post aan één persoon, door een handeling uitgelokt), * broadcast-streams zijn voor post aan een groep. Postmark houdt ze uit elkaar en rekent * afmeldingen per stream bij; ze door elkaar gebruiken is precies wat je niet moet doen. * * Nodig in de omgeving: POSTMARK_SERVER_TOKEN. Zonder dat token wordt er niets verstuurd en * zegt elke aanroep dat ook, in plaats van stil te mislukken. */ const API = "https://api.postmarkapp.com"; export const mailToken = () => process.env.POSTMARK_SERVER_TOKEN ?? ""; export const mailReady = () => Boolean(mailToken()); /* * Eén bericht → wat Postmark verwacht. De tekstversie is de hoofdzaak: dit is post van een mens, * geen mailing. Een `html` erbij maakt er een bericht met twee versies van (Postmark stuurt beide * mee); de mailclient kiest. Dat is er voor één ding: een link achter woorden zetten in plaats van * een kale URL in de tekst. Wie geen HTML leest, krijgt exact dezelfde woorden. */ const bericht = ({ from, to, subject, text, html, replyTo, stream, headers }) => ({ From: from, To: to, Subject: subject, TextBody: text, ...(html ? { HtmlBody: html } : {}), ...(replyTo ? { ReplyTo: replyTo } : {}), MessageStream: stream, ...(headers?.length ? { Headers: headers.map(([Name, Value]) => ({ Name, Value })) } : {}), }); async function postmarkGet(pad) { const res = await fetch(`${API}${pad}`, { headers: { "X-Postmark-Server-Token": mailToken(), Accept: "application/json" }, }); const uit = await res.json().catch(() => ({})); if (!res.ok || uit.ErrorCode) throw new Error(uit.Message ?? `Postmark antwoordde met ${res.status}`); return uit; } async function postmark(pad, body) { const res = await fetch(`${API}${pad}`, { method: "POST", headers: { "X-Postmark-Server-Token": mailToken(), "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify(body), }); const uit = await res.json().catch(() => ({})); if (!res.ok && !Array.isArray(uit)) throw new Error(uit.Message ?? `Postmark antwoordde met ${res.status}`); return uit; } /* * De adressen waar deze stream niets meer heen stuurt, met de reden. Dit is de terugweg van de * afmeldknop: bij een broadcast-stream handelt Postmark het afmelden zelf af (hun link, hun * headers — dat is bij hen niet uit te zetten zonder toestemming voor "custom unsubscribe * handling"), en dit is hoe je te weten komt wie er geklikt heeft. * * ManualSuppression — de ontvanger meldde zich af (Origin 'Recipient') of jij deed het voor hem * SpamComplaint — als spam gemarkeerd; hier gaat sowieso niets meer heen * HardBounce — het adres bestaat niet */ export async function streamSuppressions(stream) { if (!mailReady()) throw new Error("POSTMARK_SERVER_TOKEN ontbreekt in de omgeving"); const uit = await postmarkGet(`/message-streams/${encodeURIComponent(stream)}/suppressions/dump`); return (uit.Suppressions ?? []).map((s) => ({ email: (s.EmailAddress ?? "").toLowerCase(), reden: s.SuppressionReason, door: s.Origin, })); } /* * Adressen weer van die lijst af halen. Nodig zodra je iemand hier terugzet die zich had * afgemeld: bleef de onderdrukking staan, dan gaat er nog steeds geen post heen en zou de * eerstvolgende synchronisatie hem meteen weer als vertrokken zien. Geeft per adres of het * gelukt is — een spamklacht mag je niet zomaar ongedaan maken, en dat zegt Postmark dan ook. */ export async function unsuppress(stream, emails) { if (!mailReady()) throw new Error("POSTMARK_SERVER_TOKEN ontbreekt in de omgeving"); const uit = await postmark(`/message-streams/${encodeURIComponent(stream)}/suppressions/delete`, { Suppressions: emails.map((EmailAddress) => ({ EmailAddress })), }); return (uit.Suppressions ?? []).map((s) => ({ email: s.EmailAddress, ok: s.Status === "Deleted", fout: s.Status === "Deleted" ? "" : (s.Message ?? s.Status ?? "onbekende reden"), })); } /* Eén mail. Geeft niets terug bij succes en gooit met de reden van Postmark als het misgaat. */ export async function sendMail(msg) { if (!mailReady()) throw new Error("POSTMARK_SERVER_TOKEN ontbreekt in de omgeving"); const uit = await postmark("/email", bericht(msg)); if (uit.ErrorCode) throw new Error(uit.Message); } /* * Een reeks mails in één verzoek (Postmark: maximaal 500). Elk bericht is een eigen mail aan één * persoon — dus geen bcc-blok waarin iedereen elkaars adres ziet, en met een eigen aanhef. * Geeft per bericht terug of het lukte, in dezelfde volgorde: { ok, to, fout }. */ export async function sendBatch(msgs) { if (!mailReady()) throw new Error("POSTMARK_SERVER_TOKEN ontbreekt in de omgeving"); if (!msgs.length) return []; const uit = await postmark("/email/batch", msgs.map(bericht)); return msgs.map((m, i) => { const r = Array.isArray(uit) ? uit[i] : null; return { to: m.to, ok: Boolean(r) && !r.ErrorCode, fout: r?.ErrorCode ? r.Message : r ? "" : "geen antwoord van Postmark" }; }); }