npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@capitalthought/postmark

v0.1.0

Published

Shared Postmark transactional email client + template registry for Capital Thought projects.

Downloads

760

Readme

@capitalthought/postmark

Shared Postmark transactional email client + template registry for Capital Thought projects.

Why

Postmark is the standing vendor for transactional email across the portfolio (per global CLAUDE.md vendor preference, 2026-04-28 — see ~/icloud/Claude/CLAUDE.md § "Vendor preferences"). Today the same sendEmail shape is re-implemented in five different places, each one slightly different, none sharing fixes:

| Repo | Has | Notes | |---|---|---| | multipov | ✅ canonical sendEmail + 4 named senders + From-mailbox pinning | Lift target — most complete impl | | mikey | partial — Postmark not yet wired, scattered Resend remnants | Migrates in Phase 2 | | pitch2 | inline fetch('https://api.postmarkapp.com/email') in 2 sites | Migrates in Phase 3 | | personalize | inline + suppression-list reads | Migrates in Phase 3 | | doorman | partial — magic-link sender only | Migrates in Phase 2 |

This package consolidates the canonical patterns and exposes them via a typed client + a registered template registry. Every consumer drops into one shared client + a shared template renderer instead of inlining HTML strings five different ways.

Phase 1 — what's in this commit

Stubs + contracts + a working in-memory client + ESLint rule. No consumer changes yet. Phase 1 commits zero runtime risk.

| Module | Status | |---|---| | client.tscreatePostmarkClient() (sendTransactional, sendBatch, bounces, suppress) | ✅ shipped | | templates.tsregisterTemplate() + slug → typed renderer | ✅ shipped | | types.tsSendArgs, SendResult, BounceResult, PostmarkTemplate, … | ✅ shipped | | no-op mode when serverToken is undefined | ✅ shipped | | ESLint rule no-inline-postmark-html + plugin entry | ✅ shipped |

Postmark's API is HTTP-only and identical across runtimes (Workers, Node, Vercel) — no per-runtime adapters, just fetch.

Migration phases

  1. Phase 1 (this scaffold). Create the package + client + template registry + ESLint rule + tests. No consumer changes.
  2. Phase 2. Migrate multipov (lift target — already canonical) and mikey to consume the package. Each drops its own sendEmail + replaces inline HTML strings with registered templates.
  3. Phase 3. Migrate pitch2, personalize, doorman. By end of phase, every Capital Thought repo that sends email uses this package and only this package.
  4. Phase 4. Add a Postmark Webhooks helper (signed inbound bounce / spam complaint webhook) so each consumer can plug a single handler into its router. Add a tiny CLI for inspecting the suppression list.

Each consumer migrates on its own PR, on its own timeline.

Design decisions (2026-05-02)

Templates as code, not Postmark Server Templates

Postmark itself supports Mustachio-rendered templates managed in the Postmark dashboard (POST /email/withTemplate). We deliberately don't use that path. Two reasons:

  1. Reviewable diffs. A template change should land in a PR, not in a Postmark dashboard the rest of the team can't see.
  2. Live testability. Code-rendered templates are pure functions — subject(params) / htmlBody(params) / textBody(params). They unit-test as plain functions, no HTTP roundtrip needed.

The downside is that Postmark's per-template open/click stats can't tag our sends by template slug. We can still tag them via the Tag field — which we do.

Slug registry, not per-client

Templates are registered into a process-wide registry (registerTemplate("welcome", { ... })) that any client can resolve. This mirrors how every consumer already structures its email module: a single templates.ts imported once. Tests get isolation via clearTemplates() between cases.

If we ever need per-client overrides (different copy for different brand domains in one process), the contract supports it — just create one client per brand and have the consumer dispatch by brand. No package change needed.

No-op mode when serverToken is undefined

createPostmarkClient({ serverToken: undefined }) returns a working client that logs every send to console.log and returns a synthetic SendResult with messageId: noop-.... Local dev, CI, and preview deploys often run without Postmark wired — opting in to send is cheaper than opting out. Mirrors the no-op-when-DSN-unset pattern from @capitalthought/observability.

ESLint rule, not just convention

no-inline-postmark-html flags any client.sendTransactional({ htmlBody: "..." }) call where the object literal contains htmlBody: or textBody: and no template:. Configurable per-file allowlist for the few legitimately-dynamic sites. Lifted from the same RuleTester-based shape as @capitalthought/anthropic's no-llm-identity-param.

private: true for now, publishConfig.access: "public" set in advance

The package ships as private: true during Phase 1 — no npm publish until consumer migration is ready. publishConfig.access is pre-set to "public" so the eventual publish doesn't trip the same 404-on-metadata trap that @capitalthought/anthropic hit on first publish (the @capitalthought scope has no paid private-packages subscription, so npm needs the --access public signal up front). Future maintainer: when you flip private off and run npm publish, the access level is already correct.

Usage (once Phase 2 lands)

// Module load — register every template the app uses.
import { registerTemplate } from "@capitalthought/postmark";

interface ReviewParams { fileName: string; jobId: string; pageCount: number }

registerTemplate<ReviewParams>("review-complete", {
  subject: (p) => `Your review of ${p.fileName} is ready`,
  htmlBody: (p) => `<!DOCTYPE html><html>...${p.fileName}...</html>`,
  textBody: (p) => `Your review of ${p.fileName} is ready: https://multipov.ai/review/${p.jobId}`,
  from: '"MultiPOV Help" <[email protected]>',
});

// Per-request — pass the slug + params, the client renders.
import { createPostmarkClient } from "@capitalthought/postmark";

const postmark = createPostmarkClient({
  serverToken: env.POSTMARK_SERVER_TOKEN,        // optional → no-op when undefined
  defaultFrom: '"MultiPOV Help" <[email protected]>',
  defaultMessageStream: "outbound",
});

await postmark.sendTransactional<ReviewParams>({
  template: "review-complete",
  params: { fileName: "deck.pdf", jobId: "j-123", pageCount: 14 },
  to: "[email protected]",
  tag: "review-complete",
  metadata: { jobId: "j-123", kind: "review" },
});

// Bulk + bounces + suppression
const results = await postmark.sendBatch([
  { template: "welcome", params: { name: "Alice" }, to: "[email protected]" },
  { template: "welcome", params: { name: "Bob" },   to: "[email protected]" },
]);

const bounces = await postmark.bounces({ type: "HardBounce", count: 50 });

await postmark.suppress("[email protected]", { reason: "HardBounce" });

Wiring the ESLint rule

Add the plugin to your repo's flat config:

import postmarkPlugin from "@capitalthought/postmark/eslint-rules";

export default [
  {
    plugins: { postmark: postmarkPlugin },
    rules: { "postmark/no-inline-postmark-html": "error" },
  },
];

To allow inline HTML in a few one-off paths (e.g. dynamic fact-check reports rendered from a thousand findings):

"postmark/no-inline-postmark-html": ["error", {
  allowFiles: ["src/email/factcheck-report.ts"],
}],

Scripts

npm run check    # tsc --noEmit
npm run build    # tsc → dist/
npm run test     # vitest run

Related

  • ~/icloud/Claude/CLAUDE.md § "Vendor preferences" — Postmark is the standing transactional-email vendor
  • multipov/src/email.ts — canonical sendEmail + From-mailbox pinning + 4 named senders (lift target for Phase 2)
  • @capitalthought/anthropic — sibling package, same shape (model registry + cost cap + ESLint rule)
  • @capitalthought/observability — sibling package, same no-op-when-secret-unset pattern