JSPM

@pfeiferio/twinmail

0.1.1
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 1
  • Score
    100M100P100Q25329F
  • License MIT

High-performance email template system with dual HTML+Text output, custom components, and intelligent caching

Package Exports

  • @pfeiferio/twinmail

Readme

twinmail

High-performance email template system with dual HTML+Text output, custom components, and intelligent caching

npm version TypeScript License: MIT Node.js

Why twinmail?

Most email solutions require you to maintain two separate templates (HTML and plain-text) or use naive HTML→Text conversion that produces poor results. twinmail generates both formats from a single template with full control over each output.

import { renderEmail, createTagStore, addTag } from '@pfeiferio/twinmail'

// Define reusable components
const store = createTagStore()
addTag(store, 'Paragraph', {
  html: '<p class="paragraph"><slot/></p>',
  text: '<slot/>\n'
})
addTag(store, 'Button', {
  html: '<a class="btn" bind="$attrs"><slot/></a>',
  text: '<slot/> ({{$attr.href}})'
})

// Render email
const { html, text } = renderEmail(`
  <Paragraph>Hello {{name}}!</Paragraph>
  <Button href="{{url}}">Click here</Button>
`, {
  customTagStore: store,
  vars: { name: 'Alex', url: 'https://example.com' }
})

Output:

<!-- HTML -->
<p class="paragraph">Hello Alex!</p>
<a class="btn" href="https://example.com">Click here</a>
<!-- Text -->
Hello Alex!

Click here (https://example.com)

Features

🎯 Dual-Mode Rendering

One template, two formats β€” automatically.

  • HTML version: Full markup with styles and attributes
  • Text version: Clean plain-text without HTML tags
  • Mode-specific templates: Define separate variants for optimal output

πŸ—οΈ Component System

Build reusable email components with Handlebars syntax:

<!-- button.handlebars -->
<a class="btn" bind="$attrs">
    <slot/>
</a>
<!-- button.text.handlebars -->
<slot/> ({{$attr.href}})

Features:

  • <slot/> for content injection
  • Attribute binding (automatic or explicit with bind="$attrs")
  • Nested components
  • 1β†’N expansion (one tag becomes multiple elements)

⚑ Blazing Fast Performance

6 templates rendered in 1.1ms with caching enabled.

First render:  ~0.8ms  (parse + compile + cache)
Cached render: ~0.05ms (instant lookup)

Two-tier caching architecture:

  • Memory cache: Instant access to compiled templates
  • Disk cache: Persistent V8-serialized templates across restarts
  • Smart invalidation: Automatic cache busting on template changes

Installation

npm install @pfeiferio/twinmail

Quick Start

1. Basic Usage

import { renderEmail } from '@pfeiferio/twinmail'

const result = renderEmail(`
  <p>Hello {{name}}!</p>
`, {
  vars: { name: 'World' }
})

console.log(result.html) // <p>Hello World!</p>
console.log(result.text) // Hello World!

2. With Custom Components

import {
  renderEmail,
  createTagStore,
  addTag
} from '@pfeiferio/twinmail'

// Create component registry
const store = createTagStore()

// Add a custom button component
addTag(store, 'Button', {
  html: '<a class="btn" bind="$attrs"><slot/></a>',
  text: '<slot/> ({{$attr.href}})'
})

// Use it
const result = renderEmail(`
  <Button href="https://example.com" class="primary">
    Sign Up Now
  </Button>
`, {
  customTagStore: store
})

Output:

<!-- HTML -->
<a class="btn primary" href="https://example.com">Sign Up Now</a>

<!-- Text -->
Sign Up Now (https://example.com)

3. Load Components from Files

Directory structure:

components/
β”œβ”€β”€ button.handlebars
β”œβ”€β”€ button.text.handlebars
β”œβ”€β”€ heading.handlebars
└── paragraph.handlebars

Code:

import { 
  createTagStore,
  loadTagsFromDirectory,
  renderEmail
} from '@pfeiferio/twinmail'

const store = createTagStore()
loadTagsFromDirectory(store, './components')

const result = renderEmail(`
  <Heading>Welcome!</Heading>
  <Paragraph>Thanks for signing up.</Paragraph>
  <Button href="/confirm">Confirm Email</Button>
`, { customTagStore: store })
import { 
  renderEmail, 
  createTagStore, 
  loadTagsFromDirectory,
  TemplateCache 
} from '@pfeiferio/twinmail'

// Setup cache
const cache = new TemplateCache({
  cacheBasePath: './tmp/twinmail-cache',
  ttl: 7 * 24 * 60 * 60 * 1000, // 1 week
  startCleanupService: true // Enable for long-running processes
})

// Setup components
const store = createTagStore()
loadTagsFromDirectory(store, './email-components')

// Render with caching
const result = renderEmail(template, {
  customTagStore: store,
  templateCache: cache,
  vars: { /* ... */ }
})

Component Examples

Button with Styles

<!-- button.handlebars -->
<table role="presentation" cellpadding="0" cellspacing="0">
  <tr>
    <td style="border-radius: 4px; background: #007bff;">
      <a bind="$attrs" style="display: inline-block; padding: 12px 24px; color: #ffffff; text-decoration: none;">
        <slot/>
      </a>
    </td>
  </tr>
</table>
<!-- button.text.handlebars -->
<slot/> ({{$attr.href}})

Usage:

<Button href="https://example.com" class="cta">Sign Up</Button>

Output (HTML):

<table role="presentation" cellpadding="0" cellspacing="0">
  <tr>
    <td style="border-radius: 4px; background: #007bff;">
      <a href="https://example.com" class="cta" style="display: inline-block; padding: 12px 24px; color: #ffffff; text-decoration: none;">
        Sign Up
      </a>
    </td>
  </tr>
</table>

Output (Text):

Sign Up (https://example.com)

Form Field (1β†’N Expansion)

<!-- form-field.handlebars -->
<label class="label" for="{{$attr.id}}">
  <slot/>
</label>
<input class="input" bind="$attrs" />
<span class="error" id="{{$attr.id}}-error"></span>

Usage:

<FormField id="email" type="email" placeholder="you@example.com">
  Email Address
</FormField>

Output:

<label class="label" for="email">Email Address</label>
<input class="input" id="email" type="email" placeholder="you@example.com" />
<span class="error" id="email-error"></span>

Email Layout

<!-- layout.handlebars -->
<div class="container" style="max-width: 600px; margin: 0 auto;">
  <header class="header">
    <img src="{{$attr.logo}}" alt="Logo" />
  </header>
  
  <main class="content">
    <slot/>
  </main>
  
  <footer class="footer">
    <p>Β© 2026 Your Company</p>
  </footer>
</div>

API Reference

renderEmail(template, options?)

Renders an email template to HTML and text formats.

Parameters:

  • template (string): Handlebars template with optional custom tags
  • options.customTagStore (TagStore): Component registry
  • options.templateCache (TemplateCache): Cache instance for performance
  • options.vars (object): Variables to pass to template

Returns:

{
  html: string  // HTML version
  text: string  // Plain-text version
}

createTagStore()

Creates a new component registry.

Returns: TagStore

addTag(store, name, definition)

Adds a component to the registry.

Parameters:

  • store (TagStore): Component registry
  • name (string): Component name (case-insensitive)
  • definition.html (string): HTML template
  • definition.text (string, optional): Text template (defaults to html)

Returns: CustomTag

loadTagsFromDirectory(store, dirPath, options?)

Loads all .handlebars files from a directory.

Naming convention:

  • button.handlebars β†’ HTML template for "button" component
  • button.text.handlebars β†’ Text template for "button" component

Parameters:

  • store (TagStore): Component registry
  • dirPath (string): Directory path
  • options.extensions (string[]): File extensions to load (default: ['handlebars'])
  • options.recursive (boolean): Scan subdirectories (default: true)

Returns: CustomTag[]

TemplateCache

High-performance two-tier cache for compiled templates.

Constructor options:

  • cacheBasePath (string): Disk cache directory
  • ttl (number): Time-to-live in milliseconds (default: 14 days)
  • startCleanupService (boolean): Enable automatic cleanup (default: false)

Methods:

  • cache.clear(): Clear memory cache
  • cache.dispose(): Stop cleanup service
  • cache.size: Number of cached entries

Component Features

Slot Injection

<!-- wrapper.handlebars -->
<div class="wrapper">
  <slot/>
</div>
<Wrapper><p>Content</p></Wrapper>
<!-- β†’ <div class="wrapper"><p>Content</p></div> -->

Attribute Binding

Implicit (automatic):

<!-- Attributes bind to first element automatically -->
<div class="card"><slot/></div>
<MyCard class="highlight">
<!-- β†’ <div class="card highlight">...</div> -->

Explicit with bind="$attrs":

<div class="outer">
  <span bind="$attrs"><slot/></span>
</div>
<MyTag class="highlight">
<!-- β†’ <div class="outer"><span class="highlight">...</span></div> -->

Accessing attributes:

<a href="{{$attr.href}}"><slot/></a>

Mode-Specific Templates

Define different templates for HTML and text modes:

<!-- image.handlebars (HTML) -->
<img src="{{$attr.src}}" alt="{{$attr.alt}}" />
<!-- image.text.handlebars (Text) -->
[{{$attr.alt}}]

Nested Components

Components can use other components:

<!-- card.handlebars -->
<div class="card">
  <Heading>{{$attr.title}}</Heading>
  <slot/>
</div>

HTML-Only Content

Use html-only attribute to completely exclude elements from text output:

<p>
  This text appears in both versions.
  <span html-only>
    This only appears in HTML emails.
  </span>
</p>

Output (HTML):

<p>
  This text appears in both versions.
  <span html-only>
    This only appears in HTML emails.
  </span>
</p>

Output (Text):

This text appears in both versions.

Note: The html-only attribute stays in HTML output but the entire element (including its content) is stripped from text output.

Common use cases:

  • Spacer elements for layout
  • Decorative images
  • HTML-specific styling elements
  • Visual separators
<!-- Example: Spacer -->
<div html-only style="height: 20px;"></div>

<!-- Example: Decorative icon -->
<img html-only src="icon.png" alt="" />

<!-- Example: Visual separator -->
<hr html-only style="border-top: 2px solid #ccc;" />

Performance Tips

  1. Enable caching in production for 100x+ speedup on repeated renders
  2. Use startCleanupService: true only for long-running processes (servers)
  3. Define text-specific templates instead of relying on HTML fallback
  4. Preload components once at startup, not per-request
  5. Set appropriate TTL based on deployment frequency

Comparison

Feature twinmail MJML React-Email Handlebars
Single template β†’ HTML + Text βœ… ❌ ❌ ❌
Custom components βœ… βœ… βœ… ❌
Zero build step βœ… ❌ ❌ βœ…
Performance (cached) ~0.05ms ~10ms ~50ms ~2ms
Learning curve Low Medium High Low
Email-client tested User responsibility βœ… βœ… User responsibility

TypeScript Support

Full TypeScript support with exported types:

import { renderEmail, createTagStore, addTag } from '@pfeiferio/twinmail'
import type { 
  EmailOutput, 
  RenderOptions,
  TagDefinition,
  TemplateCacheOptions 
} from '@pfeiferio/twinmail'

// Type-safe component definition
const definition: TagDefinition = {
  html: '<div><slot/></div>',
  text: '<slot/>'
}

// Type-safe render options
const options: RenderOptions = {
  customTagStore: createTagStore(),
  vars: { name: 'Alice' }
}

// Type-safe result
const result: EmailOutput = renderEmail('<p>{{name}}</p>', options)
console.log(result.html) // Autocomplete works!
console.log(result.text)

Use Cases

  • βœ‰οΈ Transactional emails (confirmations, notifications, receipts)
  • πŸ“° Newsletter systems
  • πŸ”” Notification services
  • πŸ“Š Automated reports
  • 🎯 Marketing campaigns

Requirements

  • Node.js β‰₯18.0.0
  • Handlebars ^4.7.0 (peer dependency)

License

MIT Β© Pascal Pfeifer

Contributing

Issues and pull requests are welcome! This is an early-stage project β€” feedback is appreciated.

For support or questions, please open an issue.


Status: v0.1.0 - Early release. API may evolve based on feedback.

Made with ❀️ for better email development