Sending email

Sending is the core of CamelMailer. You hand a message to the platform, it stores and queues it, and the delivery worker takes it from there.

Two front doors reach the same pipeline: an HTTP send API and SMTP submission. Both authenticate with a server API credential, both apply the same From-address rules, and both end in one stored, queued message per recipient. This page covers the request and response shapes, attachments and custom headers, how the message stream is chosen, and what happens after a message is accepted.

The HTTP send API

The send endpoints live on the Messaging surface (base path /api/v2/server) and authenticate with a server API credential passed in the X-Server-API-Key header. That credential is an API-type credential of one mail server (see Quickstart); it scopes the request to that server and its data.

EndpointPurpose
POST /api/v2/server/messagesSend one message.
POST /api/v2/server/messages/batchSend an array of messages in one call.
POST /api/v2/server/messages/with_templateRender a stored template, then send.
POST /api/v2/server/messages/with_template/batchRender and send to many recipients.

Request payload

The body is JSON. Only from and at least one recipient are required; everything else is optional.

FieldTypeNotes
fromaddressRequired. The sender. Its domain must be authorized for this server (see below).
toarray of addressesPrimary recipients.
ccarray of addressesCarbon-copy recipients.
bccarray of addressesBlind carbon-copy recipients.
reply_toarray of addressesSets the Reply-To header.
subjectstringMessage subject. Defaults to empty when omitted.
html_bodystringHTML part.
text_bodystringPlain-text part.
headersobject (string to string)Extra headers such as X-* or List-*. Reserved names are ignored (see Custom headers).
attachmentsarray of attachmentsSee Attachments.
tagstringFree-form label stored with the message and available as a filter in the message list, stats and tags endpoints.
metadataobjectArbitrary JSON stored alongside the message.
streamstringPermalink of the target message stream. Defaults to the server's default stream.

At least one of to, cc or bcc must be present and non-empty. A request with only a from returns 400 ParameterMissing.

An address is either a bare email string or an object carrying a display name:

address
"to": [
  "ada@example.com",
  { "email": "grace@example.com", "name": "Grace Hopper" }
]

Supply html_body, text_body, or both. With both parts CamelMailer builds a multipart/alternative message. With neither, it writes an empty text part so the message stays valid.

From-address authorization

The from address is checked before anything is queued. The request is accepted when either of these holds:

  • the server (or its organization) owns a verified sending domain that matches the From domain, or
  • the exact From address is a confirmed sender address of the server.

When the domain matches a verified domain, that domain's DKIM key signs the message. A From address that satisfies neither rule returns 422 ValidationError. SMTP submission applies the same two-step rule inside the session state machine, so both doors behave identically.

Response shape

Every Messaging response uses the standard envelope { status, time, data } (or { status, time, error } on failure), where time is the server-side processing time in seconds. A successful send returns 201 Created:

201 Created
{
  "status": "success",
  "time": 0.012,
  "data": {
    "message_id": 4021,
    "recipients": [
      { "rcpt_to": "ada@example.com", "message_id": 4021, "token": "b1f9c3e2d0", "status": "queued" },
      { "rcpt_to": "grace@example.com", "message_id": 4022, "token": "7a2c58f11e", "status": "queued" }
    ]
  }
}

One entry appears per recipient across to, cc and bcc, because each recipient becomes its own stored message. The top-level message_id is the numeric id of the first stored message, a convenience for the common single-recipient send. Error responses carry a stable error.code (for example ParameterMissing or ValidationError) and a human-readable error.message.

Batch sends

POST /api/v2/server/messages/batch takes an array of send requests and returns one result per entry, so a single bad entry reports its own error while the others still queue:

batch response
{
  "status": "success",
  "time": 0.031,
  "data": {
    "messages": [
      { "status": "success", "data": { "message_id": 4023, "recipients": [ ... ] } },
      { "status": "error", "error": { "code": "ValidationError", "message": "..." } }
    ]
  }
}

How message IDs work

Three separate identifiers travel with a message, and it helps to keep them apart:

IdentifierWhere it appearsWhat it is
message_idsend response, GET /messages/{id} pathThe numeric database id of a stored message (integer). Use it to read the message back.
tokensend response, message recordAn opaque per-message token, also used to build tracking and share links.
message_idGET /messages/{id} responseThe RFC 5322 Message-ID header, a string like <...@host>.

The RFC Message-ID header is generated automatically when the MIME message is built. On the read side, GET /api/v2/server/messages/{id} returns it as the message_id field of the message record, while the send response message_id is the numeric id you pass back into the path. When you read a message, the numeric id sits in the URL and the header string sits in the body.

curl example

terminal
curl -s -X POST "$API/api/v2/server/messages" \
  -H "X-Server-API-Key: $SERVER_KEY" -H "Content-Type: application/json" \
  -d '{
    "from": { "email": "billing@acme.example", "name": "Acme Billing" },
    "to": ["ada@example.com"],
    "reply_to": ["support@acme.example"],
    "subject": "Your receipt",
    "html_body": "<p>Thanks for your purchase.</p>",
    "text_body": "Thanks for your purchase.",
    "tag": "receipt",
    "headers": { "X-Order-Id": "10432" },
    "attachments": [
      { "name": "invoice.pdf", "content_type": "application/pdf",
        "data_base64": "JVBERi0xLjQK..." }
    ]
  }'

Attachments

Each attachment is an object with three required fields:

FieldTypeNotes
namestringThe filename, used as the attachment's Content-Disposition name.
content_typestringMIME type, for example application/pdf or image/png.
data_base64stringThe file content, base64-encoded (standard alphabet).

Content that fails to decode as base64 returns 422 ValidationErrornaming the offending attachment, and nothing is queued. Total message size is bounded by the SMTP server's max_message_size for anything that transits SMTP, so keep large payloads in mind.

Custom headers

The headers object adds arbitrary headers to the outgoing message, which is the place for things like X-* application markers or List-* headers. The MIME builder owns a set of reserved headers and ignores any client value for them, so identity and routing headers stay tamper-proof:

reserved headers
from, to, cc, bcc, reply-to, subject, date,
message-id, content-type, content-transfer-encoding, mime-version

To set the sender, recipients, subject or reply address, use the dedicated payload fields; the reserved list above shows which names headers skips.

Choosing a message stream

Every send is attributed to one message stream. Streams separate traffic classes (for example transactional versus broadcast) so each one gets its own tracking, stats and configuration.

  • Omit streamand the message is attributed to the server's default stream.
  • Set stream to a stream permalink to target that stream. The stream must exist and must be active, otherwise the send returns 422 ValidationError.
Broadcast streams add behavior on send. When the target stream is a broadcast stream, CamelMailer requires every recipient to have opted in to that stream and rejects the whole request naming the first address that has not. It also gives each recipient a unique one-click unsubscribe: per-recipient List-Unsubscribe and List-Unsubscribe-Postheaders (RFC 8058) plus a visible unsubscribe footer, and the sender's physical postal address when one is configured on the server. Transactional and inbound streams stay untouched by all of this. See Broadcast streams for the opt-in model and Suppressions for how opt-outs are recorded.

SMTP submission

SMTP submission is the alternative front door, for clients and libraries that already speak SMTP. It reaches the same pipeline as the HTTP API and applies the same From-address authorization.

Credentials. Create a credential of type SMTP on the server. The HTTP API uses API-type credentials; SMTP submission uses SMTP-type ones:

terminal
curl -s -X POST "$API/api/v2/admin/organizations/acme/servers/production/credentials" \
  -H "X-Admin-API-Key: $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"type": "SMTP", "name": "smtp-submission"}'

The returned data.credential.key is the SMTP password. Authenticate with AUTH PLAIN or AUTH LOGIN, passing that key as the password; the username is accepted but unused for these mechanisms. The credential identifies the server on its own.

STARTTLS. The server advertises STARTTLS until the session is upgraded, and it advertises AUTH only once the session is TLS-protected, so submission credentials stay off the wire in cleartext. When TLS is disabled entirely, AUTH is advertised on the plain session as a fallback. Port 25 speaks plain SMTP with STARTTLS; a listener on 587 is the usual submission port, and a listener on 465 uses implicit TLS from the first byte. Ports and certificates are set in the smtp_server configuration group.

Example session over the submission port with swaks:

terminal
swaks --server mx.example.com --port 587 --tls \
  --auth PLAIN --auth-user ignored --auth-password "$SMTP_KEY" \
  --from billing@acme.example \
  --to ada@example.com \
  --header "Subject: Your receipt" \
  --body "Thanks for your purchase."

A successful AUTH is acknowledged with 235 Granted for <org>/<server>. As with the HTTP API, each RCPT TO recipient becomes its own stored, queued message.

What happens after a message is accepted

Accepting a message and delivering it are two steps:

  • Acceptance. The request is validated, the From address is authorized, the stream is resolved, the raw MIME is built, and one message per recipient is stored with status queued. The API responds at this point. Acceptance means the message is safely stored, and delivery comes next.
  • Delivery. The worker dequeues messages (using PostgreSQL SKIP LOCKED, so any number of workers cooperate safely), signs them with DKIM, rewrites tracking links where enabled, and delivers either direct-to-MX on port 25 or through the configured relays. Delivery attempts and their outcomes are recorded against the message and readable via GET /api/v2/server/messages/{id}/deliveries.
Each send is independent. Send the same request twice and you get two queued messages, since there is no idempotency key. Deduplicate on your side when a retry must not resend.

Local development without a worker

In a local stack that runs only the web and SMTP processes, sends succeed and messages are stored with status queued, and they wait there because nothing delivers them: the queue simply grows. Start the worker process to move queued messages out the door. The full Docker Compose stack in the Quickstart already includes the worker, so messages there are delivered (direct-to-MX, or via relays when configured).