Skip to content

Email + Mailer Endpoints

The tenant-app email-send relay, plus the per-app email identity, event and suppression surfaces.

Mailer relay (data plane)

The mailer is a dedicated platform service. Tenant apps with email: enabled: true in their appmecca.yaml get three env vars injected by the agent at deploy time (APPMECCA_MAILER_URL, APPMECCA_MAILER_TOKEN, APPMECCA_MAILER_FROM) and POST to the mailer's HTTP endpoint. The mailer is NOT a route on appmecca-api; it has its own hostname mail.{env}.{domain}.

Method Path Auth Description
POST https://mail.{env}.{domain}/v1/send Bearer token Send a transactional email
GET https://mail.{env}.{domain}/healthz none Process-liveness probe

POST /v1/send

Headers:

Header Value Required
Authorization Bearer <APPMECCA_MAILER_TOKEN> yes
X-AppMecca-App tenant app name (matches the token's subject) yes
Content-Type application/json yes
X-Idempotency-Key UUID (optional 24h dedupe) no

Request body:

{
  "to":       ["user@example.com"],
  "cc":       [],
  "bcc":      [],
  "subject":  "Welcome",
  "text":     "Plain body",
  "html":     "<p>HTML body</p>",
  "reply_to": "support@example.com"
}

Constraints:

  • 1–10 recipients total across to + cc + bcc.
  • Subject ≤ 255 characters, no \r or \n.
  • text and/or html required; both optional individually.
  • Max body 256 KB.
  • From is server-derived as {app}@{env}.{domain} — tenant cannot override.

Response — 200 OK:

{
  "message_id":     "550e8400-e29b-41d4-a716-446655440000",
  "accepted_at":    "2026-06-20T17:30:00Z",
  "ses_message_id": "010001..."
}

Every response (success and 429) carries the rolling-24h quota meter for the sending app-branch, so app code can self-monitor with zero extra calls:

Header Meaning
X-Quota-Limit The per-app-branch cap over the rolling 24 h window (default 2,400)
X-Quota-Remaining Sends left in the window right now
X-Quota-Reset Seconds until the oldest window bucket ages out (when Remaining next increases)

Error codes:

Status Reason Action
400 Bad input (missing field, oversize body, header injection, invalid recipient) Fix the payload
401 Missing/bad bearer token, unknown app, or wrong-app token Confirm the deploy injected the env vars
403 Recipient is on the per-app suppression list mecca email suppress list <app>
409 Idempotency replay (duplicate X-Idempotency-Key) Returns the original message_id
429 Rolling-24h quota exceeded — 2,400 per app per branch (default), or the env-wide ceiling. No per-minute limit; bursts inside the window are unrestricted. Retry after Retry-After seconds; the X-Quota-* headers carry the meter
502 Upstream delivery failure (sending paused or throttled) appmecca mailer status -e <env>
503 Env-level kill switch (email.enabled: false for the environment) Operator-controlled

GET /healthz

Process liveness only — never touches the database or the upstream sender. Returns {"status":"ok"} with 200 OK.


Admin (control plane)

Routes mounted on appmecca-api, NOT on the mailer. Used by appmecca mailer status and other admin tooling.

Method Path Scope Description
GET /api/admin/mailer/top-apps?limit=10 platform-admin Top-N highest-volume app-branches, rolling 24 h

GET /api/admin/mailer/top-apps

Returns the top-N app-branches by accepted sends over the trailing 24 h window (quotas are per app per branch). During the #314 rollout the legacy calendar-day counters are unioned in, so an old relay build's sends still appear. A recently-disabled app is not filtered out — its refusals are exactly the diagnostic for "a tenant tried to send while disabled".

Query parameters:

Parameter Type Default Description
limit int 10 Number of rows to return (1–100)

Response — 200 OK — a bare array:

[
  {
    "app_name": "acme-billing",
    "app_id": "550e8400-e29b-41d4-a716-446655440000",
    "branch": "main",
    "sent_count": 1843,
    "refused_count": 12,
    "last_send_at": "2026-06-20T17:29:11Z"
  },
  {
    "app_name": "acme-marketing",
    "app_id": "660e8400-e29b-41d4-a716-446655440001",
    "branch": "dev",
    "sent_count": 422,
    "refused_count": 0,
    "last_send_at": "2026-06-20T17:25:03Z"
  }
]

last_send_at is null for an app-branch with only refusals in the window.

403 Forbidden if the caller isn't a platform admin.


Per-app email surfaces

Identities, events, suppressions and volume for one app. Every route is group-scoped and slug-resolving; branch is optional and resolves across any branch when omitted.

Method Path Scope Description
GET /api/apps/{app}/email/domains?branch= read List the app's sending-domain identities
GET /api/apps/{app}/email/quota?branch= read Rolling-24h send-quota meter for one branch (limit_24h, used_24h, refused_24h, remaining, resets_in_seconds)
POST /api/apps/{app}/email/domains?branch= deploy Request a new sending domain (returns DKIM CNAMEs) → 201
DELETE /api/apps/{app}/email/domains/{identity_id}?branch= deploy Remove a sending domain → 204
GET /api/apps/{app}/email/events?limit=&branch= read Recent send / bounce / complaint / delivery events
GET /api/apps/{app}/email/log-health?branch= read Whether the relay's best-effort audit write is currently failing (write_failing, last_reported_at, retention_days)
GET /api/apps/{app}/email/volume?days=&branch= read Per-day send-volume aggregate
GET /api/apps/{app}/email/suppressions?branch= read Per-app suppression list
POST /api/apps/{app}/email/suppressions?branch= deploy Add a manual suppression → 201
DELETE /api/apps/{app}/email/suppressions/{recipient}?branch= deploy Remove a suppression → 204
POST /api/internal/ses/event (signed webhook) Bounce / complaint / delivery notifications

Add a sending domain

{"domain": "mail.example.com"}

Responds 201 with the identity row, including the dkim_cnames the customer must publish:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "domain": "mail.example.com",
  "tier": "custom",
  "status": "pending_dns",
  "dkim_status": "pending",
  "dkim_cnames": [{"name": "…._domainkey.mail.example.com", "value": "….dkim…"}],
  "configuration_set_name": "…",
  "created_at": "2026-06-20T17:00:00Z"
}

DKIM alone will not get a custom domain into the inbox

The platform can only publish DNS in zones it owns, so for a custom sending domain it hands you the DKIM CNAMEs and stops there — it cannot publish SPF or DMARC on your behalf. Publish both yourself, alongside the CNAMEs above:

mail.example.com          TXT   "v=spf1 include:amazonses.com ~all"
_dmarc.mail.example.com   TXT   "v=DMARC1; p=none"

Without a DMARC record the domain is unauthenticated by policy even though DKIM passes, and major receivers (Gmail / Google Workspace among them) silently junk the mail rather than bouncing it — so email/events shows sent with no bounce and nothing looks wrong. BUGS #927 records that failure happening on the platform's own domain. Start at p=none and only tighten to quarantine / reject once you have observed real traffic. The platform-owned <env>.appmecca.net domain gets both records automatically from infra setup; this note is about domains you own.

Events

GET /api/apps/{app}/email/events?limit=100

limit is 1–500, default 100. Each row carries event_type, recipient, timestamp, message_id, and — for bounces — bounce_type / bounce_subtype.

The response is a bare list capped at limit, with no pagination signal — compare the returned row count against the limit you requested to detect truncation. The log itself is best-effort (the relay's audit write can never affect a send's response, so rows can be dropped under a platform-side failure) and rows are pruned after 90 days — pair this endpoint with log-health when the completeness of the answer matters.

Log health

GET /api/apps/{app}/email/log-health

Responds 200:

{"write_failing": false, "last_reported_at": null, "retention_days": 90}

write_failing is true while the platform's mailer_event_write_failing warning is unresolved (the relay is currently failing to record audit rows); last_reported_at is the warning's most recent report time. ⚠ false does not assert the log is complete — sub-threshold intermittent loss is not alarmed — it only ever adds the loud arm.

Volume

GET /api/apps/{app}/email/volume?days=30

days is 1–365, default 30.

{
  "app_name": "acme-billing",
  "environment": "dev",
  "window_days": 30,
  "by_day": {"2026-06-20": {"send": 412, "bounce": 2}},
  "totals": {"send": 9821, "bounce": 37, "complaint": 1}
}

Suppressions

A recipient lands on the suppression list automatically on a permanent bounce or any complaint, and the relay refuses to send to them (403). Add one manually with {"recipient": "user@example.com"}, remove it with the DELETE route.