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
\ror\n. textand/orhtmlrequired; both optional individually.- Max body 256 KB.
Fromis 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¶
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¶
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¶
Responds 200:
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¶
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.