Email: domains, mailboxes & sending

Tandem gives an app three ways to do email:

  • Real mailboxes — full IMAP + SMTP + JMAP accounts (send, receive, store).
  • Transactional send API — a Resend-style HTTP lane for sending mail with no mailbox.
  • Inbound webhooks — receive a mailbox’s mail as HTTP POSTs instead of over IMAP.

Email is custom-domain-only: every mailbox and every From address lives on a domain the org owns, and that domain must be onboarded first.

Step 1 — Onboard a domain

{ "name": "add_email_domain", "arguments": { "projectId": "…", "domain": "example.com" } }

add_email_domain registers an AWS SES sending identity, tells the mail server to accept the domain, and returns the DNS records to publish: MX (inbound), plus DKIM CNAMEs, SPF, and DMARC (outbound). If the domain’s DNS is managed by Tandem, the records are created automatically; otherwise the response gives a copy-paste record list for the tenant’s DNS provider.

Then verify:

{ "name": "verify_email_domain", "arguments": { "domainId": "…" } }

verify_email_domain re-checks SES (identity + DKIM) and DNS (inbound MX). Mail only sends/receives once the domain is fully verified; if something is still failing the response re-lists the records to add. list_email_domains shows the status of every onboarded domain.

Step 2a — Provision a mailbox

{ "name": "provision_email",
  "arguments": { "projectId": "…", "domain": "example.com", "localPart": "hello", "attachServiceId": "…" } }

This creates a real mailbox (hello@example.com) on the self-hosted mail server and, when attachServiceId is set, injects the EMAIL_* runtime env vars (IMAP + SMTP + JMAP) on that service.

  • attach_email_to_service / detach_email_from_service inject or remove the EMAIL_* vars on any service (idempotent).
  • reveal_email_credentials returns the full connection profile (IMAP/SMTP host/port/secure/username/password + JMAP url/token) for manual setup in a mail app. Audit-logged.
  • rotate_email_credentials sets or auto-generates a new password and re-injects it into every attached service.
  • list_emails lists mailboxes with address, DNS status, and attachments (never passwords).

Step 2b — Or use the transactional send API (no mailbox)

Create a key and send over HTTP:

{ "name": "create_email_api_key",
  "arguments": { "projectId": "…", "name": "app-sender", "allowedDomains": ["example.com"] } }

The key is org-scoped, returned once (store it now), and optionally restricted to specific From domains via allowedDomains. Use it as a Bearer token:

curl -s https://portal.launchtandem.com/api/email/send \
  -H "Authorization: Bearer <the-key>" \
  -H "Content-Type: application/json" \
  -d '{ "from": "hello@example.com", "to": "user@dest.com",
        "subject": "Hi", "html": "<p>Hello</p>" }'

Request body fields:

Field Type Notes
from string local@domain or "Display Name" <local@domain>. Any local part works; the domain must be onboarded and verified for the org (and in the key’s allowedDomains, if set).
to, cc, bcc string or string[] Bare addresses (user@dest.com), up to 50 each.
reply_to (alias replyTo) string or string[] Up to 10 entries; each addr@domain or "Name" <addr@domain>. Not limited to your domains — point replies anywhere (e.g. each client’s own inbox). Send one of the two names, not both.
subject string Required.
html, text string At least one is required.

Addresses containing line breaks or other control characters are rejected (400 invalid_request / invalid_from). Unrecognised top-level fields are ignored, not rejected — double-check field names.

{ "from": "\"Lincoln Academy\" <admissions@example.com>",
  "to": "parent@dest.com",
  "reply_to": ["\"Lincoln Admissions\" <office@lincolnacademy.org>"],
  "subject": "Your application", "text": "Thanks for applying!" }

The From domain must be onboarded first. list_email_api_keys lists keys (name, prefix, allowed domains, last used, revoked) without the secret; revoke_email_api_key disables one immediately.

Step 3 — Receive mail over HTTP (inbound webhooks)

Instead of polling IMAP, have Tandem POST parsed mail to your app:

{ "name": "set_email_inbound_webhook",
  "arguments": { "mailboxId": "…", "url": "https://app.example.com/inbound" } }

When mail arrives, the platform POSTs a JSON copy:

{ "type": "email.inbound", "mailbox": "hello@example.com",
  "message": { "from": "…", "to": "…", "cc": "…", "subject": "…",
               "text": "…", "html": "…", "messageId": "…",
               "receivedAt": "…", "attachments": [] } }

set_email_inbound_webhook returns a signing secret once — verify the X-Tandem-Signature header (sha256=<hex HMAC-SHA256 of the raw body>) with it. get_email_inbound_webhook shows the config (secret never returned; call set again to rotate). remove_email_inbound_webhook stops forwarding — mail still lands in the mailbox.

External forwarding (off-platform destinations)

Forward mail to Gmail, HubSpot, or any external inbox:

{ "name": "set_email_forwarder",
  "arguments": { "organizationId": "…", "sourceAddress": "support@example.com",
                   "destinationAddress": "hub@external.com", "keepLocalCopy": true } }

When a mailbox exists at sourceAddress, mail is delivered locally and relayed (cPanel deliver-and-forward). When no mailbox exists, the address is forward-only (Stalwart mailing list with an external recipient). list_email_forwarders lists active forwards; remove_email_forwarder removes one.

Outbound relay preserves the original From header for the recipient. The platform relays through SES; envelope sender rewriting (SRS) depends on Stalwart’s outbound path — see LNCH-130 PR notes if SPF alignment at the destination is strict.

Loop protection rejects self-referential forwards and A↔B ping-pong pairs. Sieve scripts cap Received hops and skip re-forwarding DSN/bounce traffic.

Tearing down

  • remove_email_domain deletes the SES identity and Tandem-managed DNS. It refuses while any mailboxes still exist on the domain — delete those first.
  • delete_email deletes a mailbox (all stored mail is lost), removes the EMAIL_* vars from attached services, and tears down the domain’s DNS if it was the last mailbox. No undo. See Deleting resources.