Skip to content

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:

email:
  enabled: true

2. Redeploy:

mecca app deploy <app>

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:

  • to is required, 1–10 recipients (cc + bcc count toward the cap).
  • subject is required, ≤ 255 chars, no \r or \n (rejected as header-injection).
  • One of text or html is required.
  • Total body size is capped at 256 KB.
  • reply_to is optional; if present it must be a valid address. It overrides your app's configured reply_to default 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":     "<uuid>",
  "accepted_at":    "2026-06-20T08:55:12Z",
  "ses_message_id": "010001..."
}

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 429 until it drains.
  • Branches are metered independently — your dev branch's sends never consume main's quota. (Each deployed container's injected token carries its branch; nothing to configure.)
  • Every /v1/send response — success and 429 alike — carries the meter: X-Quota-Limit, X-Quota-Remaining, and X-Quota-Reset (seconds until the window next drains). Refusals also carry Retry-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.