npm package: @pfeiferio/twinmail
Description: High-performance email template system with dual HTML+Text output, custom components, and intelligent caching
Author: Pascal Pfeifer
Latest version: 0.1.1

# twinmail

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

[![npm version](https://badge.fury.io/js/%40pfeiferio%2Ftwinmail.svg)](https://www.npmjs.com/package/@pfeiferio/twinmail)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue.svg)](https://www.typescriptlang.org/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org/)

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

```typescript
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
<!-- HTML -->
<p class="paragraph">Hello Alex!</p>
<a class="btn" href="https://example.com">Click here</a>
```

```text
<!-- 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:

```handlebars
<!-- button.handlebars -->
<a class="btn" bind="$attrs">
    <slot/>
</a>
```

```handlebars
<!-- 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

```bash
npm install @pfeiferio/twinmail
```

## Quick Start

### 1. Basic Usage

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

```typescript
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
<!-- 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:**
```typescript
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)

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

```handlebars
<!-- 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>
```

```handlebars
<!-- button.text.handlebars -->
<slot/> ({{$attr.href}})
```

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

**Output (HTML):**
```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):**
```text
Sign Up (https://example.com)
```

### Form Field (1→N Expansion)

```handlebars
<!-- 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:**
```html
<FormField id="email" type="email" placeholder="you@example.com">
  Email Address
</FormField>
```

**Output:**
```html
<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

```handlebars
<!-- 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:**
```typescript
{
  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

```handlebars
<!-- wrapper.handlebars -->
<div class="wrapper">
  <slot/>
</div>
```

```html
<Wrapper><p>Content</p></Wrapper>
<!-- → <div class="wrapper"><p>Content</p></div> -->
```

### Attribute Binding

**Implicit (automatic):**
```handlebars
<!-- Attributes bind to first element automatically -->
<div class="card"><slot/></div>
```

```html
<MyCard class="highlight">
<!-- → <div class="card highlight">...</div> -->
```

**Explicit with `bind="$attrs"`:**
```handlebars
<div class="outer">
  <span bind="$attrs"><slot/></span>
</div>
```

```html
<MyTag class="highlight">
<!-- → <div class="outer"><span class="highlight">...</span></div> -->
```

**Accessing attributes:**
```handlebars
<a href="{{$attr.href}}"><slot/></a>
```

### Mode-Specific Templates

Define different templates for HTML and text modes:

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

```handlebars
<!-- image.text.handlebars (Text) -->
[{{$attr.alt}}]
```

### Nested Components

Components can use other components:

```handlebars
<!-- 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:

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

**Output (HTML):**
```html
<p>
  This text appears in both versions.
  <span html-only>
    This only appears in HTML emails.
  </span>
</p>
```

**Output (Text):**
```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

```html
<!-- 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:

```typescript
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](https://github.com/pfeiferio)

## Contributing

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

For support or questions, please [open an issue](https://github.com/pfeiferio/twinmail/issues).

---

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

Made with ❤️ for better email development