Node.js 20: odeslání e-mailu přes fetch nebo Nodemailer a ověření webhooku v Expressu
Praktické příklady pro Node.js 20 bez zbytečných závislostí: odeslání přes API nativním fetch s Idempotency-Key a zpracováním chyb, Nodemailer transport na smtp.topemail.cz, a Express endpoint pro webhook email.status s ověřením HMAC podpisu přes crypto.timingSafeEqual.
Node.js 20 má nativní fetch, takže k odeslání e-mailu přes REST API nepotřebujete žádnou knihovnu. Pokud už aplikace posílá přes Nodemailer, stačí vyměnit transport za SMTP relay. A protože stav doručení chcete zpátky, třetí část článku ukazuje Express endpoint pro webhook s ověřením podpisu tak, jak má být: z nezparsovaného těla a v konstantním čase. Všechny příklady jsou ESM, spustitelné bez TypeScriptu.
Odeslání přes API nativním fetch
// topemail.js
const API = 'https://topemail.cz/api/email/send'
export class TopEmailError extends Error {
constructor(code, message, status) {
super(`${code}: ${message}`)
this.code = code
this.status = status
}
}
export async function sendEmail(payload, { idempotencyKey } = {}) {
const headers = {
'Authorization': `Bearer ${process.env.TOPEMAIL_KEY}`, // "CLIENT_ID:SECRET"
'Content-Type': 'application/json',
}
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey
const res = await fetch(API, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: AbortSignal.timeout(15_000),
})
const data = await res.json()
if (!res.ok) throw new TopEmailError(data.code ?? 'unknown', data.error ?? 'Unknown error', res.status)
return data // { ok, emails: [{ id, to, status }], price }
}
Použití při potvrzení objednávky:
import { sendEmail, TopEmailError } from './topemail.js'
try {
const result = await sendEmail({
from: 'Můj e-shop <objednavky@muj-eshop.cz>',
to: [order.email],
subject: `Potvrzení objednávky ${order.code}`,
html: renderConfirmation(order),
text: renderConfirmationText(order),
replyTo: 'podpora@muj-eshop.cz',
tags: { order: order.code, type: 'confirm' },
}, { idempotencyKey: `order-${order.code}-confirm` })
await db.orders.update(order.id, { confirmEmailId: result.emails[0].id })
} catch (err) {
if (err instanceof TopEmailError && err.code === 'invalid_recipient') {
await db.orders.flagBadEmail(order.id) // neopakovat
} else if (err instanceof TopEmailError && err.status < 500 && err.status !== 429) {
logger.error(err) // chyba konfigurace, opravit
} else {
await queue.retry('send-confirmation', order.id) // síť, 429, 5xx: opakovat se stejným klíčem
}
}
Dvě věci, které příklad dělá správně a kopie z internetu často ne:
Idempotency-Keyje odvozený z objednávky, ne náhodný. Když worker spadne po odeslání a před zápisem do DB, opakovaný pokus e-mail nepošle znovu (klíč platí 24 hodin).toje pole, i pro jednoho příjemce. Můžete poslat až 50 adres, každá dostane vlastní zprávu a vlastníidvemails[].
Přílohu přidáte jako attachments: [{ filename, content: buffer.toString('base64'), contentType }], celkem do 10 MB.
Odeslání přes SMTP Nodemailerem
Pokud už máte Nodemailer, změna je jen v transportu:
import nodemailer from 'nodemailer'
const transport = nodemailer.createTransport({
host: 'smtp.topemail.cz',
port: 587,
secure: false, // STARTTLS na 587; pro 465 dejte port: 465, secure: true
requireTLS: true,
auth: { user: process.env.TOPEMAIL_CLIENT_ID, pass: process.env.TOPEMAIL_SECRET },
pool: true, // drží spojení, při více e-mailech za sebou výrazně rychlejší
})
const info = await transport.sendMail({
from: 'Můj e-shop <objednavky@muj-eshop.cz>',
to: order.email,
replyTo: 'podpora@muj-eshop.cz',
subject: `Potvrzení objednávky ${order.code}`,
text: renderConfirmationText(order),
html: renderConfirmation(order),
headers: {
'X-TopEmail-Tag-order': order.code,
'X-TopEmail-Tag-type': 'confirm',
},
})
console.log(info.response) // "250 2.0.0 Queued as <id>"
Značky předáváte hlavičkami X-TopEmail-Tag-<klíč> a ve webhooku se objeví jako tags, stejně jako z API. Relay v odpovědi 250 2.0.0 Queued as <id> vrací ID e-mailu, které z info.response vytáhnete regulárním výrazem, pokud ho chcete uložit. pool: true je důležité: bez něj Nodemailer otevírá nové TLS spojení na každou zprávu.
Idempotence u SMTP: relay použije Message-ID zprávy jako idempotenční klíč. Pokud mu dáte vlastní, stabilní messageId (<order-1042-confirm@muj-eshop.cz>), opakovaný pokus se stejným ID e-mail nepošle znovu.
Webhook email.status v Expressu
Při každé změně stavu pošleme POST s tělem jako
{
"event": "email.status",
"id": "…",
"to": "zakaznik@example.com",
"status": "delivered",
"detail": null,
"tags": { "order": "2026-1042", "type": "confirm" },
"occurredAt": "2026-10-26T09:41:07Z"
}
a hlavičkou X-TopEmail-Signature: sha256=<HMAC-SHA256 těla s webhook secretem>. Secret najdete v dashboardu u nastavení webhooku.
import express from 'express'
import { createHmac, timingSafeEqual } from 'node:crypto'
const app = express()
function verifySignature(rawBody, header) {
const expected = 'sha256=' + createHmac('sha256', process.env.TOPEMAIL_WEBHOOK_SECRET)
.update(rawBody)
.digest('hex')
const a = Buffer.from(header ?? '')
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
app.post('/webhooks/topemail',
express.raw({ type: 'application/json', limit: '64kb' }), // Buffer, ne objekt
async (req, res) => {
if (!verifySignature(req.body, req.get('X-TopEmail-Signature'))) {
return res.status(401).end()
}
const event = JSON.parse(req.body.toString('utf8'))
await queue.add('email-status', event) // zpracovat na pozadí
res.status(200).end() // odpovědět do 10 s
})
app.listen(3000)
Tři pravidla, která endpoint dodržuje:
express.raw()místoexpress.json()na této trase. Podpis se počítá z bajtů, které jsme poslali; po naparsování a znovu serializování se změní mezery a pořadí klíčů a podpis nesedí. Pokud máteapp.use(express.json())globálně, zaregistrujte webhook trasu před ním.timingSafeEquals kontrolou délky předem (jinak funkce vyhodí výjimku při různě dlouhých bufferech).- Rychlá odpověď, zpracování na pozadí. Při 5xx nebo timeoutu doručení opakujeme, takže handler musí být idempotentní: ukládejte události s unikátním klíčem
(id, status, occurredAt)a duplicity ignorujte. Podrobně v článku o webhookách.
Ve zpracování na pozadí pak reagujte na bounced (adresa je špatná, zobrazit zákazníkovi výzvu k opravě), complained (zkontrolovat obsah) a delivered (označit v objednávce). Stavy deferred a sent jsou jen informativní.
Stav e-mailu na vyžádání
const res = await fetch(`https://topemail.cz/api/email/${id}`, {
headers: { Authorization: `Bearer ${process.env.TOPEMAIL_KEY}` },
})
const email = await res.json()
// email.status, email.statusDetail, email.events[]
Hodí se pro podporu („zákazník říká, že nic nepřišlo"), ne pro polling v cyklu; na to je webhook.
Testování lokálně
Webhook na localhost nedorazí. Použijte tunel (ngrok, Cloudflare Tunnel), nastavte veřejnou URL v dashboardu a pošlete si testovací e-mail. Pro jednotkový test ověření podpisu si vyrobte podpis sami:
import { createHmac } from 'node:crypto'
const body = JSON.stringify({ event: 'email.status', id: 'test', status: 'delivered' })
const sig = 'sha256=' + createHmac('sha256', 'test-secret').update(body).digest('hex')
// POST body s hlavičkou X-TopEmail-Signature: sig
Shrnutí
fetch + Idempotency-Key + chyby podle code pro nový kód; Nodemailer s pool: true a hlavičkami X-TopEmail-Tag-* pro stávající; Express s express.raw() a timingSafeEqual pro webhook. Dohromady asi 80 řádků, které pokryjí odeslání i zpětnou vazbu. Pole, stavy a limity jsou v dokumentaci API a v OpenAPI specifikaci; cena je 0,06 Kč za e-mail bez paušálu.
Transakční e-maily z vaší domény přes API nebo SMTP, od 0,025 do 0,06 Kč za e-mail podle objemu, prvních 1 000 e-mailů měsíčně zdarma. Registrace zdarma, bez platební karty.
Registrovat zdarma →