Inbound mail & routing

CamelMailer receives mail as well as sends it. A route decides what happens to each incoming message: hand it to an HTTP endpoint, keep it on the server for inspection, or ingest it internally for DMARC reports.

An inbound stream groups the mail that arrives at a server. This page covers what a route does once mail has landed. For the streams themselves, see Message streams.

How inbound mail arrives

The SMTP server listens as the MX for the domains you host and accepts mail at RCPT TO time. It splits each recipient into a local part, an optional +tag, and a domain, then matches in this order:

  • Return path: the address carries a server token, so the message is booked as a bounce for that server.
  • Direct-to-route: on the configured route_domain, the local part is a route token, so <token>@<route_domain> reaches exactly one route regardless of that route's own address. A +tag is preserved on the rebuilt recipient.
  • Route address: the local part and domain match a route's name and its domain. This is the normal way a person or system addresses your inbound mail.

A matched recipient is accepted with 250 OK, stored, and queued for the worker with its route_id attached. Two answers come right at RCPT TO: a suspended mail server replies 535, and a route in Reject mode replies 550 so the sender learns immediately.

The route model

A route is a small config record on one mail server. Its name is the local part it answers to, its domain_id is the domain the address lives under, its token is an opaque handle for the direct-to-route address, and its modedecides the message's fate. When a route is in Endpoint mode, endpoint_url names the delivery target.

ModeAt RCPT TOAt the worker
Endpointaccepteddelivered to endpoint_url (see target kinds below)
Acceptacceptedstored, with nothing further to deliver
Holdacceptedstored and kept on the server for inspection
Bounceacceptedstored, with no separate bounce action today
Rejectrefused with 550never reached, since the sender was already told

Accept, Hold, and Bounce all leave the message stored and readable in message history with nothing sent onward. Endpoint is the mode that forwards the message somewhere, and where it goes depends on endpoint_url.

Target kinds

For a route in Endpoint mode, the endpoint_url names the target. Route validation accepts exactly two shapes. Any other internal:// value or a non-URL string is rejected with 422 ValidationError.

endpoint_urlTarget kindWhat the worker does
https://…HTTP endpointPOSTs a JSON envelope carrying the raw message (base64) to the URL
internal://dmarc-reportsInternal DMARC ingestionparses the message as an RFC 7489 aggregate report and stores it in the report tables

The HTTP delivery is the webhook-style path for inbound mail. It shares the signing and retry model that the rest of CamelMailer's HTTP deliveries use, described in Webhooks. The POST body carries the envelope plus the full RFC 822 message:

POST body
{
  "message": {
    "id": 1234,
    "token": "…",
    "rcpt_to": "support@acme.example",
    "mail_from": "customer@example.com",
    "bounce": false
  },
  "raw_base64": "…the full RFC 822 message, base64-encoded…"
}

Your endpoint owns everything after that: parse the raw message, create a ticket, reply, or file it. A 2xxresponse completes the delivery. Any other status or a connection error retries with backoff until the worker's attempt limit, after which the delivery is marked failed.

The internal://dmarc-reportstarget feeds DMARC aggregate reports into the tenant's report tables. Point the rua= tag of your DMARC record at a route that uses it, and CamelMailer parses and stores each report for you.

A route's target is an HTTP(S) URL or the internal DMARC target. There is no email-forward target: relaying an inbound message to another mailbox is left to your own endpoint, which can re-inject the mail through the send API.

What happens to a routed message

Every inbound message is stored the moment it is accepted, so it appears in the server's messages with its mail_from, rcpt_to, subject, and raw content, exactly like an outbound message. The worker then acts on it according to the route:

  • HTTP endpoint delivery POSTs the envelope above. A 2xx completes the message; failures retry with backoff and end as a failed delivery once the attempts run out.
  • DMARC ingestion records a Processed delivery entry on success. A message that fails to parse as an aggregate report is held with a delivery entry naming the parse error, so a malformed report stays visible under the message and the worker keeps running.
  • Accept, Hold, and Bounce routes leave the stored message in place with nothing to deliver.

Two checks run on inbound mail by inspecting the message content, independently of the route. When rspamd or ClamAV is configured, a message that exceeds the spam-failure threshold or fails a virus scan is held rather than delivered. A message that an ISP delivers as a spam-complaint report is recognised by its envelope and turned into a stream-scoped complaint for the recipient who complained, then marked Processed.

Bounce-flagged messages that arrive at a return path are classified into hard, soft, or undetermined, so the observability API can break bounces down by category. You read inbound messages through the same endpoints as outbound mail: GET /api/v2/server/messages/{id} returns the message with its delivery attempts, which is where the Processed, Held, or failure entries show up.

Managing routes

Routes are managed over the admin API with an X-Admin-API-Key key or a user session (Authorization: Bearer), and mirrored in the dashboard under a server's Routes tab.

Method and pathAction
GET …/routesList the server's routes
POST …/routesCreate a route
GET …/routes/{id}Show one route
PATCH …/routes/{id}Update name and mode
DELETE …/routes/{id}Delete a route

Create takes name (required, the local part), domain (when omitted, the route is reachable only by token at <token>@<route_domain>), mode (defaults to Endpoint), and endpoint_url. The endpoint URL is validated when present: it must be an HTTP(S) URL or exactly internal://dmarc-reports. Update changes name and mode only. To change the endpoint or domain, delete the route and create it again.

In the dashboard, the Routes tab lists each route with its local part, mode as a badge, and endpoint, filterable by mode. New route takes the local part, a domain, a mode, and (for Endpoint mode) the HTTP endpoint URL. When a server has no routes, the empty state reminds you that inbound mail to the server is refused until at least one route exists.

Examples

Route replies to your app over HTTP. Mail to support@acme.example is POSTed to your inbound handler:

terminal
curl -s -X POST \
  "$API/api/v2/admin/organizations/acme/servers/production/routes" \
  -H "X-Admin-API-Key: $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "support",
    "domain": "acme.example",
    "mode": "Endpoint",
    "endpoint_url": "https://app.acme.example/inbound"
  }'

Point the MX record for acme.example at your CamelMailer SMTP host, and mail to support@acme.example starts flowing to the endpoint.

Ingest DMARC aggregate reports. The internal target that feeds the DMARC compliance data:

terminal
curl -s -X POST \
  "$API/api/v2/admin/organizations/acme/servers/production/routes" \
  -H "X-Admin-API-Key: $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "dmarc",
    "domain": "acme.example",
    "endpoint_url": "internal://dmarc-reports"
  }'

Then point the rua= tag of your DMARC record at that address (dmarc@acme.example).

A note on catch-all

A route answers to an exact local part. The New route dialog shows support or *as a hint, yet the delivery path matches the recipient's local part exactly, so a route named * does not act as a wildcard catch-all today. To reach one route from many addresses, publish the direct-to-route address <token>@<route_domain> and send to it, or give each address its own route.