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

davepi-plugin-postmark

v0.1.0

Published

Transactional email for dAvePi via Postmark. Exposes sendEmail / sendTemplate for use in schema lifecycle hooks, and can subscribe to the in-process record event bus so a configured event pattern auto-sends a templated message.

Readme

davepi-plugin-postmark

Transactional email for dAvePi via Postmark. Exposes sendEmail and sendTemplate so a schema lifecycle hook can fire a welcome / receipt / password-reset email inline, and optionally subscribes to the in-process record event bus to auto-send a template for every CRUD event whose type matches a configured pattern.

Install

npm install davepi-plugin-postmark

Add it to your project's package.json under davepi.plugins:

{
  "davepi": {
    "plugins": ["davepi-plugin-postmark"]
  }
}

Configure

All config is env-driven:

| Variable | Required | Default | Description | |----------|----------|---------|-------------| | POSTMARK_SERVER_TOKEN | yes (otherwise dormant) | — | The server token from your Postmark server. Used in the X-Postmark-Server-Token header. | | POSTMARK_FROM | strongly recommended | — | Default From address. Can be [email protected] or "Acme <[email protected]>". Per-call from overrides this. | | POSTMARK_REPLY_TO | no | — | Default Reply-To address. Per-call replyTo overrides this. | | POSTMARK_MESSAGE_STREAM | no | (Postmark uses outbound) | Default Postmark message stream. Set to your transactional stream's ID. | | POSTMARK_APP_NAME | no | dAvePi's APP_NAME env var, then "dAvePi" | Surfaced to rule build() callbacks as { appName } and useful for logging. |

A missing POSTMARK_SERVER_TOKEN is intentional: the plugin logs a warning and stays dormant. sendEmail / sendTemplate will throw if called in that state. This lets you ship the plugin in a project that hasn't wired Postmark yet without crashing boot.

A malformed POSTMARK_FROM (e.g. Acme Co with no @) is rejected at boot — the plugin stays dormant and logs an error. Better to catch the typo at startup than during the first send.

Inbound webhook (optional)

If you want Postmark's inbound parsing — replies threading back to a ticket record, contact-form intake, mail-to-record bridges — set both:

| Variable | Description | |----------|-------------| | POSTMARK_INBOUND_PATH | The Express path to mount the inbound POST handler at, e.g. /webhooks/postmark/inbound. | | POSTMARK_INBOUND_AUTH | A user:pass pair the plugin will require as HTTP Basic on every request. Configure the same pair in Postmark's dashboard as https://user:pass@yourdomain/<path>. |

Setting only one of the two logs an error and leaves the route unmounted — an unauthenticated public POST endpoint that fans out to your app's handlers would be a foot-cannon, so this is intentional.

See Reacting to inbound mail below for the consumer API.

Calling Postmark from a hook

The primary API. Most apps want welcome / receipt / verification email to be a side-effect of a specific record mutation, with all the record's fields available to the template.

// schema/versions/v1/user.js
const postmark = require('davepi-plugin-postmark');

module.exports = {
  path: 'user',
  collection: 'user',
  fields: [/* ... */],
  hooks: {
    afterCreate: async ({ record, req }) => {
      try {
        await postmark.sendTemplate({
          to: record.email,
          templateAlias: 'welcome',
          templateModel: {
            name: record.name,
            verifyUrl: `https://app.example.com/verify/${record.verifyToken}`,
          },
        });
      } catch (err) {
        (req?.log || console).error({ err }, 'afterCreate welcome email failed');
      }
    },
  },
};

The try/catch is the convention for after* hooks — they're best-effort, and dAvePi swallows throws to keep responses fast. Wrap locally so a Postmark outage doesn't lose its diagnostic trail. See Hooks › Calling a plugin from a hook.

API

const postmark = require('davepi-plugin-postmark');

// Plain email (HTML and/or text body).
await postmark.sendEmail({
  to: '[email protected]',          // or ['[email protected]', '[email protected]']
  subject: 'Welcome',
  htmlBody: '<p>Hi</p>',
  textBody: 'Hi',                  // at least one of htmlBody / textBody is required
  from: '[email protected]',        // optional; defaults to POSTMARK_FROM
  cc:  '[email protected]',
  bcc: ['[email protected]'],
  replyTo: '[email protected]',
  tag: 'welcome',
  metadata: { plan: 'pro' },
  headers: [{ Name: 'X-Source', Value: 'davepi' }],
  attachments: [{ Name: 'invite.ics', Content: '...base64...', ContentType: 'text/calendar' }],
  trackOpens: true,
  trackLinks: 'HtmlOnly',          // 'None' | 'HtmlAndText' | 'HtmlOnly' | 'TextOnly'
  messageStream: 'outbound',
});

// Templated email (created in the Postmark UI). `sendEmailWithTemplate`
// is the canonical name (matches Postmark's /email/withTemplate
// endpoint); `sendTemplate` is a short alias — both are the same
// function.
await postmark.sendEmailWithTemplate({
  to: '[email protected]',
  templateAlias: 'welcome',        // or templateId: 12345
  templateModel: { name: 'Dave', verifyUrl: '...' },
  inlineCss: true,                 // optional
  // ... same optional overrides as sendEmail
});

// Batch sends — up to 500 messages per call (Postmark limit).
await postmark.sendBatch([
  { to: '[email protected]', subject: 'one', textBody: '1' },
  { to: '[email protected]', subject: 'two', textBody: '2' },
]);
await postmark.sendBatchTemplates([
  { to: '[email protected]', templateAlias: 'welcome', templateModel: { name: 'A' } },
  { to: '[email protected]', templateAlias: 'welcome', templateModel: { name: 'B' } },
]);

All four return Postmark's parsed JSON response. sendEmail / sendEmailWithTemplate throw on transport error, non-2xx response, or a 200 response with ErrorCode !== 0. The batch endpoints return Postmark's array of per-message results unchanged — Postmark allows partial success, so the caller decides how to handle failed entries.

Reacting to inbound mail

When POSTMARK_INBOUND_PATH and POSTMARK_INBOUND_AUTH are set (see Configure › Inbound webhook above), the plugin mounts the configured route on the dAvePi Express app. The route:

  1. Requires HTTP Basic with the configured user:pass (constant-time compare).
  2. Validates that the body looks like a Postmark InboundMessage (MessageID + From present).
  3. ACKs Postmark with 200 { ok: true, MessageID } immediately.
  4. Fans out to every registered handler via setImmediate. Handler errors are logged via the framework's pino instance and never trigger a Postmark retry — Postmark retries are for transport, not application failures, so a slow/broken subscriber must not create a thundering herd.

Register a handler:

const postmark = require('davepi-plugin-postmark');

// Returns an unsubscribe function.
const off = postmark.onInboundEmail(async (msg) => {
  // msg is Postmark's full InboundMessage:
  //   { MessageID, From, FromName, To, Cc, Bcc, Subject,
  //     TextBody, HtmlBody, StrippedTextReply, Attachments,
  //     Headers, MessageStream, Date, ... }

  const ticketId = parseTicketTag(msg.To);     // e.g. [email protected]
  if (!ticketId) return;

  await TicketReply.create({
    ticketId,
    from: msg.From,
    text: msg.StrippedTextReply || msg.TextBody,
    receivedAt: new Date(msg.Date || Date.now()),
    postmarkMessageId: msg.MessageID,
  });
});

Idiomatic place for the subscription is a plugin of your own — it gets the live app + bus during setup(), and any multi-tenant routing logic (parsing To addresses, looking up accounts) belongs alongside the rest of the app's cross-cutting code:

// plugins/inbound-mail.js
const postmark = require('davepi-plugin-postmark');

module.exports = {
  name: 'inbound-mail',
  async setup({ schemaLoader, log }) {
    postmark.onInboundEmail(async (msg) => {
      try {
        // ...your routing logic here...
      } catch (err) {
        log.error({ err, messageId: msg.MessageID }, 'inbound mail handler failed');
      }
    });
  },
};

Multi-tenant routing

Postmark forwards every inbound email to the same URL — the plugin can't infer which tenant it belongs to. The standard pattern is to embed the tenant in the recipient address:

  • Subaddressing: support+account-<accountId>@your-domain.com — Postmark preserves the full address in msg.OriginalRecipient.
  • Per-tenant inbound domains: each account gets its own subdomain (acme.inbound.your-app.com); set up a Postmark mail server per domain, or run a single inbound and parse the recipient.

Either way, the handler is responsible for the lookup and for stamping the resulting record with the correct userId / accountId so the multi-tenant invariants in the rest of the framework still hold.

Body-size limits

dAvePi mounts express.json() globally at the framework's default limit. Postmark inbound payloads can be up to 35MB when attachments are included (base64-encoded), so if you expect large attachments bump the JSON limit in your app.js initialization (or, in a consumer project, via a plugin that re-mounts a larger express.json({ limit: '40mb' }) ahead of the inbound route).

Event-driven auto-send (optional)

If you want "fire a template every time event X happens" without adding a hook to every schema, register rules via the createPlugin factory:

// plugins/welcome-on-signup.js
const { createPlugin } = require('davepi-plugin-postmark');

module.exports = createPlugin({
  rules: [
    {
      events: 'user.created',          // or ['user.created', 'invite.accepted']
      build: (event, { appName }) => {
        const email = event.record && event.record.email;
        if (!email) return null;       // returning null skips this event
        return {
          to: email,
          templateAlias: 'welcome',
          templateModel: { name: event.record.name, app: appName },
        };
      },
    },
  ],
});

Then in package.json:

{
  "davepi": {
    "plugins": ["./plugins/welcome-on-signup.js"]
  }
}

Event patterns (identical to dAvePi's built-in outbound webhooks):

| Pattern | Matches | |---------|---------| | user.created | Exact event type. | | user.* | Every user.<verb> event (created, updated, deleted, transitioned). | | * | Every event the framework emits. |

Pure-env auto-send is intentionally not supported: emails need a recipient and a template, both of which depend on the record's fields, so the rule has to be code.

Failure handling

  • Rule subscriber (event-driven): every send is wrapped in try/catch. A Postmark outage logs an error row via the framework's pino instance and is otherwise silent — the request loop is never blocked.
  • sendEmail / sendTemplate (ad-hoc): errors propagate to the caller. The convention is to call them from an after* hook and wrap in try/catch so the hook doesn't surface an unhandledRejection.
  • Boot: a missing POSTMARK_SERVER_TOKEN logs a warning and leaves the plugin dormant; a malformed POSTMARK_FROM logs an error and stays dormant. Boot does not fail — that would be a footgun in CI / staging where Postmark is intentionally unset.

Errors carry status and errorCode properties so operators can grep without re-parsing the message:

try {
  await postmark.sendEmail({ ... });
} catch (err) {
  if (err.errorCode === 406) /* "Inactive recipient" */ markUserUnreachable();
  else throw err;
}

See Postmark API error codes for the full list.

Why not outbound webhooks for this?

The framework's webhook dispatcher delivers per-tenant subscriptions to arbitrary URLs with HMAC signing and exponential-backoff retries — right tool for "tenant X wants their own webhook." It's the wrong tool for transactional email: you need a templating system, a sender reputation, and bounce / open / click tracking, all of which Postmark provides and a generic webhook does not.

License

ISC