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 → EMLInstall
npm install mikser-io-post-emailMinimal 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
postprocessand 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:
tomissing → hard error (no silent send-to-nobody).@unknown→ hard error naming the missing list.to: [](empty after resolution) → writes an empty.emlmarker, 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
sendAtreschedules in place; can't double-queue. - FK CASCADE — delete the source doc, queue row vanishes. Schedule follows the file.
payloadis 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 deployrsync --delete, removes it) and on the transport honouring nodemailer'srawfield. Mailgun's does not —nodemailer-mailgun-transportapplies a key whitelist with norawin 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 (
--watchor--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:
attemptsandlast_errorrecord what happened, andnext_attempt_atholds the row until 1m × 2^attempts (capped at 15m).send_atis never moved —maxDelayis 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-42Forcing 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_aton 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
maxDelayis 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, withlast_erroron 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.
transportand the drain timer are module-level, so configuringpostEmail()twice in one project has the second call's transport win for both. Use one.
License
MIT
