@uscreen.de/postal-mailer is an ESM-only Node.js library for sending transactional
emails via Postal API or SMTP. It compiles MJML templates with Handlebars, supports
layout inheritance, i18n via locale-based template directories, and frontmatter metadata.
- Install:
pnpm install(use pnpm, not npm -- enforced viapackageManagerfield) - Lint:
pnpm run lint(ESLint, no auto-fix) - Lint + fix:
pnpm run lint:fix(ESLint with--fix) - Test all:
pnpm testornode --test test/*.test.js - Test single file:
node --test test/api.test.js - Test single case:
node --test --test-name-pattern="pattern" test/api.test.js - Test with coverage:
pnpm run test:cov(HTML + text report via c8)
Node.js 20+ required. .nvmrc specifies Node 24. CI tests against 20, 22, 24.
SMTP tests (test/smtp.test.js) require a running Mailpit instance on ports 1025/8025.
Start it via: docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit:latest
API tests (test/api.test.js) use nock to mock HTTP -- no external services needed.
Layout tests (test/layout.test.js) use temp directories -- no external services needed.
index.js # Package entry point, re-exports lib/index.js
lib/
index.js # Core factory: validates config, selects transport
config.js # env-schema based configuration validation
utils.js # Template compilation (MJML, Handlebars, layouts)
postal.js # Postal API transport (@atech/postal)
smtp.js # SMTP transport (nodemailer)
test/
helper.js # Test builder factory (build function)
interceptor.js # Nock HTTP body-matching helper
api.test.js # Postal API transport tests (uses nock)
smtp.test.js # SMTP transport tests (requires Mailpit)
layout.test.js # Layout inheritance tests (uses temp dirs)
templates/ # Test MJML templates organized by locale (en/, de/)
This is an ESM-only project ("type": "module" in package.json).
- Node.js built-ins with
node:protocol prefix - Third-party packages (bare specifiers)
- Relative imports (always with
.jsextension)
import assert from 'node:assert' // 1. Node built-ins
import { test, describe } from 'node:test'
import nock from 'nock' // 2. Third-party
import app from '../index.js' // 3. Relative (always .js ext)export defaultfor main module exports (all lib/ files, entry point)- Named
export { name }for utility/helper exports (test helpers)
Uses @antfu/eslint-config (flat config in eslint.config.js) with formatters enabled.
Key rules: comma-dangle: never, curly: multi-line, consistent, no-console: off.
Prettier: no semicolons, single quotes, no trailing commas, bracket spacing enabled.
- Files: kebab-case (
test-with-subject.mjml) - Variables/functions/config: camelCase (
compileHtmlBody,postalServer) - Internal markers: double-underscore prefix (
__pmtransport) - No UPPER_SNAKE_CASE constants -- everything is camelCase
Stroustrup style -- else on a new line after the closing brace:
if (contentMatch) {
content = contentMatch[1].trim()
}
else {
content = content.trim()
}Every module follows a factory function / closure pattern. Modules export a function that receives config and returns an object of methods. No classes are used.
The initialization chain: index.js -> lib/index.js -> config.js (validate) ->
select transport (postal.js or smtp.js) -> return { client, sendMail, compileHtmlBody, compilePlainBody }.
Input validation -- use assert in library code:
assert(template, 'providing a value for template is required')
assert(typeof template === 'string', 'template should be a string')I/O operations -- try/catch with graceful fallback (note: bare catch {, no param):
try {
layoutContent = fs.readFileSync(layoutPath, { encoding: 'utf8' })
} catch {
console.error(`Layout not found: ${layoutName}`)
return `<mjml><mj-body>${content}</mj-body></mjml>`
}Tests -- use assert.rejects() with predicate functions:
await assert.rejects(
() => app.sendMail(payload),
(error) => error && error.code === 'ERR_ASSERTION' && error.operator === '==',
`should fail on missing property ${prop}`
)- Use
describe()for grouping,test()for individual cases - Use
beforeEach()/afterEach()for setup/teardown - Assertions:
assert.ok(),assert.strictEqual(),assert.match(),assert.doesNotMatch(),assert.rejects(),assert.deepStrictEqual() - HTTP mocking:
nockwith custom interceptor (test/interceptor.js) - Test builder:
build()factory fromtest/helper.jscreates configured app instances - SMTP tests need Mailpit (
axllent/mailpit:latest) on ports 1025/8025 - Mailpit cleanup:
DELETE /api/v1/messagesbetween tests via custommailpitClient()
- Templates live in locale directories:
templates/en/,templates/de/ - Frontmatter uses HTML comment delimiters:
<!-- @meta ... --> - Layout inheritance:
layout: basein frontmatter referenceslayouts/base.mjml - Content blocks:
<!-- @content -->...<!-- @endcontent --> - Style blocks:
<!-- @styles -->...<!-- @endstyles --> - Layouts use triple-stache
{{{content}}}for unescaped HTML insertion
Uses env-schema (Fastify ecosystem) with JSON Schema validation. Required:
postalTemplates, postalServer, postalKey. Optional: postalTransport
(postal/smtp, default postal), postalPort (default 25), postalUser,
postalSender, postalAssetsUrl, postalDefaultLocale. All camelCase.
- GitHub Actions (
.github/workflows/main.yml), triggers on push/PR tomaster/main - Matrix: Node.js 20, 22, 24 with pnpm (
pnpm/action-setup@v4) - Starts Mailpit via
docker runfor SMTP tests, coverage uploaded to Coveralls - Dependabot auto-merge for dependency updates