hono-email
v0.3.2
Published
A lightweight, type-safe email template engine for Hono, powered by Hono JSX.
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
- Style markdown content with the
Markdowncomponent - Use
hono/cssclass-based CSS-in-JS as a styling option - Apply Tailwind utility output through
Tailwindbuild artifacts - Expose bundler integrations through
hono-email/plugin
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 and plain text as
{ html, text } - Uses
strict: trueby default - Accepts
doctype: 'html5' | 'xhtml-transitional' | false - 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',
path: './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,path,href,ReadableStream,encoding, inlinecid, and explicitcontentType.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.
Cloudflare Email Service
hono-email/cloudflare-email 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-email'
import WorkersConnector from 'hono-email/cloudflare-email/cloudflare'
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-email'
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
HtmlHeadBodyContainerSectionRowColumnTextHeadingButtonLinkImgPreviewHrFontTailwindMarkdown
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, andml
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
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 { vitePlugin as EmailTailwind } from 'hono-email/plugin'
export default defineConfig({
plugins: [tailwindcss(), EmailTailwind()],
})For CommonJS Webpack config files, use the named webpackPlugin export.
// webpack.config.cjs
const { webpackPlugin: EmailTailwind } = require('hono-email/plugin')
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>,
)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.
Development
bun i
bun run build
bun run test
bun run typecheckCredits
This project is inspired by react-email and jsx-email. Thanks to everyone involved in these projects.
