Skip to content

Sending from an application

Purpose. Your application sends a receipt, a password reset or a notification from an address on this server, with one HTTP request, and can ask later whether it went. It needs an API token that may send, and nothing else: no SMTP library, no mailbox password in your code.

  1. A mailbox to send from. Create one for the application, such as notifications@example.com, on the Domains page. Sending runs as that mailbox, so its hourly rate limit and any block that containment puts on it apply to the application and to nothing else. Do not use a person’s mailbox.
  2. A sending token. On API tokens → Applications, Create token, list the addresses it may send from (an address, or *@example.com for every address on the domain), and keep the msk_… secret. It is shown once. From the licence’s degraded stage a new sending token is refused (402 license_degraded); existing tokens keep working (Licensing). The token can send as those addresses and do nothing a role can do; see REST API → API tokens.
POST /api/v1/messages
Authorization: Bearer msk_…
Idempotency-Key: order-42
Content-Type: application/json
{
"from": { "email": "notifications@example.com", "name": "Example" },
"to": "someone@example.org",
"replyTo": "support@example.com",
"subject": "Your receipt",
"text": "Thanks for your order.",
"html": "<p>Thanks for your order.</p>",
"headers": { "X-Entity-Ref-ID": "order-42" },
"attachments": [{ "filename": "receipt.pdf", "contentType": "application/pdf", "content": "<base64>" }],
"tags": ["receipt"],
"metadata": { "orderId": "42" }
}

to, cc and bcc take one address or a list, each an address or { "email", "name" }, up to 100 in all. text, html or both. headers may add X-* and List-* headers only. Attachments are base64 in the request; there is no separate upload. An attachment may carry contentId, to be referenced as cid:<id> from the HTML. tags and metadata are yours: stored, returned, never read by the server. Limits: 50 attachments, a 998-character subject, text and html up to 5,000,000 characters each, 20 tags of up to 64 characters, metadata keys up to 64 and values up to 1,000 characters, and an Idempotency-Key of up to 255 characters, remembered per token.

The answer is 202 once Postfix has queued the message:

{ "id": "…", "messageId": "…@example.com", "status": "queued", "createdAt": "…" }

messageId is the Message-ID header on the delivered message, without its angle brackets.

Retrying safely. Send an Idempotency-Key header with a value of your own, such as your order number. For 24 hours, the same key with the same request returns the first answer and sends nothing (provided the mailbox can still send: the sender checks run first); a replay while the first is still in flight is 409 idempotency_in_progress; the same key with a different request is refused (409 idempotency_conflict). If a send failed outright, the key is released and a retry goes through. If the answer was 502 outcome_unknown (the connection dropped before Postfix answered), a retry with the same key is sent as the same message, with the same Message-ID.

The same request from Node, Deno or Bun, with the token in an environment variable rather than in code:

Terminal window
npm install @maildock/sdk
import { MailDock } from '@maildock/sdk';
const maildock = new MailDock({ baseUrl: 'https://mail.example.com', apiKey: process.env.MAILDOCK_API_KEY });
const { id } = await maildock.messages.send(
{
from: { email: 'notifications@example.com' },
to: 'someone@example.org',
subject: 'Your receipt',
text: 'Thanks.',
},
{ idempotencyKey: 'order-42' },
);
const { status } = await maildock.messages.get(id);

The package is MIT-licensed on public npm and is versioned apart from the server. It sends an Idempotency-Key with every message, generating one when you pass none, and it retries a connection failure, a 429 or a 5xx with that same key, so a retry never sends the message twice. Each refusal below arrives as a typed error carrying the server’s code. Do not use it in a browser, because the token would be exposed. Its README lists the server versions it supports and the error classes.

GET /api/v1/messages/{id}

returns the message and the latest event per recipient from the delivery log: recipient, status (which for one recipient can also read rejected; the overall status folds that into bounced), the relay Postfix handed it to, viaRelay, the server’s reply in detail, and when. recipients holds only addresses you named, at most one entry each (and only once the delivery log has an event for one), however often you named it. An address that is an alias or forwards elsewhere reports its destinations’ worst status under its own name, and only its status: a forward’s destination is never named, so such an entry has no detail (Postfix’s text names the destination’s host) and no relay unless it is the configured outbound relay, and a mailbox’s forwarding stays private. Only your send counts: a Sieve redirect or vacation reply carries the same Message-ID in a queue of its own and is left out. GET /api/v1/messages?status=bounced&tag=receipt lists messages, newest first, limit up to 100 (default 50). A read sees every message sent through this server’s API by its tenant, whichever token sent it; ?status= is derived from the delivery log at read time, so it filters the newest 500 records and total is then the count among those.

Status Meaning
queued Postfix has it and has not yet handed it on, for at least one recipient
sent Every recipient’s server accepted it — or the outbound relay did, when viaRelay is true
deferred A recipient’s server said try later; Postfix will
bounced A recipient’s server refused it, or Postfix gave up
rejected Postfix refused it at submission
failed It never reached Postfix
unknown The submission ended without an answer; a retry with the same Idempotency-Key resends it

There is no delivered. Through a relay, sent means the relay took the message; its bounce, if any, comes back later as mail to the sending mailbox. Sending directly, sent means the recipient’s server took it; a refusal by that server, or a deferral that later fails, appears here as an event, while a bounce it sends after accepting arrives as mail. The words are the same either way, so switching from a relay to direct sending changes nothing your application reads.

Answer Why
403 sender_not_allowed The token may not send from from.email
422 sender_not_a_mailbox from.email is not an active mailbox on this server, or is one of the server’s own system mailboxes (noreply@, dmarc-reports@)
403 sending_blocked Containment has blocked the mailbox from sending
400 validation A header outside X-*/List-*, no text or html, too many recipients, and the like
413 message_too_large Over the server’s message size limit (limits.messageSizeBytes), or over the 15 MiB request cap
409 idempotency_conflict / idempotency_in_progress The Idempotency-Key was used for a different message, or its first send is still in flight
429 rate_limited Over the per-token request ceiling (Retry-After says when), or one of Rspamd’s hourly limits: the mailbox’s, the domain’s, or IP warm-up
401 / 403 forbidden / 404 not_found No token; a session or a role token, which hold no permission; a well-formed id that is not a message in this tenant (a malformed id is 400 validation)
422 rejected Postfix refused the message; message carries its reply
503 temporarily_unavailable Postfix asked for it later, or could not be reached
502 outcome_unknown The connection ended before Postfix answered

Every response, a refusal or not, carries an x-request-id: a UUID the server generated for that request, and the reqId on the API’s log lines for it, so one failed call can be found in docker logs mailserver-api-1. An x-request-id or request-id the caller sends is ignored. The SDK surfaces it as error.requestId. Servers through 0.10.0 send none.

Sending counts against the mailbox’s outbound rate limit (500 recipients an hour by default, per mailbox; 2,000 per domain, each recipient counting once — Outbound rate limits) and against compromised-mailbox containment, exactly as a person’s sends do; that is why the application gets a mailbox of its own. The per-token request ceiling is 120 a minute (MESSAGES_RATE_LIMIT_PER_MINUTE; reads count against it, so polling GET /messages/{id} spends the same budget as sending) and the largest request is 15 MiB (MESSAGES_BODY_LIMIT_BYTES); both are set in .env. Bodies are not stored; the record keeps addresses, subject, size, tags and metadata, and is never pruned, while the delivery log it reads status from is kept for DELIVERY_LOG_RETENTION_DAYS (30) — after that a message’s recipients have no events and its status reads queued again. No licence stage refuses the request itself, but from send_suspended — 90 days into an unpaid lapse (Licensing) — the mail server refuses every recipient not hosted on this server at submission: a send whose every recipient is outside answers 422 rejected with the server’s 554 5.7.1 Sending is suspended… reply, and one with recipients both here and outside is accepted (202, status queued), and the message’s error names each refused address and why. Renewing the licence restores sending within minutes. No role and no signed-in session can call this resource: only a scoped token holding messages:send (and messages:read for the two reads) can, by design.