CeníkAPI & SMTPŘešeníSrovnáníNástrojeBlogFAQKontakt
PřihlásitRegistrovat
Tmavý režim
← Zpět na blog
26. října 2026·4 min čtení·Tým TopEmail

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-Key je 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).
  • to je pole, i pro jednoho příjemce. Můžete poslat až 50 adres, každá dostane vlastní zprávu a vlastní id v emails[].

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:

  1. express.raw() místo express.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áte app.use(express.json()) globálně, zaregistrujte webhook trasu před ním.
  2. timingSafeEqual s kontrolou délky předem (jinak funkce vyhodí výjimku při různě dlouhých bufferech).
  3. 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.

Node.jsapiSMTPwebhookyintegracenávod
Transakční e-maily z vaší domény

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 →