mikser-io-post-email
v1.0.1
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, layouts, renderHbs, frontMatter } from 'mikser-io'
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.html-mjml-email
---That's the transactional case: one entity, one recipient. The .eml lands at out/welcome.eml and the message ships immediately. On the next build, mikser's render manifest sees unchanged inputs and skips the whole chain — no resend.
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' | Deliver immediately during the chain |
| future | Write .eml, queue a row, deliver on drain when due |
| recent past (≤ maxDelay) | Catch-up: deliver immediately, warn nothing |
| 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,
send_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.
- Drain triggers: on startup, after every cycle (
onFinalized), and every 60s in--watchmode. - Failures stay queued:
attemptsandlast_errortrack retries; the next drain re-attempts. - 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.
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 |
| retention | duration string | '90d' | How long delivered/expired rows stay in the queue |
| dryRun | boolean | false | Write .eml, skip transport |
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. If 10k pending sends drain at once after long downtime, that's transport-side load to manage via SMTP-pool config.
- No retry policy beyond "next drain tries again".
attemptsis recorded for visibility; there's no exponential backoff or dead-lettering.
License
MIT
