Een dating-app op basis van brieven, niet van gezichten!

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

5.6 kB Ruw bestand
/*
 * 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" };
  });
}