Send email from Hono
Hono runs on runtimes with very different ideas about environment variables, so the one thing to get right is reading the key from c.env rather than process.env. This page gives you the typed app, validation with zod, and the webhook route.
Setup
- 01
Create a key
https://emails.sh/dashboard issues a key starting with esh_.
- 02
Type your bindings
new Hono<{ Bindings: Env }>() makes c.env.EMAILSSH_API_KEY typed and portable, so the same file works on Workers and on Node.
- 03
Put the key where the runtime looks
.dev.vars on Workers, .env with node --env-file or bun on Node and Bun. On Node, adapt with env(c) from hono/adapter if you want one code path.
- 04
Construct the client per request
On Workers, c.env only exists inside a handler, so a module scope client cannot read the key. Building it in the handler is cheap because the SDK is fetch based.
npm install hono @emails.sh/sdk zod @hono/zod-validatorEMAILSSH_API_KEY=esh_your_key_here
EMAILSSH_FROM="Acme <hello@acme.com>"
EMAILSSH_WEBHOOK_SECRET=whsec_your_secret_hereThe app
src/index.ts (Hono 4)
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { Emailssh } from '@emails.sh/sdk';
type Bindings = {
EMAILSSH_API_KEY: string;
EMAILSSH_FROM: string;
};
const app = new Hono<{ Bindings: Bindings }>();
const signup = z.object({
email: z.string().email(),
name: z.string().max(100).optional()
});
app.post('/send', zValidator('json', signup), async (c) => {
const { email, name = 'there' } = c.req.valid('json');
// c.env is per request on Workers, so the client is built here rather than
// at module scope. It is a thin fetch wrapper, so this costs nothing.
const mail = new Emailssh({ apiKey: c.env.EMAILSSH_API_KEY });
const sent = await mail.send({
from: c.env.EMAILSSH_FROM,
to: [email],
subject: 'Welcome to Acme',
html: `<p>Hi ${escapeHtml(name)}, your Acme account is ready.</p>`,
text: `Hi ${name}, your Acme account is ready.`,
idempotencyKey: `welcome:${email.toLowerCase()}`
});
return c.json({ id: sent.id, status: sent.status });
});
function escapeHtml(value: string) {
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
}
export default app;The webhook route
src/webhooks.ts, mounted with app.route("/", webhooks)
import { Hono } from 'hono';
type Bindings = { EMAILSSH_WEBHOOK_SECRET: string };
export const webhooks = new Hono<{ Bindings: Bindings }>();
webhooks.post('/webhooks/emailssh', async (c) => {
const signature = c.req.header('x-emailssh-signature') ?? '';
const raw = await c.req.text();
// WebCrypto rather than node:crypto, so this runs on every runtime Hono
// supports without a polyfill.
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(c.env.EMAILSSH_WEBHOOK_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const digest = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(raw));
const expected = [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
if (expected !== signature) return c.json({ error: 'bad signature' }, 401);
const event = JSON.parse(raw) as { type: string; data: { id: string; to: string[] } };
if (event.type === 'email.bounced' || event.type === 'email.complained') {
console.warn('Suppress', event.data.to[0], 'after', event.type);
}
return c.body(null, 204);
});Worth knowing
process.env does not exist on Workers
c.env is the portable way to read configuration in Hono. If you need one file that also works under Node, use env(c) from hono/adapter, which reads process.env there and bindings on Workers.
Module scope cannot see bindings
On Workers, bindings are attached to the request, so a client constructed at import time reads undefined. Build it inside the handler.
Use c.req.text() for the webhook, not c.req.json()
The signature covers the exact bytes. Parse the JSON yourself from the string you verified.
Do not reach for node:crypto if you deploy to Workers
It only works with nodejs_compat enabled. crypto.subtle is available everywhere Hono runs, which is why the webhook route above uses it.
Questions
How do I send email from a Hono app?
mail.send from the SDK inside a handler, with the client built from c.env.EMAILSSH_API_KEY. The same handler runs on Workers, Bun, Deno, and Node.
Where do I put the API key for local development?
.dev.vars if you run wrangler dev, .env if you run under Node or Bun. Both surface through c.env.
Can I use nodemailer with Hono?
Only under Node. Workers and Deno Deploy have no raw TCP for SMTP, so an HTTP API is the only option if you want the app to stay portable.
The rest of the API
POST /v1/emails sends one and POST /v1/emails/batch sends up to 100 in a call. GET /v1/emails/:id returns the delivery events for a message, which is how you answer "did it arrive" without a support ticket. Webhooks push the same events to you as they happen.