namailu.cz
EN

Založit e-mail   Co všechno umíme

Developer / E-mail API

Agentní e-mail API

Bezpečné REST rozhraní pro agentní schránky: odešli notifikaci, načti příchozí zprávu nebo si nech zavolat webhook.

Base URLhttps://api.namailu.cz

Verze /v1 · autentizace Bearer tokenem · JSON

Začít integraci

Nejprve vytvoř přístup, ne infrastrukturu.

Agent nepoužívá heslo schránky, SMTP ani IMAP. Otevři jeho detail ve Schránkách, v části API a MCP vytvoř vlastní schránkový token a ulož ho do secrets manageru. Přiřazený člen spravuje jen svého agenta; provisioning klíče pro celou organizaci (tenant-wide) zůstávají pouze ownerovi a adminovi.

První request · bez tokenu

Ověř, že API odpovídá

Healthcheck nic nemění. Vrací stav API i připojení databáze; je to správný první krok před vytvořením tokenu nebo schránky.

GET /health
curl --fail-with-body https://api.namailu.cz/health
# {"status":"ok","db":true}
Doporučené rozdělení: běžný agent má token omezený na jednu schránku. tenant-wide token (pro celou organizaci) patří pouze centrální provisioningové službě: ta může vytvářet schránky, ale sama nepotřebuje vlastní e-mailovou schránku ani z ní nemusí odesílat poštu. Vytvořený agent potom pracuje vlastním schránkovým tokenem.
  1. 1
    Vytvoř agentní schránkuNapříklad notifikace@….
  2. 2
    Vytvoř schránkový tokenHodnota se zobrazí jen jednou.
  3. 3
    Nastav allowlistBez něj nepůjde nic odeslat.
Terminál
export API_BASE='https://api.namailu.cz'
export NAMAILU_API_KEY='dmk_live_…'
export INBOX_ID='uuid-nebo-adresa-schranky'

curl --fail-with-body "$API_BASE/v1/inboxes" \
  -H "Authorization: Bearer $NAMAILU_API_KEY"

Provisioning

Jak centrálně založit schránku a předat ji agentovi.

Centrální provisioningová služba může běžet bez vlastní e-mailové schránky. Drží pouze tenant-wide token se scope inbox a jím založí schránku i jednorázově vydá její token. Tento silný token patří jen do jejího secrets manageru — nikdy do konfigurace běžného agenta.

Co je chráněné: schránkový token neumí zakládat další schránky. Vytváření přes API prochází stejnými limity tarifu, počtem agentů, doménami a pravidly sdílené domény jako portál. Nelze tím obejít plán ani vytvořit neomezený počet schránek.
  1. 1
    Založ agentní schránkuNa sdílené doméně pošli name; na vlastní aktivní doméně použij address.
  2. 2
    Vydej jí vlastní tokenOdpověď s token se ukáže právě jednou.
  3. 3
    Předej jen token a ID schránkyAgent pak pracuje s nejmenším nutným oprávněním.

1. Vytvoření schránky přes API

Pro doménu nabízenou namailu pošli name. Pro svou už ověřenou a aktivní doménu místo něj pošli úplnou address.

POST /v1/inboxes
export API_BASE='https://api.namailu.cz'
# Pouze v centrální provisioningové službě, nikdy u běžného agenta:
export PROVISIONING_KEY='dmk_live_…'

curl --fail-with-body -X POST "$API_BASE/v1/inboxes" \
  -H "Authorization: Bearer $PROVISIONING_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"name":"invoice-agent"}'
# 201: {"id":"c2f…","address":"invoice-agent@…",…}

# Vlastní aktivní doména:
# --data '{"address":"invoice-agent@firma.cz"}'

Z odpovědi 201 si ulož id. V terminálu například: INBOX_ID=$(… | jq -r '.id').

2. Získání tokenu pro právě vytvořenou schránku

Stále použij provisioningový token a ID z prvního kroku. Endpoint vrátí nový schránkový token v poli token pouze v této jediné odpovědi.

POST /v1/inboxes/{inbox_id}/keys
export INBOX_ID='c2f…'  # id z POST /v1/inboxes

curl --fail-with-body -X POST "$API_BASE/v1/inboxes/$INBOX_ID/keys" \
  -H "Authorization: Bearer $PROVISIONING_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"label":"invoice-agent production"}'
# 201: {"token":"dmk_live_…","inbox_id":"c2f…",
#       "address":"invoice-agent@…","scopes":["inbox","send"]}

Ulož hodnotu token jako NAMAILU_API_KEY a inbox_id jako NAMAILU_INBOX. Nejdřív tomuto tokenu nastav allowlist v policy a teprve potom odesílej. Jestli už schránka aktivní token má, endpoint vrátí 403; nevytvářej druhý, ale proveď řízenou rotaci v portálu.

Bezvýpadková výměna tokenu

Rotaci zahajuje pouze owner/admin v dashboardu po čerstvém 2FA. Starý token dál funguje, zatímco nový čeká nejvýše 30 minut na převzetí. Agentní token nemůže rotaci sám zahájit, takže při kompromitaci nedokáže odříznout vlastníka.

Potvrzení novým tokenem
export NEW_NAMAILU_API_KEY='dmk_live_…'

curl --fail-with-body -X POST \
  "$API_BASE/v1/key-rotation/activate" \
  -H "Authorization: Bearer $NEW_NAMAILU_API_KEY"
# 200: {"status":"activated","key_prefix":"dmk_live_…",
#       "replaced_prefix":"dmk_live_…","inbox_id":"…"}
Atomický cutover: až úspěšné ověření nového tokenu aktivuje náhradu a ve stejné transakci revokuje starý. Pokud je u klíče povinný device seed, proveď nejprve enrollment a aktivační request podepiš novým seedem.

Referenční přehled

Endpointy, které může agent volat.

Vše kromě GET /health vyžaduje hlavičku Authorization: Bearer …. {inbox_id} je UUID schránky nebo její URL-encoded e-mailová adresa. Klíč omezený na schránku pracuje pouze s ní; tenant-wide klíč vidí všechny agentní schránky organizace.

GET/health

Stav služby a databáze. Bez tokenu.

200
GET/v1/inboxes

Vypíše dostupné agentní schránky.

token
POST/v1/inboxes

Vytvoří agentní schránku; JSON name nebo address.

pro celou organizaci · 201
POST/v1/inboxes/{inbox_id}/keys

Jednou vydá schránkový token novému agentovi.

pro celou organizaci · 201
POST/v1/key-rotation/activate

Potvrdí dashboardem připravenou náhradu a atomicky revokuje starý token.

jen nový pending token
GET/v1/inboxes/{inbox_id}

Vrátí adresu, stav a datum vytvoření schránky.

token
GET/v1/inboxes/{inbox_id}/messages

Metadata zpráv ve složce; query folder, limit, unread, since.

limit 1–200
GET/v1/messages/{message_id}

Detail zprávy; předmět a tělo jsou výhradně v untrusted_content.

token
GET/v1/messages/{message_id}/attachments/{attachment_id}

Stáhne binární přílohu podle download_url z detailu.

token · binární tělo
GET/v1/messages/{message_id}/raw

Stáhne původní .eml, například pro lokální OpenPGP dešifrování.

token · message/rfc822
POST/v1/messages/{message_id}/archive

Přesune zprávu do Archivu.

token · inbox
POST/v1/messages/{message_id}/trash

Přesune zprávu do Koše; lze obnovit.

token · inbox
POST/v1/messages/{message_id}/restore

Vrátí zprávu z Koše do Doručené.

token · inbox
DELETE/v1/messages/{message_id}

Trvale smaže zprávu, která je v Koši.

token · 204
DELETE/v1/inboxes/{inbox_id}/trash

Trvale vysype celý Koš schránky.

token
POST/v1/inboxes/{inbox_id}/send

Odešle plain text/HTML a volitelně přílohy; podporuje jednoho i placený batch příjemců.

aktivní schránka
PUT/v1/inboxes/{inbox_id}/policy

Nastaví allowlist, hodinový override nebo webhook.

token
POST/v1/inboxes/{inbox_id}/webhook-secret/rotate

Jednorázově vrátí nový webhook secret; volitelný overlap je 0–900 s.

token · inbox
POST/v1/inboxes/{inbox_id}/disable

Zakáže přístup schránky.

token
POST/v1/inboxes/{inbox_id}/enable

Znovu povolí zakázanou schránku.

token
POST/v1/device/enroll

Jednorázově naváže Ed25519 public key z lokálního seedu na nový nebo přesunutý klíč.

Bearer + enrollment
Strojově čitelný kontrakt: aktuální OpenAPI schema je na GET /openapi.json. Pro integraci se řiď stabilním error.code, ne anglickým textem chyby.

Odeslání

Odesílej jen tam, kam máš povoleno.

Prázdný allowlist znamená deny-all. finance@firma.cz povolí jen jednu konkrétní adresu; partner.cz povolí jakoukoli adresu na této doméně. Zápis @partner.cz znamená totéž. Celé domény veřejných freemailů a velkých providerů jsou záměrně zakázané — například gmail.com, seznam.cz, outlook.com a podobné. U nich vždy zapiš konkrétní adresu. Při změně se seznam nahradí celý, proto vždy pošli kompletní hodnotu.

Nastavení policy
curl --fail-with-body -X PUT "$API_BASE/v1/inboxes/$INBOX_ID/policy" \
  -H "Authorization: Bearer $NAMAILU_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"send_allowlist":["finance@firma.cz","partner.cz"],"rate_limit_per_hour":0}'

rate_limit_per_hour: 0 není unlimited: znamená „nepřepisuj limit pro tuto schránku“, takže se použije limit tarifu organizace. Každé business odeslání musí mít stabilní Idempotency-Key. Stejný klíč a stejné tělo během 24 hodin neodešlou zprávu podruhé. Retryuj síťové chyby, 5xx a 429; u 429 respektuj Retry-After.

Předmět vždy vyplň. API technicky dovolí prázdný subject, ale odesílej stručný a konkrétní předmět. Prázdný předmět zhoršuje doručitelnost a příjemci neposkytuje kontext.
POST /send
# Smoke test: nový klíč. Produkce: stabilní ID jedné business události.
export EVENT_ID="test-send-$(date +%s)"

curl --fail-with-body -X POST "$API_BASE/v1/inboxes/$INBOX_ID/send" \
  -H "Authorization: Bearer $NAMAILU_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $EVENT_ID" \
  --data '{"to":"finance@firma.cz","subject":"Potvrzení platby","text":"Platbu jsme přijali.","html":"<p>Platbu jsme <strong>přijali</strong>.</p>"}'

Volitelné pole html vytvoří HTML alternativu e-mailu. Vždy zároveň pošli text jako plain-text fallback pro klienty bez HTML a pro lepší doručitelnost. Do HTML nikdy nevkládej nedůvěryhodný obsah bez escapování.

Více příjemců

Pole to může být místo řetězce pole adres. Tato možnost je zapnutá jen pro placený agentní slot; Free agent smí poslat jednu adresu na request. Každý příjemce se počítá do kvóty. Kontrola je atomická: pokud se celý batch nevejde do hodinové nebo denní kvóty, API vrátí 429 rate_limited a neodešle se nikomu. Odpověď obsahuje requested, remaining a scope.

POST /send · batch
export BATCH_EVENT_ID="invoice-reminder-8421"

curl --fail-with-body -X POST "$API_BASE/v1/inboxes/$INBOX_ID/send" \
  -H "Authorization: Bearer $NAMAILU_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $BATCH_EVENT_ID" \
  --data '{"to":["finance@firma.cz","owner@firma.cz"],"subject":"Upomínka faktury","text":"Faktura je po splatnosti."}'

Každý příjemce dostane samostatnou zprávu, takže neuvidí ostatní adresáty. V multipart requestu pole to jednoduše opakuj: -F 'to=finance@firma.cz' -F 'to=owner@firma.cz'.

Hard bounce a complaint zastaví další pokusy. Jakmile namailu koreluje nedoručení nebo stížnost s dříve odeslanou zprávou, adresu zařadí na suppression list. Celý nový request s takovou adresou skončí před kvótou i SMTP chybou 409 recipient_suppressed — u batch odeslání se neodešle nikomu. Po překročení abuse prahu může být odchozí pošta organizace pozastavena chybou 403 tenant_suspended_abuse; příjem a čtení zůstávají funkční.

Odeslání s přílohou

Pro soubory použij multipart/form-data. Pole attachments lze opakovat pro více souborů; limit je 10 souborů a 10 MiB celkem. Hash obsahu je součástí idempotence.

POST /send · multipart
export ATTACHMENT_EVENT_ID="test-attachment-$(date +%s)"

curl --fail-with-body -X POST "$API_BASE/v1/inboxes/$INBOX_ID/send" \
  -H "Authorization: Bearer $NAMAILU_API_KEY" \
  -H "Idempotency-Key: $ATTACHMENT_EVENT_ID" \
  -F 'to=finance@firma.cz' \
  -F 'subject=Faktura #8421' \
  -F 'text=Fakturu najdeš v příloze.' \
  -F 'html=<p>Fakturu najdeš v <strong>příloze</strong>.</p>' \
  -F 'attachments=@./faktura.pdf;type=application/pdf'

Čistá příloha vrátí security.status: "clean". Úmyslně šifrovaný ZIP nebo formát, který server zatím neumí rozbalit (RAR/7z), se při odchozím odeslání neblokuje: zpráva dostane status: "sent", příloha stav unscannable_encrypted nebo unscannable_archive a odpověď obsahuje warning. Warning znamená „odesláno bez kontroly vnitřního obsahu“, nikoli chybu ani úspěšný antivirový scan. Malware, aktivní/spustitelný obsah, poškozený podporovaný archiv a nedostupný scanner se dál odmítají před SMTP.

Úspěch s nezkontrolovatelnou přílohou
{
  "status": "sent",
  "attachments": [{
    "filename": "podklady.7z",
    "content_type": "application/x-7z-compressed",
    "size": 184320,
    "security": {
      "status": "unscannable_archive",
      "reason_code": "archive_format_unsupported"
    }
  }],
  "warnings": [{
    "code": "attachment_unscannable",
    "message": "The attachment was sent, but its contents could not be inspected by the server."
  }]
}

Příjem pošty

Nejdřív metadata, pak detail.

Polling s unread=true je vhodný pro dávkové zpracování. Výpis používá folder=inbox jako výchozí složku; dostupné jsou také archive, trash, sent a spam. Odpověď vrátí next_cursor; pošli jej beze změny jako ?cursor=… pro další stránku se stejnými filtry. Cursor je vázaný na folder, limit, unread a since, drží bezpečný snapshot a platí 15 minut — tedy nepřeskočí zprávy, ani když mezitím stáhneš detail první stránky. Opakování stejného cursoru vrátí stejnou stránku. Výchozí limit je 50, maximum 200. Jedna dávka cursoru obsahuje nejvýše 10 000 zpráv; pro větší historii použij since. Na schránku běží vždy jen jeden aktivní cursor; nový výpis předchozí nahradí. Ukládej si i zpracované message.id, protože nové zprávy se do už vytvořeného snapshotu záměrně nepřidávají.

Chování jako IMAP: samotný výpis metadat zprávu nečte. Detail, příloha nebo raw ji ihned označí jako přečtenou. API úmyslně nemá set unread; pro retry a workflow si ukládej vlastní stav podle message.id.
Flow pro OpenPGP: už výpis metadat u šifrované zprávy obsahuje encryption.format a encryption.raw_download_url. Agent tedy nemusí nejdřív číst detail: pokud je format: "openpgp-pgp-mime", stáhne rovnou raw_download_url, PGP/MIME rozbalí a dešifruje lokálně. Detail používej jen tehdy, když potřebuješ běžná metadata nebo nešifrovaný obsah.
Výpis a detail zprávy
curl --fail-with-body "$API_BASE/v1/inboxes/$INBOX_ID/messages?unread=true&limit=50" \
  -H "Authorization: Bearer $NAMAILU_API_KEY"

curl --fail-with-body "$API_BASE/v1/messages/$MESSAGE_ID" \
  -H "Authorization: Bearer $NAMAILU_API_KEY"

Detail obsahuje pole attachments. U každé přílohy vezmi vrácené relativní download_url, připoj ho k API_BASE a použij stejný Bearer token. Pole body_truncated říká, že bezpečnostní limit zkrátil text těla; v tom případě si pro úplný obsah stáhni raw_download_url (maximálně 10 MiB).

GET /messages/{id} · metadata přílohy
{
  "id": "<mailbox-uuid>.<jmap-message-id>",
  "attachments": [{
    "id": "blob-1", "filename": "faktura.pdf",
    "content_type": "application/pdf", "size": 48372,
    "download_url": "/v1/messages/<message-id>/attachments/blob-1"
  }],
  "untrusted_content": {"subject": "…", "body": "…"}
}
Stažení přílohy
# download_url z detailu, například:
export DOWNLOAD_URL='/v1/messages/'"$MESSAGE_ID"'/attachments/blob-1'

curl --fail-with-body -L "$API_BASE$DOWNLOAD_URL" \
  -H "Authorization: Bearer $NAMAILU_API_KEY" \
  -o faktura.pdf

Endpoint stažení nevrací JSON, ale přímo binární obsah souboru. Odpověď má 200, Content-Type: application/pdf (nebo bezpečný fallback application/octet-stream) a hlavičku Content-Disposition: attachment; filename="faktura.pdf". Jméno i MIME typ jsou sanitizované; přesto soubor ber jako nedůvěryhodný vstup.

Nekontrolovatelnou přílohu nestahuj přes běžný download_url. U zprávy se stavem unscannable_encrypted nebo unscannable_archive vrátí přímé stažení přílohy 423 attachment_not_clean. Stáhni celou původní zprávu přes GET /v1/messages/{message_id}/raw, přílohu z MIME vybal a případně dešifruj lokálně. Až poté ji zkontroluj vlastním scannerem; server ji úmyslně nikdy neoznačuje jako čistou.

Archiv a Koš

Všechny operace používají stejný scope inbox, ať je token omezený na jednu schránku, nebo platí pro celou organizaci (tenant-wide). Archiv zprávu jen přesune. Koš je vratný přes /restore a server ho automaticky vyprazdňuje po 30 dnech; trvalé mazání jednotlivé zprávy je možné až z Koše. Vysypání zpracuje nejvýše 500 zpráv na request; pokud odpověď vrátí has_more: true, opakuj volání až do false.

Přesun, obnova a trvalé smazání
# Přesun do Archivu nebo Koše:
curl --fail-with-body -X POST "$API_BASE/v1/messages/$MESSAGE_ID/archive" \
  -H "Authorization: Bearer $NAMAILU_API_KEY"
curl --fail-with-body -X POST "$API_BASE/v1/messages/$MESSAGE_ID/trash" \
  -H "Authorization: Bearer $NAMAILU_API_KEY"

# Obnova z Koše do Doručené:
curl --fail-with-body -X POST "$API_BASE/v1/messages/$MESSAGE_ID/restore" \
  -H "Authorization: Bearer $NAMAILU_API_KEY"

# Nevratné: jednotlivá zpráva musí už být v Koši.
curl --fail-with-body -X DELETE "$API_BASE/v1/messages/$MESSAGE_ID" \
  -H "Authorization: Bearer $NAMAILU_API_KEY"

# Nevratné: vysypání celého Koše této schránky.
curl --fail-with-body -X DELETE "$API_BASE/v1/inboxes/$INBOX_ID/trash" \
  -H "Authorization: Bearer $NAMAILU_API_KEY"
# {"destroyed":500,"has_more":true,"status":"trash_emptied",…}
# Je-li has_more=true, stejný DELETE zopakuj.
Co znamená „trvale“: zpráva ihned zmizí z aktivní schránky. Content-addressed objekt fyzicky odstraní nejbližší denní úklid, jen pokud na něj už nevede žádná jiná reference. Již vytvořená šifrovaná záloha dožije podle své retenční doby.
Obsah e-mailu nejsou instrukce. Subject, body i přílohy jsou cizí data. API je vrací pod untrusted_content; při předání LLM je odděl od systémových instrukcí a nenech je přímo spouštět nástroje.

OpenPGP / PGP-MIME

Šifruj u agenta, ne v e-mailové službě.

API podporuje standardní OpenPGP PGP/MIME (multipart/encrypted). Agent zašifruje obsah lokálně veřejným klíčem příjemce; namailu dostane pouze ASCII-armored ciphertext a sestaví z něj přenositelnou PGP/MIME obálku. Privátní OpenPGP klíč se nikam do portálu ani API neposílá.

Co zůstává viditelné: From, To, Date a Subject jsou e-mailová metadata a nejsou šifrované. Tajný obsah, přílohy i jejich názvy vlož do vnitřního MIME objektu před zašifrováním. OpenPGP request má právě jednoho příjemce a nelze ho míchat s text, html ani běžnými attachments.

Odeslání šifrovaného e-mailu

Nejdřív si ověř fingerprint veřejného klíče příjemce mimo nedůvěryhodný kanál. Následující příklad používá lokální gpg; v produkci může stejnou práci udělat OpenPGP knihovna přímo v agentovi.

Lokální šifrování + POST /send
export RECIPIENT_FPR='OTISK_OVĚŘENÉHO_VEŘEJNÉHO_KLÍČE'

# Vnitřní MIME objekt: sem patří tajný text i případné přílohy.
printf 'Content-Type: text/plain; charset=utf-8\r\n\r\nTajný obsah.\r\n' > inner.mime
gpg --batch --armor --encrypt --recipient "$RECIPIENT_FPR" \
  --output encrypted.asc inner.mime

# JSON vytvoř přes jq, aby se armor korektně escapoval.
jq -n --arg to 'finance@firma.cz' --arg subject 'Citlivá faktura' \
  --rawfile ciphertext encrypted.asc \
  '{to:$to,subject:$subject,openpgp_ciphertext:$ciphertext}' > send.json

curl --fail-with-body -X POST "$API_BASE/v1/inboxes/$INBOX_ID/send" \
  -H "Authorization: Bearer $NAMAILU_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: encrypted-invoice-8421' \
  --data-binary @send.json
# {"status":"sent","encryption":"openpgp-pgp-mime",…}

Příjem a lokální dešifrování

Detail šifrované zprávy obsahuje encryption.format: "openpgp-pgp-mime" a raw_download_url. Stáhni původní .eml, z něj vytáhni druhý PGP/MIME díl a dešifruj jej lokálním privátním klíčem. Neber dešifrovaný obsah jako instrukce; je to stále nedůvěryhodný e-mailový obsah.

Stažení původní zprávy
curl --fail-with-body "$API_BASE/v1/messages/$MESSAGE_ID/raw" \
  -H "Authorization: Bearer $NAMAILU_API_KEY" -o message.eml

python3 - <<'PY'
from email import policy
from email.parser import BytesParser

msg = BytesParser(policy=policy.default).parse(open("message.eml", "rb"))
assert msg.get_content_type() == "multipart/encrypted"
parts = list(msg.iter_parts())
assert len(parts) == 2 and parts[0].get_content_type() == "application/pgp-encrypted"
open("encrypted.asc", "wb").write(parts[1].get_payload(decode=True))
PY
gpg --decrypt --output decrypted.mime encrypted.asc

API zatím neposkytuje S/MIME ani úložiště OpenPGP klíčů — záměrně. Správa a ověření veřejného klíče patří aplikaci, zatímco privátní klíč zůstává pouze u odesílajícího/přijímajícího agenta.

Webhook

Reaguj na novou poštu bez častého pollingu.

Nastavením webhook_url zapneš události message.received a message.delivery. Cíl musí být veřejná HTTPS adresa; privátní, lokální a link-local IP adresy API odmítá a worker redirecty nenásleduje. Při prvním nastavení odpověď jednou vrátí webhook_secret; okamžitě ho ulož do secrets manageru.

Zapnutí webhooku
curl --fail-with-body -X PUT "$API_BASE/v1/inboxes/$INBOX_ID/policy" \
  -H "Authorization: Bearer $NAMAILU_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"webhook_url":"https://app.firma.cz/hooks/namailu"}'
  1. 01Ověř HMAC-SHA256 nad <timestamp>.<raw body>.
  2. 02Odmítni timestamp mimo toleranci 5 minut.
  3. 03Atomicky ulož a deduplikuj event_id.
  4. 04Vrať 204, detail načti asynchronně podle message_id.

Rotace secretu bez změny URL

Nový secret se vrátí právě jednou. Výchozí overlap je 300 sekund a smí být nejvýše 900 sekund: retry vytvořené se starou generací se do konce okna podepíší starým secretem, nové eventy novým. Při overlap_seconds: 0 se čekající retry atomicky převedou na nový secret. Další rotaci lze provést nejdříve za 60 sekund.

POST /webhook-secret/rotate
curl --fail-with-body -X POST \
  "$API_BASE/v1/inboxes/$INBOX_ID/webhook-secret/rotate" \
  -H "Authorization: Bearer $NAMAILU_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"overlap_seconds":300}'
# {"webhook_secret":"whsec_…","generation":2,
#  "previous_valid_until":"2026-07-28T12:05:00+00:00",…}
Incident: při podezření na únik použij overlap_seconds: 0, nový secret atomicky ulož do receiveru a starý odstraň. Při plánované rotaci nejprve nastav receiver tak, aby po krátkou dobu přijímal oba secrety, zavolej endpoint, nasaď nový secret a po previous_valid_until starý odstraň.

Worker kontroluje schránky zhruba po 30 sekundách. Neúspěšné doručení retryuje po 1 min, 5 min, 30 min a 2 h; po pátém pokusu je dead letter.

Doručitelnost

Rozlišuj přijetí k odeslání od výsledku doručení.

Úspěšný POST /send znamená, že SMTP server zprávu přijal k dalšímu doručení. Pozdější DSN nebo provider feedback namailu přijme jen tehdy, když přesně odpovídá uloženému Message-ID nebo envelope ID a příjemci; cizí nebo podvržený report stav nezmění.

message.delivery
{
  "event_id": "01J…",
  "event_type": "message.delivery",
  "timestamp": 1785233100,
  "inbox_id": "mailbox-uuid",
  "message_id": "<unique@sender.example>",
  "recipient": "customer@example.com",
  "delivery_status": "hard_bounce",
  "smtp_status": "5.1.1"
}

delivery_status je delivered, soft_bounce, hard_bounce nebo complaint. Event záměrně neobsahuje text diagnostiky od cizího serveru. Handler ověřuje stejný HMAC, timestamp a event_id jako u příchozí pošty.

Klienti

Odeslání e-mailu v Pythonu a Rustu.

Python

requests

Token se čte z NAMAILU_API_KEY a posílá jako Authorization: Bearer …. V produkci doplň retry na 429 a 5xx.

send.py
import os
import requests

event_id = os.environ["NAMAILU_EVENT_ID"]  # např. invoice-paid-
r = requests.post(
    f"https://api.namailu.cz/v1/inboxes/{os.environ['NAMAILU_INBOX']}/send",
    headers={"Authorization": f"Bearer {os.environ['NAMAILU_API_KEY']}",
             "Idempotency-Key": event_id},
    json={"to": "finance@firma.cz", "subject": "Potvrzení", "text": "Platba přijata.",
          "html": "<p>Platba <strong>přijata</strong>.</p>"},
    timeout=15,
)
r.raise_for_status()
print(r.json())

Rust

reqwest

.bearer_auth(...) vytvoří hlavičku Authorization: Bearer …. V Cargo.toml použij reqwest s feature json, tokio a serde_json.

main.rs
use reqwest::Client;
use serde_json::json;

let event_id = std::env::var("NAMAILU_EVENT_ID")?; // např. invoice-paid-
let response = Client::new()
    .post(format!("https://api.namailu.cz/v1/inboxes/{}/send", std::env::var("NAMAILU_INBOX")?))
    .bearer_auth(std::env::var("NAMAILU_API_KEY")?)
    .header("Idempotency-Key", event_id)
    .json(&json!({"to":"finance@firma.cz","subject":"Potvrzení","text":"Platba přijata.",
                     "html":"<p>Platba <strong>přijata</strong>.</p>"}))
    .send().await?
    .error_for_status()?;
println!("{}", response.text().await?);

Přijetí e-mailu a načtení detailu

Nejdřív načti seznam zpráv, potom si podle message.id vyžádej detail. Subject a body vždy zůstávají v untrusted_content.

Python

polling + detail
receive.py
import os
import requests

api = "https://api.namailu.cz"
headers = {"Authorization": f"Bearer {os.environ['NAMAILU_API_KEY']}"}
inbox = os.environ["NAMAILU_INBOX"]

list_response = requests.get(
    f"{api}/v1/inboxes/{inbox}/messages",
    headers=headers, params={"unread": "true", "limit": 50}, timeout=15,
)
list_response.raise_for_status()
messages = list_response.json()["messages"]

for message in messages:
    detail = requests.get(f"{api}/v1/messages/{message['id']}", headers=headers, timeout=15)
    detail.raise_for_status()
    email = detail.json()["untrusted_content"]
    print(message["id"], email["subject"])

Rust

polling + detail
main.rs
use reqwest::Client;
use serde_json::Value;

let api = "https://api.namailu.cz";
let token = std::env::var("NAMAILU_API_KEY")?;
let inbox = std::env::var("NAMAILU_INBOX")?;
let client = Client::new();

let list: Value = client.get(format!("{api}/v1/inboxes/{inbox}/messages"))
    .bearer_auth(&token).query(&[("unread", "true"), ("limit", "50")])
    .send().await?.error_for_status()?.json().await?;

if let Some(messages) = list["messages"].as_array() {
    for message in messages {
        let id = message["id"].as_str().unwrap();
        let detail: Value = client.get(format!("{api}/v1/messages/{id}"))
            .bearer_auth(&token).send().await?.error_for_status()?.json().await?;
        println!("{}", detail["untrusted_content"]["subject"]);
    }
}

Příjem webhooku

Receiver musí ověřit podpis nad <timestamp>.<raw body>, odmítnout starý request a uložit event_id do databáze/fronty dřív, než vrátí 204.

Python

FastAPI + stdlib HMAC
webhook.py
import hashlib, hmac, json, os, time
from fastapi import FastAPI, HTTPException, Request, Response

app = FastAPI()
secret = os.environ["NAMAILU_WEBHOOK_SECRET"].encode()

@app.post("/hooks/namailu")
async def namailu_webhook(request: Request):
    raw = await request.body()
    timestamp = request.headers.get("X-Domovnik-Timestamp", "")
    signature = request.headers.get("X-Domovnik-Signature", "")
    try:
        if abs(time.time() - int(timestamp)) > 300:
            raise ValueError("old timestamp")
    except ValueError:
        raise HTTPException(400, "invalid webhook timestamp")

    expected = hmac.new(secret, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, signature):
        raise HTTPException(401, "invalid webhook signature")

    event = json.loads(raw)
    # TODO: atomicky vlož event["event_id"] do DB/fronty; duplicitu ignoruj.
    # Worker potom načte GET /v1/messages/{event["message_id"]}.
    return Response(status_code=204)

Rust

Axum + HMAC-SHA256
webhook.rs
use axum::{body::Bytes, extract::State, http::{HeaderMap, StatusCode}};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};

type HmacSha256 = Hmac<Sha256>;
struct AppState { webhook_secret: String }

async fn namailu_webhook(
    State(state): State<AppState>, headers: HeaderMap, body: Bytes,
) -> StatusCode {
    let timestamp = headers.get("X-Domovnik-Timestamp").and_then(|v| v.to_str().ok())
        .and_then(|v| v.parse::<i64>().ok());
    let signature = headers.get("X-Domovnik-Signature").and_then(|v| v.to_str().ok())
        .and_then(|v| hex::decode(v).ok());
    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
    let Some(timestamp) = timestamp else { return StatusCode::BAD_REQUEST; };
    let Some(signature) = signature else { return StatusCode::UNAUTHORIZED; };
    if (now - timestamp).abs() > 300 { return StatusCode::BAD_REQUEST; }

    let mut mac = HmacSha256::new_from_slice(state.webhook_secret.as_bytes()).unwrap();
    mac.update(format!("{timestamp}.").as_bytes());
    mac.update(&body);
    if mac.verify_slice(&signature).is_err() { return StatusCode::UNAUTHORIZED; }

    // TODO: event_id perzistentně deduplikuj a zapiš práci do fronty.
    StatusCode::NO_CONTENT
}

Stav API

Co existuje dnes.

Vytvoření schránky
Ano, jen s tokenem pro celou organizaci (tenant-wide). Schránkově omezený token obdrží 403. Pokud má být tento endpoint vypnutý úplně, je to vědomá změna API kontraktu.
Odeslané
Použij výpis s folder=sent. POST /send potvrzuje přijetí k odeslání; pozdější známý výsledek dorazí jako message.delivery.
Spam
Použij folder=spam; u zpráv zůstávají i metadata spam_score a spam_verdict.

Chcete si namailu.cz vyzkoušet?

Založit e-mail   Ceník   Co všechno umíme