@pfeiferio/twinmail
v0.1.1
Published
High-performance email template system with dual HTML+Text output, custom components, and intelligent caching
Maintainers
Readme
twinmail
High-performance email template system with dual HTML+Text output, custom components, and intelligent caching
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/twinmailQuick 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.handlebarsCode:
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 })4. With Caching (Recommended for Production)
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="[email protected]">
Email Address
</FormField>Output:
<label class="label" for="email">Email Address</label>
<input class="input" id="email" type="email" placeholder="[email protected]" />
<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 tagsoptions.customTagStore(TagStore): Component registryoptions.templateCache(TemplateCache): Cache instance for performanceoptions.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 registryname(string): Component name (case-insensitive)definition.html(string): HTML templatedefinition.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" componentbutton.text.handlebars→ Text template for "button" component
Parameters:
store(TagStore): Component registrydirPath(string): Directory pathoptions.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 directoryttl(number): Time-to-live in milliseconds (default: 14 days)startCleanupService(boolean): Enable automatic cleanup (default:false)
Methods:
cache.clear(): Clear memory cachecache.dispose(): Stop cleanup servicecache.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
- Enable caching in production for 100x+ speedup on repeated renders
- Use
startCleanupService: trueonly for long-running processes (servers) - Define text-specific templates instead of relying on HTML fallback
- Preload components once at startup, not per-request
- 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
