sdxc

Type to search, or start from one of these:

[ Feeds & publishing ]

@sdxc/newsletter

Vendor-neutral newsletter subscriber lists with Buttondown and Kit providers

npm add @sdxc/newsletter
pnpm add @sdxc/newsletter
yarn add @sdxc/newsletter
bun add @sdxc/newsletter
Depends on
remix
Used by
books

Vendor-neutral newsletter subscriber lists, with Buttondown and Kit providers, a memory provider for tests and a verified webhook endpoint.

Installation

npm add @sdxc/newsletter

Addresses arrive parsed by @sdxc/email-address, a visitor's IP as an IP from @sdxc/ip or the Result of IP.parse, and every answer is a Result from @sdxc/result.

Usage

Build one provider at module scope; construction reaches no network, so an empty API key fails the call that needed it, not the boot.

import type { Newsletter } from "@sdxc/newsletter";

import { ButtondownNewsletter } from "@sdxc/newsletter/buttondown";

export let newsletter: Newsletter = new ButtondownNewsletter({
	apiKey: process.env.BUTTONDOWN_API_KEY ?? "",
	confirmation: "double",
});

Subscribe an address. One already on the list, in any status, is a success with created: false:

import { parseEmailAddress } from "@sdxc/email-address";
import { IP } from "@sdxc/ip";
import { isFailure } from "@sdxc/result";

let email = parseEmailAddress(form.get("email"));
if (isFailure(email)) return invalid();

let outcome = await newsletter.subscribers.subscribe({
	email: email.data,
	attribution: { source: "twitter", campaign: "launch" },
	ip: IP.parse(request.headers.get("cf-connecting-ip") ?? ""), // a failed parse records no IP
});

if (isFailure(outcome)) {
	if (outcome.error.code === "invalid_address") return invalid();
	if (outcome.error.code === "suppressed") return blocked();
	return unavailable();
}

outcome.data.subscriber.status; // "pending" until the reader confirms

Change a known reader. Tags are added and removed by name; a null metadata value removes the key:

let updated = await newsletter.subscribers.update(
	{ email: email.data },
	{ tags: { add: ["buyer"] }, metadata: { purchase: "pro", trial: null } },
);

if (isFailure(updated) && updated.error.code === "not_found") {
	// the buyer never subscribed
}

Kit decides double opt-in by the form a reader is added through, so its provider names that form:

import { KitNewsletter } from "@sdxc/newsletter/kit";

let kit = new KitNewsletter({
	apiKey: process.env.KIT_API_KEY ?? "",
	webhookSecret: process.env.KIT_WEBHOOK_SECRET,
	form: { id: "55", confirmation: "double" },
});

API

Newsletter

The contract every provider implements: connection (the configured credential set's name), subscribers, webhooks, and native, the underlying client for endpoints the contract leaves out.

subscribers.subscribe(input)

Ensures an address is on the list. A new reader answers created: true and status: "pending" when a confirmation email went out, "active" otherwise; an address the platform already holds answers created: false with its record untouched, so tags, metadata and attribution apply only to a reader the call created. Repeating it after a timeout is safe.

subscribers.find(ref), subscribers.tags(ref)

Read one reader, or its tag names, by { id } or { email }. A reader the list does not hold is not_found. Tags are a separate read because some platforms serve them from a second endpoint.

subscribers.list(query?)

One page of readers, filtered by status or tag, DEFAULT_PAGE_SIZE (100) at a time. Pass the page's cursor back for the next page; only cursor === null ends a walk.

subscribers.update(ref, input), subscribers.unsubscribe(ref)

Change a known reader, answering the stored result. Unsubscribing keeps the record and is idempotent; the contract offers no resubscribe, since an unsubscribe is revoked consent.

webhooks.verify(request, rawBody), webhooks.events(request, rawBody)

Whether the configured secret proves a delivery, and the events an authentic delivery carries. An unset secret proves nothing, and neither call reaches the network.

NewsletterError

The error in every failed Result: a normalized code, the platform's providerCode, connection, retryable (only rate_limited) and retryAfter in seconds. invalid_address and suppressed are the refusals a visitor can act on; unknown is a timeout or 5xx after which the write may have landed.

NewsletterWebhook

new NewsletterWebhook(provider, handlers, { store?, ttl? }), whose handler mounts as a route action. It answers 401 to an unproven delivery, acknowledges an authentic one it cannot parse, skips an event id the ReplayStore from @sdxc/webhooks has seen, and answers 503 when a handler throws so the platform redelivers. Handlers are keyed by event type, so a misspelled key is a type error.

@sdxc/newsletter/middleware

newsletter({ provider }) publishes the provider, or a per-request factory's answer, as context.newsletter on a Remix router.

@sdxc/newsletter/buttondown

ButtondownNewsletter({ apiKey, webhookSecret?, confirmation?, connection?, newsletterId? }). The API version is fixed by the package. Buttondown signs deliveries without a timestamp, so an endpoint for it should configure a replay store.

@sdxc/newsletter/kit

KitNewsletter({ apiKey, webhookSecret?, form?, connection? }). form.confirmation must match the form's own double opt-in setting. Metadata keys must already exist as Kit custom fields, and a new reader costs up to four requests against Kit's rate limit.

@sdxc/newsletter/memory

MemoryNewsletter({ confirmation?, connection?, webhookSecret?, faults? }) implements the whole contract in memory and adds seed(records), confirm(email), fail(target, code?), heal(target?), attribution(email), ip(email) and webhooks.emit(event), which signs a Standard Webhooks delivery.

@sdxc/newsletter/conformance

conformance(options) registers Vitest tests holding a provider to the contract's rules: one subscribe per address, lookups by id and email, tags, metadata merging, idempotent unsubscribe, a full cursor walk and fail-closed webhooks.

Pattern: Testing a form against the memory provider

import { parseEmailAddress } from "@sdxc/email-address";
import newsletter from "@sdxc/newsletter/middleware";
import { MemoryNewsletter } from "@sdxc/newsletter/memory";
import { unwrap } from "@sdxc/result";
import { createRouter } from "remix/router";
import { expect, test } from "vitest";

test("a blocked address sees the blocked copy", async () => {
	let provider = new MemoryNewsletter();
	provider.fail("subscribers.subscribe", "suppressed");

	let router = createRouter({ middleware: [newsletter({ provider })] });
	router.post("/subscribe", subscribeHandler);

	let response = await router.fetch(
		new Request("https://example.com/subscribe", {
			method: "POST",
			body: new URLSearchParams({ email: "reader@example.com" }),
		}),
	);

	expect(await response.text()).toContain("can't be subscribed");
	expect(provider.attribution("reader@example.com")).toBeNull();
});

Pattern: Crediting the campaign a visitor arrived from

@sdxc/attribution remembers a visitor's touches across pages. Its toCampaign flattens one into the fields attribution takes, and toMetadata keeps the first and last touches as metadata keys:

import { toCampaign, toMetadata } from "@sdxc/attribution";

let outcome = await ctx.newsletter.subscribers.subscribe({
	email: payload.email,
	attribution: toCampaign(ctx.attribution.last ?? ctx.attribution.first, ctx.url),
	metadata: toMetadata(ctx.attribution),
	ip: ctx.ip,
});

On Kit, every toMetadata key must already exist as a custom field.

Pattern: Reacting to confirmations

import { NewsletterWebhook } from "@sdxc/newsletter";
import { unwrap } from "@sdxc/result";
import { KVReplayStore } from "@sdxc/webhooks";

export default new NewsletterWebhook(
	newsletter,
	{
		async "subscriber.confirmed"(event) {
			/** A throw answers 503, so the platform redelivers once the read succeeds. */
			let reader =
				event.subscriber ?? unwrap(await newsletter.subscribers.find({ id: event.subscriberId }));
			await sendWelcome(reader);
		},
	},
	{ store: new KVReplayStore(env.WEBHOOKS, { prefix: "newsletter:" }) },
);

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.