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

mikser-io-post-email

v11.1.0

Published

Email postprocessor for mikser-io — sends rendered output via SMTP and writes .eml audit files. Composes after post-mjml in a chain.

Readme

mikser-io-post-email

Email postprocessor for mikser-io. Reads rendered HTML from a postprocess chain, composes a MIME message, delivers it via SMTP (or any nodemailer transport), and writes the .eml audit file to the output folder. Idempotency falls out of mikser's render manifest — unchanged inputs don't resend.

Sits after post-mjml in the canonical chain, so the same source content ships as a web page and a responsive email built from one MJML layout:

layouts/welcome.html-mjml-email.hbs    # renderer → MJML → post-mjml → HTML → post-email → EML

Install

npm install mikser-io-post-email

Minimal usage

// mikser.config.js
import { documents, renderHbs, frontMatter } from 'mikser-io'
import { layouts } from 'mikser-io-layouts'
import { postMjml } from 'mikser-io-post-mjml'
import { postEmail } from 'mikser-io-post-email'

export default {
    plugins: [
        documents(),
        frontMatter(),
        layouts(),
        renderHbs(),
        postMjml(),
        postEmail({
            from: '[email protected]',
            transport: {
                host: 'smtp.example.com',
                port: 587,
                auth: { user: '...', pass: '...' },
            },
        }),
    ],
}
---
to: [email protected]
subject: Welcome, Alice
layout: welcome          # the layout's NAME — the chain suffixes are not part of it
---

That's the transactional case: one entity, one recipient. The .eml lands at out/welcome.eml and the message is queued for immediate delivery — see Delivery is out of band. On the next build, mikser's render manifest sees unchanged inputs and skips the whole chain — no resend.

Delivery is out of band

Nothing is ever sent from inside the render pipeline. A postprocessor writes the .eml, records a queue row, and returns; the transport is called later, by the drain.

That matters because a transport is a third-party service. Sending inline — which this plugin did through 11.0.x — made two things true:

  • Every cycle waited on the provider. A build could not finish until every message in it had been acknowledged. One restart with a backlog spent it on 1515 sequential Mailgun round-trips, inside the pipeline, while the site's own requests queued behind it.
  • The provider could fail the build. A rejection — a 429, an outage, a socket that never answered — threw out of postprocess and failed the entity. A provider having a bad minute became a broken render, and with no marker written and no backoff, the next cycle simply tried again.

Queuing costs one sqlite row and buys delivery that survives a crash mid-cycle, retries with backoff, a bounded per-message timeout, and a render that never waits on mail.

Promptness is unaffected at both ends. A one-shot mikser drains at onFinalized, so it still delivers before it exits. A resident instance (--watch or --server) drains on a 60-second timer, off the cycle — and not at onFinalized, because that hook is awaited inside the cycle and draining there would put the transport straight back on the critical path.

Recipient lists — @listname references

For broadcast, define named lists in plugin options and reference them from to/cc/bcc with an @ prefix.

postEmail({
    from: '[email protected]',
    lists: {
        subscribers: async ({ entity }) =>
            (await queryEntities({ type: 'subscriber', meta: { lang: entity.meta.lang } }))
                .map(s => s.meta.email),
        clients:     ['[email protected]', '[email protected]'],
        team:        ['[email protected]'],
        archive:     ['[email protected]'],
    },
    bcc: ['@archive'],     // global audit copy on every send
})
# newsletter
---
to: '@subscribers'
subject: This week
---

# client update + one-off CC
---
to: '@clients'
cc: ['[email protected]']
---

Resolution rule

| Field | Where it comes from | Combine semantics | |---|---|---| | to | entity meta.to only | exclusive — no plugin-options fallback | | cc | entity meta.cc + plugin cc | additive, deduped (case-insensitive) | | bcc | entity meta.bcc + plugin bcc | additive, deduped | | from| entity meta.from ?? plugin from | entity wins; missing entirely → error |

A @listname in any of these fields resolves through options.lists. Spec values can be a literal string, an array, or (ctx) => Promise<string | string[]> where ctx = { entity, runtime, config, lists, logger }.

Errors:

  • to missing → hard error (no silent send-to-nobody).
  • @unknown → hard error naming the missing list.
  • to: [] (empty after resolution) → writes an empty .eml marker, logs debug, no delivery. Valid "no subscribers for this language" case.

Scheduling — sendAt

---
to: '@subscribers'
subject: Friday digest
sendAt: 2026-06-20T09:00:00Z   # ISO 8601, or missing/'now' for immediate
---

Resolution against maxDelay (default '1h'):

| sendAt | What happens | |---|---| | missing / 'now' | Write .eml, queue a row due now, deliver on the next drain | | future | Write .eml, queue a row, deliver on drain when due | | recent past (≤ maxDelay) | Catch-up: same as 'now' | | ancient past (> maxDelay) | Write .eml, mark expired_at, log warning, no delivery |

maxDelay is overridable per-entity:

---
sendAt: 2026-06-20T09:00:00Z
maxDelay: 4h                    # tolerate up to 4h of downtime
---

How the queue works

A persistent table (mikser_post_email_queue in runtime/mikser.sqlite) holds future sends.

mikser_post_email_queue (
    id          PRIMARY KEY → mikser_entities(id) ON DELETE CASCADE,
    eml_path,   eml_hash,   payload,
    send_at,    next_attempt_at,
    sent_at,    expired_at,
    attempts,   last_error
)
  • PK on entity id + UPSERT — re-editing sendAt reschedules in place; can't double-queue.
  • FK CASCADE — delete the source doc, queue row vanishes. Schedule follows the file.
  • payload is what gets delivered — the message as fields, stored on the row. Not the .eml: that is an audit artifact, and re-reading it to send a raw message depended both on the output tree still holding the file (a --clear, or a deploy rsync --delete, removes it) and on the transport honouring nodemailer's raw field. Mailgun's does not — nodemailer-mailgun-transport applies a key whitelist with no raw in it, so the field is dropped and what reaches the API has no sender, no recipient and no body. Released once the row is delivered.
  • Drain triggers: on startup; every 60s while resident (--watch or --server); after every cycle only when there is no timer, i.e. for a one-shot build.
  • One drain at a time. Passes are serialised. A pass only marks a row sent once the transport has answered, so an overlapping pass would select the same unmarked rows and deliver them twice — and a backlog easily outlasts the 60s interval.
  • Failures back off: attempts and last_error record what happened, and next_attempt_at holds the row until 1m × 2^attempts (capped at 15m). send_at is never moved — maxDelay is measured from it, so backing off must not make a row that keeps failing look permanently on-time and never expire.
  • Retention: delivered + expired rows are kept for retention (default '90d') then pruned.

Transport

Anything nodemailer.createTransport accepts:

postEmail({
    transport: {
        host: 'smtp.example.com', port: 587, secure: false,
        auth: { user: '...', pass: '...' },
    },
})

With no transport provided, the plugin builds a JSON transport — useful for dev/testing: messages serialize to the .eml on disk but never leave the box.

Dry-run

postEmail({ dryRun: process.env.NODE_ENV !== 'production' })

dryRun: true writes every .eml (so authors can review them in the output folder) but skips transport.sendMail(). The queue still records deliveries (with sent_at) for observability. No send-once marker is written, so a dry run never suppresses a later real delivery.

Send-once — durable delivery markers

An email is delivered once, even across rebuilds. After a successful send the plugin writes a marker file into sentFolder (default emails/ in the working folder); on every later build the marker suppresses re-delivery.

This can't live in the queue table: mikser wipes its cache whenever mikser.config.js changes, and the queue rows cascade off mikser_entities. Both drop the sent state, so before markers existed a rebuild re-rendered every email document and re-sent the lot — a whole backlog of form submissions at once. The marker is on disk for the same reason the assets plugin keeps .md5 sidecars there: it has to outlive the cache.

postEmail({
    from: 'Site <[email protected]>',
    sentFolder: 'emails',   // default; relative to the working folder, or absolute
    revision: 1,            // bump to invalidate every marker and allow a resend
})

Keep sentFolder out of source control and out of any deploy that deletes what it doesn't ship. It is runtime delivery state, not build cache — unlike assets/, "who we already emailed" must never be committed, and if a deploy's rsync --delete removes it the backlog re-sends. Treat it like runtime/: add it to .gitignore and to the deploy's exclude list.

What counts as "the same email"

The delivery identity is a hash of from, to, cc, bcc, subject, the rendered body, and sendAt — not the composed .eml bytes, which carry a fresh Message-ID and Date on every compose and would never match.

sendAt is part of it on purpose: a recurring email keeps its id and its body and only moves its send time, so without it every occurrence after the first would look already-delivered and be silently dropped.

Volatile bodies. If a template renders something that changes every build — a timestamp, a random token, a cache-buster — its hash changes too and the guard never matches. Name a stable identity in the entity's frontmatter instead, and the body stops being part of it:

to: [email protected]
subject: Your receipt
deliveryKey: receipt-42

Forcing a resend

Delete the entity's marker file, or bump revision to invalidate all of them.

Options reference

| Option | Type | Default | | |---|---|---|---| | name | string | 'email' | Chain identifier (used in layout filenames as -email) | | from | string | required (or per-entity from) | Default sender | | lists | Record<string, string \| string[] \| function> | {} | Named recipient lists for @listname references | | cc | spec | none | Global CC, deduped with entity cc | | bcc | spec | none | Global BCC, deduped with entity bcc | | transport | nodemailer config | JSON transport | Delivery target | | maxDelay | duration string | '1h' | How late past sendAt is still acceptable | | sendTimeout | duration string | number | '30s' | How long one message may wait on the transport. 0 waits forever | | retention | duration string | '90d' | How long delivered/expired rows stay in the queue | | sentFolder | string | 'emails' | Where send-once markers live; keep it gitignored and out of the deploy's delete set | | revision | number | 1 | Bump to invalidate every marker (forces a resend) | | dryRun | boolean | false | Write .eml, skip transport (and write no marker) |

What it does NOT do (v1)

  • No transport-native scheduling. Setting send_at on SendGrid/Mailchimp/etc. is not exposed — when an entity is rescheduled (frontmatter edited), the transport would hold both the old and new send. The internal queue dedupes via PK; transport-native scheduling can't.
  • No rename-preserving queue. A file rename = old entity DELETE + new entity INSERT; the old queue row cascades out, the new one's queue row inherits the renamed file's sendAt. Acceptable for v1.
  • No throttling. A drain delivers every due row, one at a time, with no rate cap. After long downtime that is transport-side load to manage via SMTP-pool config. Deliberately not capped per pass: a cap plus maxDelay is a way to expire mail that was only late because we throttled it, and a lost email is worse than a slow one.
  • No dead-lettering. A row that keeps failing backs off to a 15m retry and is eventually expired by maxDelay, with last_error on the row. There is no separate dead-letter table and no alert.
  • A timeout cannot cancel a send. It abandons the call, so a message that timed out may still have been delivered and the retry can duplicate it. The trade is deliberate: an unbounded send wedges the (serialised) drain permanently, which stops all delivery.
  • One transport per process. transport and the drain timer are module-level, so configuring postEmail() twice in one project has the second call's transport win for both. Use one.

License

MIT