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.
/*
* 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" };
});
}