hono-email
v0.5.1
Published
A lightweight, type-safe email template engine for Hono, powered by Hono JSX.
Downloads
1,146
Maintainers
Readme
hono-email
hono-email is an ESM library for rendering HTML email and plain text from hono/jsx. It focuses on rendering, normalization, validation, and email-oriented primitives.
[!WARNING] This project is in the early stages of development. APIs and other elements are subject to change in the future.
Features
- Render HTML email from
hono/jsx - Render plain text from the same JSX tree through
render() - Keep strict email validation enabled by default
- Minify output by default, with optional pretty printing and widow control
- Style markdown content with the
Markdowncomponent - Use
hono/cssclass-based CSS-in-JS as a styling option - Apply Tailwind utility output through
Tailwindbuild artifacts - Send rendered email through transport adapters (SMTP, Resend, SendGrid, Postmark, Mailgun, Cloudflare Email)
- Expose bundler integrations through
@hono-email/tailwind-plugin - Live-preview templates with real-time props editing using
@hono-email/preview
Setup
npm i hono-emailQuick Start
import { Body, Button, Container, Head, Heading, Html, Preview, Text, render } from 'hono-email'
function WelcomeEmail() {
return (
<Html lang="en">
<Head>
<title>Welcome</title>
</Head>
<Preview>Your account is ready.</Preview>
<Body style={{ backgroundColor: '#f6f9fc', color: '#1f2937' }}>
<Container style={{ maxWidth: '560px', margin: '0 auto', padding: '24px' }}>
<Heading as="h1">Welcome</Heading>
<Text>Thanks for signing up.</Text>
<Button href="https://example.com/start">Get started</Button>
</Container>
</Body>
</Html>
)
}
const { html, text } = await render(<WelcomeEmail />, {
text: {
headingStyle: 'preserve',
linkFormat: 'text-only',
},
})render()
render() is the primary runtime API.
- Returns HTML, plain text, and collected warnings as
{ html, text, warnings } - Uses
strict: trueby default - Accepts
doctype: 'html5' | 'xhtml-transitional' | false - Minifies the HTML by default. Set
pretty: truefor readable output, orminify: falsefor unprocessed HTML.prettytakes precedence overminify. - Set
widows: trueto join the last two words of each text block with a non-breaking space - Always expands three-digit hex colors (
#abc→#aabbcc) insidestyleattributes and<style>blocks - Controls warning handling through
onWarning: 'warn' | 'error' | 'silent' | (warning) => void(default'warn') - Accepts plain-text options through the
textfield
const { html, text } = await render(<WelcomeEmail />, {
text: {
linkFormat: 'text-only',
listBullet: '*',
},
})Send Email with Adapter
SMTP
hono-email/smtp provides a connector-based SMTP sender.
import CloudflareConnector from 'hono-email/smtp/cloudflare'
import { Body, Html, Text, sendEmail } from 'hono-email'
import { SmtpTransport } from 'hono-email/smtp'
const dkimPrivateKey = `-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----`
const smtp = new SmtpTransport({
connector: CloudflareConnector,
hostname: 'smtp.example.com',
port: 587,
secure: 'starttls',
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 30_000,
auth: {
username: 'smtp-user',
password: 'smtp-password',
},
dkim: {
domainName: 'example.com',
keySelector: 'mail',
privateKey: dkimPrivateKey,
},
pool: {
maxConnections: 2,
maxMessages: 100,
},
limits: {
maxAttachmentSize: 10 * 1024 * 1024,
},
})
try {
await smtp.verify()
const receipt = await sendEmail({
adapter: smtp,
from: '[email protected]',
to: ['[email protected]', '[email protected]'],
subject: 'Welcome',
envelope: {
from: '[email protected]',
},
jsx: (
<Html>
<Body>
<Text>Hello from hono-email.</Text>
</Body>
</Html>
),
attachments: [
{
filename: 'invoice.txt',
content: 'Invoice text',
contentType: 'text/plain',
},
{
filename: 'logo.png',
href: 'https://example.com/assets/logo.png',
cid: 'logo',
contentDisposition: 'inline',
},
],
})
if (!receipt.successful) {
console.error(receipt.errorMessages)
}
} finally {
await smtp.close()
}SMTP transport uses Web Streams internally. Runtime-specific socket support is supplied by a
connector such as hono-email/smtp/cloudflare, which uses Cloudflare Workers
cloudflare:sockets. Cloudflare Workers does not allow outbound SMTP connections on port 25, so
use submission ports such as 465 or 587.
SmtpTransport reuses SMTP sessions until transport.close() is called. The default pool size is
1, so sends on the same transport share one TCP connection sequentially. Set
pool.maxConnections to allow multiple concurrent SMTP sessions, and pool.maxMessages to retire a
session after a fixed number of messages. If a session fails during send, that session is discarded
and the message is not retried automatically.
SMTP-specific delivery controls:
await transport.verify()checks connection setup, TLS negotiation, and authentication without sending a message.dkimcan be configured onSmtpTransportor overridden per message to add aDKIM-Signatureheader before SMTP delivery.envelopelets you override the SMTP envelope sender and recipients without changing the visibleFrom/Toheaders.to,cc,bcc, andenvelope.toaccept single addresses or arrays. SMTP sends oneRCPT TOcommand per resolved recipient and reports partial recipient rejection inreceipt.rejected.attachmentssupportscontent,href, remote URL / data URIpath,ReadableStream,encoding, inlinecid, and explicitcontentType.hono-emaildoes not read local files; read local files in your app and pass the bytes ascontent.limits.maxAttachmentSizerejects oversized attachments before delivery.headersis for custom headers only. Managed headers such asFrom,To,Subject,Date,Message-ID,MIME-Version, andContent-Typeare rejected when passed as custom headers.connectionTimeout,greetingTimeout, andsocketTimeoutbound SMTP connection and protocol waits. STARTTLS and AUTH are checked against EHLO capabilities before use.
Runtime connector entry points:
hono-email/smtp/cloudflarefor Cloudflare Workershono-email/smtp/nodefor Node.jshono-email/smtp/denofor Denohono-email/smtp/bunfor Bun
Bun's TCP API supports direct TLS connections, but this connector does not support STARTTLS upgrade.
Use port 465 with secure: true on Bun.
Resend
hono-email/resend provides ResendAdapter for the Resend Email API.
import { Body, Html, Text, sendEmail } from 'hono-email'
import ResendAdapter from 'hono-email/resend'
const receipt = await sendEmail({
adapter: ResendAdapter({
apiKey: process.env.RESEND_API_KEY!,
}),
from: 'Sender <[email protected]>',
to: ['[email protected]'],
subject: 'Welcome',
jsx: (
<Html>
<Body>
<Text>Hello from Resend.</Text>
</Body>
</Html>
),
attachments: [
{
filename: 'invoice.txt',
content: 'Invoice text',
contentType: 'text/plain',
},
],
})
if (!receipt.successful) {
console.error(receipt.errorMessages)
}The adapter calls Resend directly with fetch; the official Resend SDK is not required. Resend
requires a User-Agent header for direct HTTP requests, so ResendAdapter sends one by default and
also accepts a userAgent override.
SendGrid
hono-email/sendgrid provides SendGridAdapter for the Twilio SendGrid Mail Send API.
import { Body, Html, Text, sendEmail } from 'hono-email'
import { SendGridAdapter } from 'hono-email/sendgrid'
const receipt = await sendEmail({
adapter: SendGridAdapter({
apiKey: process.env.SENDGRID_API_KEY!,
}),
from: { address: '[email protected]', name: 'Sender' },
to: ['[email protected]'],
subject: 'Welcome',
jsx: (
<Html>
<Body>
<Text>Hello from SendGrid.</Text>
</Body>
</Html>
),
})The adapter sends JSON directly to /v3/mail/send and maps SendGrid error responses to
SendEmailReceipt. Use apiBaseUrl: 'https://api.eu.sendgrid.com' for SendGrid EU regional
sending.
Postmark
hono-email/postmark provides PostmarkAdapter for the Postmark Email API.
import { Body, Html, Text, sendEmail } from 'hono-email'
import { PostmarkAdapter } from 'hono-email/postmark'
const receipt = await sendEmail({
adapter: PostmarkAdapter({
serverToken: process.env.POSTMARK_SERVER_TOKEN!,
messageStream: 'outbound',
}),
from: '[email protected]',
to: '[email protected]',
subject: 'Welcome',
jsx: (
<Html>
<Body>
<Text>Hello from Postmark.</Text>
</Body>
</Html>
),
})Optional Postmark delivery controls include messageStream, tag, trackOpens, and trackLinks.
Mailgun
hono-email/mailgun provides MailgunAdapter for the Mailgun Messages API.
import { Body, Html, Text, sendEmail } from 'hono-email'
import { MailgunAdapter } from 'hono-email/mailgun'
const receipt = await sendEmail({
adapter: MailgunAdapter({
apiKey: process.env.MAILGUN_API_KEY!,
domain: process.env.MAILGUN_DOMAIN!,
}),
from: '[email protected]',
to: '[email protected]',
subject: 'Welcome',
jsx: (
<Html>
<Body>
<Text>Hello from Mailgun.</Text>
</Body>
</Html>
),
})The adapter sends a multipart/form-data request to /v3/{domain}/messages. Use
apiBaseUrl: 'https://api.eu.mailgun.net' for Mailgun EU domains.
All provider adapters support to, cc, bcc, replyTo, custom headers, and attachments.
Inline attachments use cid when the provider supports content IDs. They call provider APIs with
fetch, so official provider SDKs are not required.
Cloudflare Email Service
hono-email/cloudflare provides CloudflareEmailAdapter and connectors for Cloudflare Email Service.
On Cloudflare Workers (using WorkersConnector):
import { Body, Html, Text, sendEmail } from 'hono-email'
import { CloudflareEmailAdapter } from 'hono-email/cloudflare'
import WorkersConnector from 'hono-email/cloudflare/workers'
export default {
async fetch(_request: Request, env: Env): Promise<Response> {
const receipt = await sendEmail({
adapter: CloudflareEmailAdapter({ connector: WorkersConnector }),
from: '[email protected]',
to: '[email protected]',
subject: 'Welcome',
jsx: (
<Html>
<Body>
<Text>Hello from Cloudflare Workers.</Text>
</Body>
</Html>
),
})
return Response.json(receipt)
},
}REST API (other runtimes, using RESTConnector):
import { Body, Html, Text, sendEmail } from 'hono-email'
import { CloudflareEmailAdapter, RESTConnector } from 'hono-email/cloudflare'
const receipt = await sendEmail({
adapter: CloudflareEmailAdapter({
connector: RESTConnector({
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
apiToken: process.env.CLOUDFLARE_API_TOKEN!,
}),
}),
from: { address: '[email protected]', name: 'Sender' },
to: ['[email protected]', '[email protected]'],
subject: 'Welcome',
jsx: (
<Html>
<Body>
<Text>Hello from the Cloudflare REST API.</Text>
</Body>
</Html>
),
})
if (receipt.successful && receipt.queued) {
console.log('Queued recipients:', receipt.queuedRecipients)
}Components
HtmlHeadBodyBoxCardContainerFlexGridSectionRowColumnSpacerTextHeadingButtonLinkImgListListItemCodeInlineCodeBlockPreviewHrFontColorSchemeConditionalTailwindMarkdown
Notes:
ButtonandLinkdefault totarget="_blank"Previewrenders hidden preview textTextdefaults tofont-size: 14px,line-height: 24px, and vertical16pxmarginsHrdefaults toborder-top: 1px solid #eaeaeaHeadingaccepts shorthand margin props such asm,mx,my,mt,mr,mb, andmlFlexrenders table-based layout instead of CSSdisplay:flexGridrenders table-based columns instead of CSSdisplay:gridCardrenders a bordered table container with configurable padding and colorsListandListItemprovide email-oriented spacing defaults for ordered and unordered listsSpacerrenders explicit email-safe spacingConditionalrenders Outlook conditional comments and is still validated in strict mode
Strict Mode
Strict mode is enabled by default in render(). It is meant to fail early on markup and CSS that are risky for HTML email clients.
Representative error cases:
- Interactive or embedded tags such as
form,input,button,select,textarea,iframe,picture,source,svg, andscript <a>withouthref<link rel="stylesheet">tags (disallowed in strict mode)<style>outside<Head>display:grid,display:inline-grid, anddisplay:inline-flex- Logical properties such as
padding-inline,margin-block, andborder-inline - Unsupported tags/CSS inside Outlook conditional comments (
<!--[if mso]>...<![endif]-->)
Representative compatibility-sensitive cases include:
display:flexpositionobject-fit/object-positionbackground-image@font-face@media<img>withoutalt
Testing
Strict-mode errors (unsupported tags, unsafe URLs, risky CSS) already reject the render() promise, so they fail tests as-is.
Compatibility warnings are returned on result.warnings and, by default, logged with console.warn. To make warnings fail a test, pass onWarning: 'error' so render() throws when any warning is collected.
import { expect, test } from 'vitest'
import { render } from 'hono-email'
import { WelcomeEmail } from './welcome-email'
// Fail the test on any compatibility warning.
const renderEmail = (jsx: Parameters<typeof render>[0]) => render(jsx, { onWarning: 'error' })
test('welcome email renders without warnings', async () => {
await expect(renderEmail(<WelcomeEmail />)).resolves.toBeDefined()
})
// Or assert on the collected warnings directly.
test('welcome email has no warnings', async () => {
const { warnings } = await render(<WelcomeEmail />, { onWarning: 'silent' })
expect(warnings).toEqual([])
})Use onWarning: 'silent' to inspect result.warnings without console output, or pass a callback to route warnings into your own collector.
Font
<Font> renders @font-face and a fallback font-family declaration inside <Head>
Please note @font-face is not available for some clients, so it is recommended to set fallbackFontFamily. see
import { Font, Head, Html, render } from 'hono-email'
const { html } = await render(
<Html>
<Head>
<Font
fallbackFontFamily={['Arial', 'sans-serif']}
fontFamily="Inter"
fontWeight={400}
webFont={{
url: 'https://example.com/inter.woff2',
format: 'woff2',
}}
/>
</Head>
</Html>,
)Styling
hono-email provides multiple types of styling.
Basic
import { Body, Html, Text, render } from 'hono-email'
const { html } = await render(
<Html>
<Body>
<Text style={{ color: '#0f172a' }}>Hello</Text>
</Body>
</Html>,
)hono/css (CSS-in-JS)
You can use hono/css class names directly on normal elements (<div>, <Text>, etc.).
render() automatically converts matching class rules to email-safe inline styles.
<Head><Style /></Head> is required when using hono/css.
import { Style, css } from 'hono/css'
import { Body, Head, Html, Text, render } from 'hono-email'
const titleClass = css`
color: #0f172a;
padding-left: 1rem;
padding-right: 1rem;
font-weight: bold;
`
const { html } = await render(
<Html>
<Head>
<Style />
</Head>
<Body>
<Text className={titleClass}>Hello</Text>
</Body>
</Html>,
)Tailwind
If you are using <Tailwind> component, we recommend using a bundler (Vite, Rolldown, Webpack, Esbuild etc) and the EmailTailwind plugin.
// vite.config.ts
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
import EmailTailwind from '@hono-email/tailwind-plugin/vite'
export default defineConfig({
plugins: [tailwindcss(), EmailTailwind()],
})For CommonJS Webpack config files, use the webpack subpath export.
// webpack.config.cjs
const EmailTailwind = require('@hono-email/tailwind-plugin/webpack').default
module.exports = {
plugins: [EmailTailwind()],
}This plugin automatically finds <Tailwind> and automatically injects Tailwind styles.
import { Body, Head, Html, Tailwind, Text, render } from 'hono-email'
const { html } = await render(
<Html>
<Head />
<Tailwind>
<Body>
<Text className="text-brand bg-brand px-4 py-2">Hello</Text>
</Body>
</Tailwind>
</Html>,
)Base utilities are inlined as style attributes, and responsive (sm:) styles are relocated into <head>. Single-element pseudo-class variants such as hover: and focus: are kept in <head> with email-safe renamed class names (hover:bg-blue-500 → hover-bg-blue-500). Combinator variants such as group-hover: and peer-* are not supported and are dropped with a warning.
When using Tailwind for frontend styling, we recommend using @source with not to exclude emails from being scanned by the frontend Tailwind build.
@import 'tailwindcss';
@source not "./emails";Passing an artifact explicitly
If you are not using a bundler plugin, use buildTailwindArtifactFromCss().
import { Body, Head, Html, Tailwind, Text, buildTailwindArtifactFromCss, render } from 'hono-email'
const artifact = buildTailwindArtifactFromCss({
css: `
@layer utilities {
.bg-brand { background-color: #0f172a; }
.text-white { color: #ffffff; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
}
`,
})
const { html } = await render(
<Html>
<Head />
<Tailwind artifact={artifact}>
<Body>
<Text className="bg-brand text-white px-4 py-2">Hello</Text>
</Body>
</Tailwind>
</Html>,
)Markdown
<Markdown> converts GFM into HTML and applies email-friendly inline styles by default. Sanitization is enabled by default.
import { Body, Head, Html, Markdown, render } from 'hono-email'
const { html } = await render(
<Html lang="en">
<Head>
<title>Markdown example</title>
</Head>
<Body>
<Markdown
markdownContainerStyles={{
padding: '12px',
border: '1px solid #111827',
}}
markdownCustomStyles={{
h1: { color: '#dc2626' },
codeInline: {
backgroundColor: '#e5e7eb',
padding: '2px 4px',
},
}}
>{`
# Markdown email
| Name | Role |
| --- | --- |
| Taro | Builder |
`}</Markdown>
</Body>
</Html>,
)Markdown with Tailwind classes
When you render Markdown inside <Tailwind>, you can switch Markdown to class-based mode so Tailwind utilities control the styling.
markdownStyleMode="tailwind" is only supported inside <Tailwind> and throws otherwise.
import { Body, Head, Html, Markdown, Tailwind, render } from 'hono-email'
const { html } = await render(
<Html lang="en">
<Head />
<Tailwind>
<Body>
<Markdown
markdownStyleMode="tailwind"
markdownContainerClassName="prose text-slate-900"
markdownCustomClassNames={{
h1: 'text-2xl font-semibold',
p: 'mb-3',
codeInline: 'bg-slate-100 px-1 rounded',
}}
>{`
# Markdown email
Paragraph with \`code\`
`}</Markdown>
</Body>
</Tailwind>
</Html>,
)markdownCustomStyles and markdownContainerStyles are still available in this mode if you want to mix class-based and inline overrides.
Live Preview
@hono-email/preview provides a live development server and CLI to preview your email templates in a web browser with real-time interactive props editing.
Installation
Install the preview package as a development dependency:
npm i -D @hono-email/previewRunning the Preview Server
Start the preview server with the hono-email CLI's preview command:
npx hono-email preview --dir ./emailsIf you are using Bun, you can run:
bunx hono-email preview --dir ./emailsOptions:
-d, --dir <path>: The directory to search for email templates recursively (defaults to./emails).-p, --port <port>: The port to run the server on (defaults to3000).
Interactive Props Schema
To enable structured props editing in the preview UI, export a previewProps configuration object alongside your default-exported email template component.
import type { PreviewPropsConfig } from '@hono-email/preview'
import { Html, Body, Container, Heading, Text } from 'hono-email'
export const previewProps = {
name: { type: 'string', default: 'Taro' },
appName: { type: 'string', default: 'Acme' },
trialDays: { type: 'number', default: 14 },
} satisfies PreviewPropsConfig
type WelcomeEmailProps = {
name: string
appName: string
trialDays: number
}
export default function WelcomeEmail({ name, appName, trialDays }: WelcomeEmailProps) {
return (
<Html>
<Body>
<Container>
<Heading>
Welcome to {appName}, {name}!
</Heading>
<Text>You have {trialDays} days remaining in your free trial.</Text>
</Container>
</Body>
</Html>
)
}Supported Field Types
The previewProps object maps prop names to field definitions. The following configurations are supported:
| Property Type | Description | Schema Properties |
| ------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- |
| 'string' | Single-line or multi-line text input | default, required, multiline: boolean |
| 'number' | Number input field | default, required |
| 'boolean' | Toggle switch or checkbox | default, required |
| 'select' | Dropdown choice selection | default, required, options: string[] |
| 'array' | Dynamic list editor. Renders object fields if item is provided, otherwise falls back to a list of strings. | default, required, item: PreviewPropsConfig |
Example of an array prop schema:
export const previewProps = {
items: {
type: 'array',
item: {
name: { type: 'string' },
qty: { type: 'number' },
},
default: [{ name: 'Widget', qty: 1 }],
},
} satisfies PreviewPropsConfigDevelopment
bun i
bun run build
bun run test
bun run typecheckLocal CI Execution
To run the full CI check (format, lint, typecheck, test, build) locally using actrun, run:
mise run ciCredits
This project is inspired by react-email and jsx-email. Thanks to everyone involved in these projects.
