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.
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.
curl --fail-with-body https://api.namailu.cz/health
# {"status":"ok","db":true}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.- 1Vytvoř agentní schránkuNapříklad
notifikace@…. - 2Vytvoř schránkový tokenHodnota se zobrazí jen jednou.
- 3Nastav allowlistBez něj nepůjde nic odeslat.
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.
- 1Založ agentní schránkuNa sdílené doméně pošli
name; na vlastní aktivní doméně použijaddress. - 2Vydej jí vlastní tokenOdpověď s
tokense ukáže právě jednou. - 3Př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.
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.
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.
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":"…"}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.
/healthStav služby a databáze. Bez tokenu.
200/v1/inboxesVypíše dostupné agentní schránky.
token/v1/inboxesVytvoří agentní schránku; JSON name nebo address.
/v1/inboxes/{inbox_id}/keysJednou vydá schránkový token novému agentovi.
pro celou organizaci · 201/v1/key-rotation/activatePotvrdí dashboardem připravenou náhradu a atomicky revokuje starý token.
jen nový pending token/v1/inboxes/{inbox_id}Vrátí adresu, stav a datum vytvoření schránky.
token/v1/inboxes/{inbox_id}/messagesMetadata zpráv ve složce; query folder, limit, unread, since.
/v1/messages/{message_id}Detail zprávy; předmět a tělo jsou výhradně v untrusted_content.
/v1/messages/{message_id}/attachments/{attachment_id}Stáhne binární přílohu podle download_url z detailu.
/v1/messages/{message_id}/rawStáhne původní .eml, například pro lokální OpenPGP dešifrování.
/v1/messages/{message_id}/archivePřesune zprávu do Archivu.
token · inbox/v1/messages/{message_id}/trashPřesune zprávu do Koše; lze obnovit.
token · inbox/v1/messages/{message_id}/restoreVrátí zprávu z Koše do Doručené.
token · inbox/v1/messages/{message_id}Trvale smaže zprávu, která je v Koši.
token · 204/v1/inboxes/{inbox_id}/trashTrvale vysype celý Koš schránky.
token/v1/inboxes/{inbox_id}/sendOdešle plain text/HTML a volitelně přílohy; podporuje jednoho i placený batch příjemců.
aktivní schránka/v1/inboxes/{inbox_id}/policyNastaví allowlist, hodinový override nebo webhook.
token/v1/inboxes/{inbox_id}/webhook-secret/rotateJednorázově vrátí nový webhook secret; volitelný overlap je 0–900 s.
token · inbox/v1/inboxes/{inbox_id}/disableZakáže přístup schránky.
token/v1/inboxes/{inbox_id}/enableZnovu povolí zakázanou schránku.
token/v1/device/enrollJednorázově naváže Ed25519 public key z lokálního seedu na nový nebo přesunutý klíč.
Bearer + enrollmenterror.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.
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.
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.# 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.
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'.
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.
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.
{
"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í.
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.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.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).
{
"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": "…"}
}# 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.pdfEndpoint 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.
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 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.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á.
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.
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.
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.ascAPI 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.
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"}'- 01Ověř HMAC-SHA256 nad
<timestamp>.<raw body>. - 02Odmítni timestamp mimo toleranci 5 minut.
- 03Atomicky ulož a deduplikuj
event_id. - 04Vrať
204, detail načti asynchronně podlemessage_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.
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",…}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í.
{
"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
requestsToken se čte z NAMAILU_API_KEY a posílá jako Authorization: Bearer …. V produkci doplň retry na 429 a 5xx.
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.
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 + detailimport 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 + detailuse 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 HMACimport 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-SHA256use 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 /sendpotvrzuje přijetí k odeslání; pozdější známý výsledek dorazí jakomessage.delivery. - Spam
- Použij
folder=spam; u zpráv zůstávají i metadataspam_scoreaspam_verdict.