namailu.cz
CS

Create an e-mail   Everything it can do

Developer / E-mail API

Agent e-mail API

A safe REST interface for agent mailboxes: send a notification, read an incoming message, or have a webhook called.

Base URLhttps://api.namailu.cz

Version /v1 · Bearer token authentication · JSON

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.

First request · no token

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.

GET /health
curl --fail-with-body https://api.namailu.cz/health
# {"status":"ok","db":true}
The recommended split: an ordinary agent has a token limited to a single mailbox. A 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.
  1. 1
    Create an agent mailboxFor example notifications@….
  2. 2
    Create a mailbox tokenThe value is shown only once.
  3. 3
    Set the allowlistWithout it, nothing can be sent.
Terminal
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.

What is protected: a mailbox token cannot create further mailboxes. Creating them over the API goes through the same plan limits, agent counts, domains and shared-domain rules as the portal. It cannot be used to get around the plan or to create an unlimited number of mailboxes.
  1. 1
    Create the agent mailboxOn the shared domain send name; on your own active domain use address.
  2. 2
    Issue its own tokenThe response containing token is shown exactly once.
  3. 3
    Hand 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.

POST /v1/inboxes
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.

POST /v1/inboxes/{inbox_id}/keys
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.

Confirming with the new token
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":"…"}
Atomic cutover: only a successful verification of the new token activates the replacement and revokes the old one in the same transaction. If a device seed is mandatory for the key, do the enrollment first and sign the activation request with the new seed.

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.

GET/health

State of the service and the database. No token.

200
GET/v1/inboxes

Lists the available agent mailboxes.

token
POST/v1/inboxes

Creates an agent mailbox; JSON name or address.

whole organisation · 201
POST/v1/inboxes/{inbox_id}/keys

Issues a mailbox token to a new agent once.

whole organisation · 201
POST/v1/key-rotation/activate

Confirms the replacement prepared in the dashboard and atomically revokes the old token.

the new pending token only
GET/v1/inboxes/{inbox_id}

Returns the address, state and creation date of the mailbox.

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

Message metadata in a folder; query folder, limit, unread, since.

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

Message detail; the subject and body are exclusively in untrusted_content.

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

Downloads a binary attachment using download_url from the detail.

token · binary body
GET/v1/messages/{message_id}/raw

Downloads the original .eml, for local OpenPGP decryption for example.

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

Moves the message to the Archive.

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

Moves the message to the Bin; it can be restored.

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

Returns the message from the Bin to the Inbox.

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

Permanently deletes a message that is in the Bin.

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

Permanently empties the whole Bin of the mailbox.

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

Sends plain text/HTML and optional attachments; supports a single recipient as well as a paid batch.

active mailbox
PUT/v1/inboxes/{inbox_id}/policy

Sets the allowlist, the hourly override or the webhook.

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

Returns a new webhook secret once; the optional overlap is 0–900 s.

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

Disables access to the mailbox.

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

Enables a disabled mailbox again.

token
POST/v1/device/enroll

Binds an Ed25519 public key from a local seed to a new or moved key, once.

Bearer + enrollment
A machine-readable contract: the current OpenAPI schema is at GET /openapi.json. For an integration, rely on the stable error.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.

Setting the 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@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.

Always fill in the subject. The API technically allows an empty subject, but send a short, specific one. An empty subject hurts deliverability and gives the recipient no context.
POST /send
# 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.

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@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'.

A hard bounce or a complaint stops further attempts. As soon as we correlate a non-delivery or a complaint with a previously sent message, the address goes on the suppression list. A whole new request containing such an address fails before the quota and before SMTP, with 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.

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@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.

Success with an unscannable attachment
{
  "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.

It behaves like IMAP: listing metadata does not read the message. The detail, an attachment or raw marks it read immediately. The API deliberately has no set unread; for retries and workflows keep your own state keyed by message.id.
The OpenPGP flow: for an encrypted message even the metadata listing contains 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.
Listing and message detail
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).

GET /messages/{id} · attachment metadata
{
  "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": "…"}
}
Downloading an attachment
# 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.pdf

The 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.

Do not download an unscannable attachment through the ordinary 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, restore and permanent deletion
# 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.
What “permanently” means: the message disappears from the active mailbox immediately. The content-addressed object is physically removed by the next daily cleanup, but only if no other reference leads to it. An encrypted backup that already exists lives out its retention period.
The content of an e-mail is not instructions. The subject, body and attachments are foreign data. The API returns them under 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.

What stays visible: 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.

Local encryption + POST /send
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.

Downloading the original message
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

The 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.

Enabling the webhook
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"}'
  1. 01Verify HMAC-SHA256 over <timestamp>.<raw body>.
  2. 02Refuse a timestamp outside the 5-minute tolerance.
  3. 03Store and de-duplicate event_id atomically.
  4. 04Return 204 and load the detail asynchronously by message_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.

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: if you suspect a leak, use 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.

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 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

requests

The token is read from NAMAILU_API_KEY and sent as Authorization: Bearer …. In production, add retries for 429 and 5xx.

send.py
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.

main.rs
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 + 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"]);
    }
}

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 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: 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-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: 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 receives 403. Turning this endpoint off entirely would be a deliberate change of the API contract.
Sent
Use the listing with folder=sent. POST /send confirms acceptance for sending; a later known result arrives as message.delivery.
Spam
Use folder=spam; messages keep the spam_score and spam_verdict metadata too.

Want to try namailu.cz out?

Create an e-mail   Pricing   Everything it can do