Sending email from your app¶
Your AppMecca app can send transactional email (signup, password reset, receipts, alerts) without storing any mail-provider credentials or configuring a mail service yourself. You opt in with one line of YAML, redeploy, and POST JSON to a relay endpoint that the platform stamps into your container's environment.
The relay is the AppMecca email relay (the appmecca-mailer service). It validates your bearer token, server-stamps the
From: header, checks the suppression list, enforces per-app quotas, then dispatches on its own scoped credentials. Your
container never holds a mail credential of any kind.
Quickstart¶
1. Add email.enabled to your appmecca.yaml:
2. Redeploy:
3. Confirm the agent injected three env vars into every container of the app:
mecca app exec <app> -- env | grep APPMECCA_MAILER_
# APPMECCA_MAILER_URL=https://mail.<env>.appmecca.net/v1/send
# APPMECCA_MAILER_TOKEN=...
# APPMECCA_MAILER_FROM=<app>@<env>.appmecca.net
4. Smoke-test it from your laptop:
mecca app exec myapp -- sh -c '
curl -sS -X POST "$APPMECCA_MAILER_URL" \
-H "Authorization: Bearer $APPMECCA_MAILER_TOKEN" \
-H "X-AppMecca-App: $APPMECCA_APP" \
-H "Content-Type: application/json" \
-d "{\"to\":[\"you@example.com\"],\"subject\":\"AppMecca smoke test\",\"text\":\"It works.\"}"
'
That POSTs a real send through the relay from inside your own container, using the injected env vars, and prints the message id on success. The recipient must equal the email address you logged in with — this is for self-verification only.
The three injected env vars¶
| Variable | Value | What it's for |
|---|---|---|
APPMECCA_MAILER_URL |
https://mail.<env>.appmecca.net/v1/send |
The relay endpoint your code POSTs to. |
APPMECCA_MAILER_TOKEN |
Per-app bearer token, derived from an env-wide HMAC key. | Authenticates the call. Treat as a secret. |
APPMECCA_MAILER_FROM |
<app_name>@<env>.appmecca.net |
The canonical sender address the relay will stamp on every outbound. |
You never need to read mail-provider credentials. You never derive an SMTP password. The relay is the only component that talks to the upstream mail service.
Configuring your sender (optional)¶
Two optional fields in your appmecca.yaml change how your mail looks. Both are optional;
an app that sets neither behaves exactly as it does today.
email:
enabled: true
from_display_name: "Acme Support" # shown beside the address
reply_to: "support@acme.com" # default Reply-To for this app
| Field | Effect |
|---|---|
from_display_name |
Recipients see Acme Support <myapp@qa.appmecca.net> instead of the bare address. |
reply_to |
Replies go here by default, instead of to the (unmonitored) sender address. |
The sender ADDRESS never changes
from_display_name adds a name in front of your address. The address itself stays
server-derived (APPMECCA_MAILER_FROM) and cannot be overridden — that is what keeps it
provably yours.
A reply_to on an individual message always wins
The config value is a default. If a message carries its own reply_to, that one is
used — so a password-reset mail can point somewhere other than your general inbox
without changing your app's configuration.
Both take effect on your next deploy, not immediately: they are resolved when your app
deploys and recorded for the relay to read. You can set them in appmecca.yaml or edit them
on the dashboard's Config tab; a dashboard edit wins over the file for that field until
you revert it, and the tab shows you when the two disagree.
Line breaks and control characters are refused in both fields — they would let a message forge extra mail headers.
Sending a message¶
curl¶
curl -sf \
-H "Authorization: Bearer $APPMECCA_MAILER_TOKEN" \
-H "X-AppMecca-App: $APPMECCA_APP" \
-H "Content-Type: application/json" \
-d '{
"to": ["user@example.com"],
"subject": "Welcome",
"text": "Thanks for signing up.",
"html": "<p>Thanks for signing up.</p>"
}' \
"$APPMECCA_MAILER_URL"
Python (requests)¶
import os
import requests
resp = requests.post(
os.environ["APPMECCA_MAILER_URL"],
headers={
"Authorization": f"Bearer {os.environ['APPMECCA_MAILER_TOKEN']}",
"X-AppMecca-App": os.environ["APPMECCA_APP"],
"Content-Type": "application/json",
},
json={
"to": ["user@example.com"],
"subject": "Welcome",
"text": "Thanks for signing up.",
"html": "<p>Thanks for signing up.</p>",
},
timeout=10,
)
resp.raise_for_status()
print(resp.json()["message_id"])
Node (fetch)¶
const resp = await fetch(process.env.APPMECCA_MAILER_URL, {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.APPMECCA_MAILER_TOKEN}`,
"X-AppMecca-App": process.env.APPMECCA_APP,
"Content-Type": "application/json",
},
body: JSON.stringify({
to: ["user@example.com"],
subject: "Welcome",
text: "Thanks for signing up.",
html: "<p>Thanks for signing up.</p>",
}),
});
if (!resp.ok) throw new Error(`mailer ${resp.status}: ${await resp.text()}`);
const { message_id } = await resp.json();
Required headers¶
| Header | Required | What it's for |
|---|---|---|
Authorization: Bearer <APPMECCA_MAILER_TOKEN> |
Yes | Auth. The relay rejects calls without it with 401. |
X-AppMecca-App: <app_name> |
Yes | Tells the relay which app's token to expect. Send the injected APPMECCA_APP: the relay matches your app's canonical, group-prefixed name exactly, so the short name you registered with is refused. Missing or mismatched, it is a 401. |
Content-Type: application/json |
Yes | The relay only accepts JSON. |
There is no send de-duplication yet
The relay does not implement idempotency keys — every accepted POST is a
distinct send. If your caller retries (a timeout, a queue redelivery), the
recipient gets a second copy. Guard against duplicates on your side, e.g.
by recording the returned message_id before you retry.
Request body¶
{
"to": ["user@example.com"],
"cc": [],
"bcc": [],
"subject": "Welcome",
"text": "Plain body",
"html": "<p>HTML body</p>",
"reply_to": "support@example.com"
}
Rules:
tois required, 1–10 recipients (cc+bcccount toward the cap).subjectis required, ≤ 255 chars, no\ror\n(rejected as header-injection).- One of
textorhtmlis required. - Total body size is capped at 256 KB.
reply_tois optional; if present it must be a valid address. It overrides your app's configuredreply_todefault for this message only.- Custom headers are not supported.
- The
From:header is server-derived to your app's canonical sender (APPMECCA_MAILER_FROM). You cannot override it.
Response¶
message_id is the relay's own id (use it to correlate with mecca email events). ses_message_id is the id the upstream
mail service assigned — it's the value that bounce and complaint notifications refer back to. (The field name is literal; read
it verbatim from the JSON.)
Error codes¶
| Code | Cause | What to do |
|---|---|---|
400 |
Malformed body, header-injection in subject / to / reply_to, invalid recipient, > 10 recipients. |
Fix the request; this is a client bug. |
401 |
Missing / bad bearer token, missing X-AppMecca-App header, or the named app isn't registered in this environment. |
Confirm APPMECCA_MAILER_TOKEN and the header are both set. After your administrator rotates the relay key, you have 1 h before the old token stops working; redeploy. |
403 |
The recipient is on your app's suppression list (a prior bounce / complaint). | List with mecca email suppress list <app>; remove with mecca email suppress remove <app> <recipient> if appropriate. |
413 |
Body exceeds the 256 KiB cap. | Trim the payload — usually an oversized inline HTML or embedded image. |
429 |
Quota exceeded — 2,400 sends per rolling 24 h per app-branch (default), or the env-wide ceiling. | Respect Retry-After; the X-Quota-* headers carry the meter. |
502 |
Upstream mail-service failure (rejection of an unverified recipient while the environment is still sandboxed, service paused, throttling). | If the environment is still sandboxed, ask your administrator to move it to production sending. Otherwise retry. |
503 |
The environment-wide email kill switch is on — an administrator setting on the environment, not anything in your appmecca.yaml. |
Ask your administrator. |
A missing email.enabled is not an error code
If you left email.enabled: true out of your appmecca.yaml (or haven't
redeployed since adding it), the relay env vars are never injected — so
your code fails on an unset APPMECCA_MAILER_URL rather than getting an
HTTP status back. Check with the mecca app exec … env command below.
Suppression¶
When a recipient hard-bounces or files a complaint, the upstream mail service feeds the event back to AppMecca, and the
platform adds (your_app, that_recipient) to its suppression list. The relay consults that list before every send — a
suppressed recipient gets 403, and the refusal is recorded as a suppressed_at_send row in your event log (best-effort —
see the note below).
View + manage:
mecca email status <app> # enablement + sender address + recent totals
mecca email events <app> # last 50 send / bounce / complaint / suppressed_at_send rows
mecca email events <app> -n 200 # widen the window (1–500)
mecca email suppress list <app> # who's currently blocked
mecca email suppress add <app> <recipient> # block a recipient yourself
mecca email suppress remove <app> <recipient> # un-block (only do this if you fixed the underlying problem)
The event log is best-effort, retention-bounded, and truncated at your -n
The relay's per-send audit write can never affect the response your app
receives, so under a platform-side failure rows can be dropped —
the log is strong evidence of what DID happen and weak evidence of what
did not. When the relay is failing to record, mecca email events (and
the dashboard's email tab) shows a loud warning naming the cause.
Rows are pruned after 90 days, and a listing shows only the newest
-n rows (default 50, max 500) — an empty or short log is not proof
that nothing was sent.
Events recorded before 2026-08-10 are gone
A defect in the relay (BUGS #823) meant no event row was ever written — on any environment, for the whole life of the feature. Your event log therefore starts from the fix, not from your first send. There is nothing to backfill from: the rows were never written.
The same defect made a suppressed or quota-blocked send answer 500
instead of 403 / 429. If you built a retry that keys on 5xx, it
was retrying refusals that will now be refused properly — check it
still does what you want.
Quotas¶
- 2,400 accepted sends per rolling 24 hours, per app per branch (default; configurable per env by your administrator). The window is trailing — it drains continuously as your oldest sends age past 24 h, never at a fixed reset time.
- Bursts are unrestricted. There is no per-minute limit: send as fast as you like until the window is spent, then the relay returns
429until it drains. - Branches are metered independently — your
devbranch's sends never consumemain's quota. (Each deployed container's injected token carries its branch; nothing to configure.) - Every
/v1/sendresponse — success and429alike — carries the meter:X-Quota-Limit,X-Quota-Remaining, andX-Quota-Reset(seconds until the window next drains). Refusals also carryRetry-After. - Check the meter any time with
mecca email status <app>(add-b <branch>for a non-production branch). - The gate is PG-backed and survives a container restart.
Sender address¶
In v1 the relay only accepts From: <app>@<env>.appmecca.net — exactly what APPMECCA_MAILER_FROM carries. You don't set it
yourself; the relay stamps it on every outbound and refuses any other value with 400. Recipients see the sender as
yourapp@dev.appmecca.net (or <env>.appmecca.net for whichever environment you deployed to).
Sending from a custom domain you own (e.g. noreply@yourbrand.com) is out of scope for v1. A separate plan handles it
in an isolated sending environment, so a bad actor on a tenant-branded domain can't damage the platform's main sending
reputation.
Authentication is handled for you¶
You do not publish any DNS for the v1 sender. infra setup publishes the environment's whole sender policy — the three
DKIM CNAMEs, an SPF record (v=spf1 include:amazonses.com ~all) and a DMARC record (v=DMARC1; p=none) — into the
environment's own zone, and every message is DKIM-signed with d=<env>.appmecca.net, which is what makes DMARC pass.
⚠ If your mail is landing in spam, check the folder, not the API response. A message the relay accepted has a real
message id and an email_events row reading sent; none of that tells you where it was filed. Receivers junk unauthenticated
or low-reputation mail silently rather than bouncing it, so "no bounce recorded" is not evidence of delivery. Open the
message in the recipient's client and read the authentication results (in Gmail: ⋮ → Show original); you want
DKIM: PASS and DMARC: PASS. SPF reporting an amazonses.com domain there is expected and correct — the envelope
sender is Amazon's, and DMARC passes on the DKIM alignment instead.
A brand-new environment also has no sending reputation, which no DNS record can create. Early volume from a never-used subdomain can be filed as spam even with all three records passing; that settles with sustained low-complaint sending, not with more configuration.
Smoke-testing from your laptop¶
The fastest way to confirm the whole pipeline works end-to-end without writing app code:
mecca app exec myapp -- sh -c '
curl -sS -X POST "$APPMECCA_MAILER_URL" \
-H "Authorization: Bearer $APPMECCA_MAILER_TOKEN" \
-H "X-AppMecca-App: $APPMECCA_APP" \
-H "Content-Type: application/json" \
-d "{\"to\":[\"you@example.com\"],\"subject\":\"AppMecca smoke test\",\"text\":\"It works.\"}"
'
The recipient must equal the email address you logged in with — this is for self-verification only.
That shells into a running container and POSTs a real message through the relay
using the injected env vars. On success you'll see a message_id and an email
arrives in your inbox. On any error the relay's own JSON tells you which gate
failed (auth / suppression / quota / sandboxed environment).
mecca app test-email does the same in one command, and the dashboard's
Email tab has a Send test email button that runs the identical check.
Neither needs curl in your image.
mecca app test-email you@example.com
mecca app test-email you@example.com -s api # pick a service
mecca app test-email you@example.com --no-follow # queue it and print the action id
Run it from inside your app's git repository. It finds the app by your
repository's name (from its origin remote), so it cannot find one you
registered under a different name — use the mecca app exec form above for
that. It refuses any recipient other than the address you are logged in with
(the dashboard always sends to that address), and sends from your app's
front-door service unless you pass -s <service>.
How it sends without installing anything¶
The platform never installs software in your container. Instead the test
runs a short sh script inside the container that uses the first HTTP client it
finds, in this order: curl, then python3, then node (only a Node version
that has a built-in fetch). The first line of the result says what it found:
Checked api: found python3.
Relay: accepted (200).
Sent — the relay recorded message 0100019a...
Accepted is not delivered — check your inbox.
The platform also checks each service once per deploy and records what it
found, so the dashboard's service picker can show it in advance (for example
api — python3). That record is only a hint: every send checks again.
Reading the result¶
| Result | What it means | What to do |
|---|---|---|
| Sent | The relay accepted the message and recorded it in its event log. | Check your inbox. Accepted is not the same as delivered. |
| Unconfirmed | The container reported a 2xx, but the relay's event log has no matching record yet. | Check your inbox and mecca email events <app>. It is not a failure. |
| No HTTP client in this image | The container has none of curl, python3 or node. |
Pick another service with -s, or add one of them to your image and redeploy. |
| No shell in this image | The image has no /bin/sh (a distroless image, for example), so the test cannot run inside it. |
Pick a service whose image has a shell. |
| The relay refused the request (4xx/5xx) | The relay answered with an error. | See the troubleshooting table below. The exec logs carry the relay's reply. |
The command exits 0 only when the result is Sent. With --json it prints
the outcome (sent, unconfirmed, no-client, no-shell, …), the client it
used, the HTTP status and the message id.
Troubleshooting¶
| Symptom | Likely cause | Next step |
|---|---|---|
APPMECCA_MAILER_* missing in the container env. |
The deploy didn't pick up email.enabled: true — yaml not committed, or the deploy failed silently. |
mecca app exec <app> -- env \| grep MAILER_; if empty, redeploy. |
Relay returns 401. |
Token mismatch — usually a stale token after a key rotation, a missing X-AppMecca-App header, or a header naming an app that isn't registered here. |
Redeploy to pick up the current token. If the key was just rotated, you have 1 h. |
Relay returns 403. |
The recipient is on the suppression list. | mecca email suppress list <app>; remove the entry only once you've fixed the underlying bounce/complaint. |
Relay returns 413. |
Body over the 256 KiB cap. | Trim the payload — usually inline HTML or an embedded image. |
Relay returns 429. |
Your app-branch spent its rolling-24h quota (or the env ceiling is reached). | Back off (the response includes Retry-After and the X-Quota-* meter). Ask your administrator if you legitimately need a higher limit. |
Relay returns 502. |
Upstream mail service — usually the environment is still sandboxed and you tried to send to an unverified recipient. | Ask your administrator to move the environment to production sending. |
| Recipient got the same email twice. | The relay has no de-duplication; a client retry sends again. | De-duplicate on your side before retrying. |