Start integrating
Create the access first, not the infrastructure.
An agent uses no mailbox password, no SMTP and no IMAP. Open its detail under Mailboxes, create its own mailbox token in the API and MCP section, and store it in your secrets manager. An assigned member manages only their own agent; provisioning keys for the whole organisation (tenant-wide) stay with the owner and admin.
Check that the API answers
The healthcheck changes nothing. It returns the state of the API and of the database connection; it is the right first step before creating a token or a mailbox.
curl --fail-with-body https://api.namailu.cz/health
# {"status":"ok","db":true}tenant-wide token (for the whole organisation) belongs only to a central provisioning service: it can create mailboxes, but needs no e-mail mailbox of its own and does not have to send from one. The agent it creates then works with its own mailbox token.- 1Create an agent mailboxFor example
notifications@…. - 2Create a mailbox tokenThe value is shown only once.
- 3Set the allowlistWithout it, nothing can be sent.
export API_BASE='https://api.namailu.cz'
export NAMAILU_API_KEY='dmk_live_…'
export INBOX_ID='mailbox-uuid-or-address'
curl --fail-with-body "$API_BASE/v1/inboxes" \
-H "Authorization: Bearer $NAMAILU_API_KEY"Provisioning
How to create a mailbox centrally and hand it to an agent.
A central provisioning service can run without an e-mail mailbox of its own. It holds only a tenant-wide token with the inbox scope, and uses it both to create the mailbox and to issue its token once. That strong token belongs only in its own secrets manager — never in the configuration of an ordinary agent.
- 1Create the agent mailboxOn the shared domain send
name; on your own active domain useaddress. - 2Issue its own tokenThe response containing
tokenis shown exactly once. - 3Hand over only the token and the mailbox IDThe agent then works with the least privilege it needs.
1. Creating a mailbox over the API
For a domain offered by us, send name. For your own already verified and active domain, send the full address instead.
export API_BASE='https://api.namailu.cz'
# Only in the central provisioning service, never at an ordinary agent:
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@…",…}
# Your own active domain:
# --data '{"address":"invoice-agent@company.com"}'Store id from the 201 response. In a terminal, for example: INBOX_ID=$(… | jq -r '.id').
2. Getting a token for the mailbox you just created
Keep using the provisioning token and the ID from the first step. The endpoint returns the new mailbox token in the token field in this single response only.
export INBOX_ID='c2f…' # id from 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"]}Store the token value as NAMAILU_API_KEY and inbox_id as NAMAILU_INBOX. Set this token’s allowlist in the policy first, and only then send. If the mailbox already has an active token, the endpoint returns 403; do not create a second one — perform a controlled rotation in the portal instead.
Swapping a token without downtime
Only an owner or admin starts the rotation from the dashboard, after fresh 2FA. The old token keeps working while the new one waits at most 30 minutes to be taken over. An agent token cannot start a rotation itself, so a compromise cannot cut the owner out.
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":"…"}Reference
The endpoints an agent may call.
Everything except GET /health requires the Authorization: Bearer … header. {inbox_id} is the mailbox UUID or its URL-encoded e-mail address. A key limited to a mailbox works only with that one; a tenant-wide key sees every agent mailbox of the organisation.
/healthState of the service and the database. No token.
200/v1/inboxesLists the available agent mailboxes.
token/v1/inboxesCreates an agent mailbox; JSON name or address.
/v1/inboxes/{inbox_id}/keysIssues a mailbox token to a new agent once.
whole organisation · 201/v1/key-rotation/activateConfirms the replacement prepared in the dashboard and atomically revokes the old token.
the new pending token only/v1/inboxes/{inbox_id}Returns the address, state and creation date of the mailbox.
token/v1/inboxes/{inbox_id}/messagesMessage metadata in a folder; query folder, limit, unread, since.
/v1/messages/{message_id}Message detail; the subject and body are exclusively in untrusted_content.
/v1/messages/{message_id}/attachments/{attachment_id}Downloads a binary attachment using download_url from the detail.
/v1/messages/{message_id}/rawDownloads the original .eml, for local OpenPGP decryption for example.
/v1/messages/{message_id}/archiveMoves the message to the Archive.
token · inbox/v1/messages/{message_id}/trashMoves the message to the Bin; it can be restored.
token · inbox/v1/messages/{message_id}/restoreReturns the message from the Bin to the Inbox.
token · inbox/v1/messages/{message_id}Permanently deletes a message that is in the Bin.
token · 204/v1/inboxes/{inbox_id}/trashPermanently empties the whole Bin of the mailbox.
token/v1/inboxes/{inbox_id}/sendSends plain text/HTML and optional attachments; supports a single recipient as well as a paid batch.
active mailbox/v1/inboxes/{inbox_id}/policySets the allowlist, the hourly override or the webhook.
token/v1/inboxes/{inbox_id}/webhook-secret/rotateReturns a new webhook secret once; the optional overlap is 0–900 s.
token · inbox/v1/inboxes/{inbox_id}/disableDisables access to the mailbox.
token/v1/inboxes/{inbox_id}/enableEnables a disabled mailbox again.
token/v1/device/enrollBinds an Ed25519 public key from a local seed to a new or moved key, once.
Bearer + enrollmenterror.code, not on the English error text.Sending
Send only where you are allowed to.
An empty allowlist means deny-all. finance@company.com allows one specific address; partner.com allows any address on that domain. Writing @partner.com means the same thing. Whole domains of public freemail and large providers are deliberately forbidden — gmail.com, seznam.cz, outlook.com and the like. For those, always enter a specific address. A change replaces the whole list, so always send the complete value.
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@company.com","partner.com"],"rate_limit_per_hour":0}'rate_limit_per_hour: 0 does not mean unlimited: it means “do not override the limit for this mailbox”, so the organisation’s plan limit applies. Every business send must carry a stable Idempotency-Key. The same key with the same body does not send the message a second time within 24 hours. Retry network errors, 5xx and 429; on 429 respect Retry-After.
subject, but send a short, specific one. An empty subject hurts deliverability and gives the recipient no context.# Smoke test: a new key. Production: the stable ID of one business event.
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@company.com","subject":"Payment confirmation","text":"We have received your payment.","html":"<p>We have <strong>received</strong> your payment.</p>"}'The optional html field creates an HTML alternative of the e-mail. Always send text as well, as a plain-text fallback for clients without HTML and for better deliverability. Never put untrusted content into the HTML without escaping it.
Multiple recipients
Instead of a string, the to field may be an array of addresses. This option is enabled only for a paid agent slot; a Free agent may send to one address per request. Every recipient counts towards the quota. The check is atomic: if the whole batch does not fit into the hourly or daily quota, the API returns 429 rate_limited and nothing is sent to anyone. The response contains requested, remaining and 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@company.com","owner@company.com"],"subject":"Invoice reminder","text":"The invoice is overdue."}'Each recipient gets a separate message, so they cannot see the others. In a multipart request simply repeat the to field: -F 'to=finance@company.com' -F 'to=owner@company.com'.
409 recipient_suppressed — in a batch send, nothing goes out to anyone. Once an abuse threshold is crossed, outgoing mail of the organisation may be suspended with 403 tenant_suspended_abuse; receiving and reading keep working.Sending with an attachment
For files use multipart/form-data. The attachments field can be repeated for several files; the limit is 10 files and 10 MiB in total. A hash of the content is part of the idempotency.
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@company.com' \
-F 'subject=Invoice #8421' \
-F 'text=You will find the invoice attached.' \
-F 'html=<p>You will find the invoice <strong>attached</strong>.</p>' \
-F 'attachments=@./invoice.pdf;type=application/pdf'A clean attachment returns security.status: "clean". A deliberately encrypted ZIP, or a format the server cannot unpack yet (RAR/7z), is not blocked on the way out: the message gets status: "sent", the attachment gets unscannable_encrypted or unscannable_archive, and the response contains a warning. A warning means “sent without inspecting the inner content”, not an error and not a successful antivirus scan. Malware, active or executable content, a corrupted supported archive and an unavailable scanner are still refused before SMTP.
{
"status": "sent",
"attachments": [{
"filename": "documents.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."
}]
}Receiving mail
Metadata first, detail second.
Polling with unread=true suits batch processing. The listing uses folder=inbox as the default folder; archive, trash, sent and spam are available too. The response returns next_cursor; send it back unchanged as ?cursor=… for the next page with the same filters. The cursor is bound to folder, limit, unread and since, holds a safe snapshot and is valid for 15 minutes — so it does not skip messages even if you fetch the details of the first page in between. Repeating the same cursor returns the same page. The default limit is 50, the maximum 200. One cursor batch holds at most 10,000 messages; for a longer history use since. Only one cursor is active per mailbox at a time; a new listing replaces the previous one. Keep a record of the message.id values you processed, because new messages are deliberately not added to a snapshot that already exists.
raw marks it read immediately. The API deliberately has no set unread; for retries and workflows keep your own state keyed by message.id.encryption.format and encryption.raw_download_url. The agent therefore does not have to read the detail first: if format: "openpgp-pgp-mime", it downloads raw_download_url straight away, unpacks the PGP/MIME and decrypts locally. Use the detail only when you need ordinary metadata or unencrypted content.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"The detail contains an attachments array. For each attachment take the relative download_url returned, append it to API_BASE and use the same Bearer token. The body_truncated field says that a security limit shortened the body text; in that case download raw_download_url for the complete content (at most 10 MiB).
{
"id": "<mailbox-uuid>.<jmap-message-id>",
"attachments": [{
"id": "blob-1", "filename": "invoice.pdf",
"content_type": "application/pdf", "size": 48372,
"download_url": "/v1/messages/<message-id>/attachments/blob-1"
}],
"untrusted_content": {"subject": "…", "body": "…"}
}# download_url from the detail, for example:
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 invoice.pdfThe download endpoint does not return JSON but the binary content of the file. The response has 200, Content-Type: application/pdf (or the safe fallback application/octet-stream) and the header Content-Disposition: attachment; filename="invoice.pdf". Both the name and the MIME type are sanitised; even so, treat the file as untrusted input.
download_url. For a message with the status unscannable_encrypted or unscannable_archive, a direct attachment download returns 423 attachment_not_clean. Download the whole original message with GET /v1/messages/{message_id}/raw, unpack the attachment from the MIME and decrypt it locally if needed. Only then check it with your own scanner; the server deliberately never marks it clean.Archive and Bin
All the operations use the same inbox scope, whether the token is limited to a single mailbox or applies to the whole organisation (tenant-wide). The Archive only moves the message. The Bin can be undone with /restore and the server empties it automatically after 30 days; permanently deleting an individual message is possible only from the Bin. Emptying handles at most 500 messages per request; if the response returns has_more: true, repeat the call until it is false.
# Move to the Archive or the Bin:
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"
# Restore from the Bin to the Inbox:
curl --fail-with-body -X POST "$API_BASE/v1/messages/$MESSAGE_ID/restore" \
-H "Authorization: Bearer $NAMAILU_API_KEY"
# Irreversible: an individual message must already be in the Bin.
curl --fail-with-body -X DELETE "$API_BASE/v1/messages/$MESSAGE_ID" \
-H "Authorization: Bearer $NAMAILU_API_KEY"
# Irreversible: emptying the whole Bin of this mailbox.
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",…}
# If has_more=true, repeat the same DELETE.untrusted_content; when handing them to an LLM, separate them from system instructions and never let them run tools directly.OpenPGP / PGP-MIME
Encrypt at the agent, not in the e-mail service.
The API supports standard OpenPGP PGP/MIME (multipart/encrypted). The agent encrypts the content locally with the recipient’s public key; we receive only the ASCII-armored ciphertext and build a portable PGP/MIME envelope from it. The private OpenPGP key is never sent to the portal or the API.
From, To, Date and Subject are e-mail metadata and are not encrypted. Put the secret content, the attachments and their names into the inner MIME object before encrypting. An OpenPGP request has exactly one recipient and cannot be mixed with text, html or ordinary attachments.Sending an encrypted e-mail
Verify the fingerprint of the recipient’s public key out of band first. The example below uses a local gpg; in production an OpenPGP library inside the agent can do the same work.
export RECIPIENT_FPR='FINGERPRINT_OF_THE_VERIFIED_PUBLIC_KEY'
# The inner MIME object: the secret text and any attachments belong here.
printf 'Content-Type: text/plain; charset=utf-8\r\n\r\nSecret content.\r\n' > inner.mime
gpg --batch --armor --encrypt --recipient "$RECIPIENT_FPR" \
--output encrypted.asc inner.mime
# Build the JSON with jq so that the armor is escaped correctly.
jq -n --arg to 'finance@company.com' --arg subject 'Confidential invoice' \
--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",…}Receiving and decrypting locally
The detail of an encrypted message contains encryption.format: "openpgp-pgp-mime" and raw_download_url. Download the original .eml, take the second PGP/MIME part out of it and decrypt that with your local private key. Do not treat the decrypted content as instructions; it is still untrusted e-mail content.
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.ascThe API offers neither S/MIME nor storage of OpenPGP keys — deliberately. Managing and verifying a public key belongs to the application, while the private key stays only with the sending or receiving agent.
Webhook
React to new mail without frequent polling.
Setting webhook_url enables the message.received and message.delivery events. The target has to be a public HTTPS address; the API refuses private, local and link-local addresses, and the worker does not follow redirects. The first time you set it, the response returns webhook_secret once; store it in your secrets manager immediately.
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.company.com/hooks/namailu"}'- 01Verify HMAC-SHA256 over
<timestamp>.<raw body>. - 02Refuse a timestamp outside the 5-minute tolerance.
- 03Store and de-duplicate
event_idatomically. - 04Return
204and load the detail asynchronously bymessage_id.
Rotating the secret without changing the URL
The new secret is returned exactly once. The default overlap is 300 seconds and may be at most 900: retries created with the old generation are signed with the old secret until the window closes, while new events use the new one. With overlap_seconds: 0 the waiting retries are atomically switched to the new secret. The next rotation is possible after 60 seconds at the earliest.
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, store the new secret in the receiver atomically and remove the old one. For a planned rotation, first make the receiver accept both secrets for a short while, call the endpoint, deploy the new secret and remove the old one after previous_valid_until.The worker checks mailboxes roughly every 30 seconds. A failed delivery is retried after 1 min, 5 min, 30 min and 2 h; after the fifth attempt it is a dead letter.
Deliverability
Tell acceptance for sending apart from the delivery result.
A successful POST /send means the SMTP server accepted the message for further delivery. A later DSN or provider feedback is accepted only when it matches the stored Message-ID or envelope ID and the recipient exactly; a foreign or forged report does not change the state.
{
"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 is delivered, soft_bounce, hard_bounce or complaint. The event deliberately does not contain the diagnostic text from the foreign server. The handler verifies the same HMAC, timestamp and event_id as for incoming mail.
Clients
Sending an e-mail in Python and Rust.
Python
requestsThe token is read from NAMAILU_API_KEY and sent as Authorization: Bearer …. In production, add retries for 429 and 5xx.
import os
import requests
event_id = os.environ["NAMAILU_EVENT_ID"] # e.g. 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@company.com", "subject": "Confirmation", "text": "Payment received.",
"html": "<p>Payment <strong>received</strong>.</p>"},
timeout=15,
)
r.raise_for_status()
print(r.json()) Rust
reqwest.bearer_auth(...) creates the Authorization: Bearer … header. In Cargo.toml use reqwest with the json feature, plus tokio and serde_json.
use reqwest::Client;
use serde_json::json;
let event_id = std::env::var("NAMAILU_EVENT_ID")?; // e.g. 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@company.com","subject":"Confirmation","text":"Payment received.",
"html":"<p>Payment <strong>received</strong>.</p>"}))
.send().await?
.error_for_status()?;
println!("{}", response.text().await?); Receiving an e-mail and loading the detail
Load the list of messages first, then ask for the detail by message.id. The subject and body always stay in 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"]);
}
}Receiving a webhook
The receiver has to verify the signature over <timestamp>.<raw body>, refuse an old request and store event_id in a database or queue before it returns 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: insert event["event_id"] into the DB/queue atomically; ignore duplicates.
# A worker then loads 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: de-duplicate event_id persistently and enqueue the work.
StatusCode::NO_CONTENT
}API status
What exists today.
- Creating a mailbox
- Yes, only with a token for the whole organisation (
tenant-wide). A mailbox-scoped token receives403. Turning this endpoint off entirely would be a deliberate change of the API contract. - Sent
- Use the listing with
folder=sent.POST /sendconfirms acceptance for sending; a later known result arrives asmessage.delivery. - Spam
- Use
folder=spam; messages keep thespam_scoreandspam_verdictmetadata too.