npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pfeiferio/twinmail

v0.1.1

Published

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

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 })

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 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