Skip to content

Send email

Roadmap — Outbound Email P1 — transactional send with truthful delivery analytics.

Send transactional email through the same engine that delivers your webhooks: one accept → queue → deliver → retry → DLQ → replay pipeline, one delivery log, one set of rollups. An email is just a delivery whose final hop goes to Amazon SES instead of an HTTPS endpoint — so every message is idempotent, retried, logged, and replayable, and every SES lifecycle event (delivered, bounced, complained) is correlated back to the message and routable as an ordinary event.

Scope of P1

Transactional send + delivery-truth analytics: sent → delivered, bounce/complaint tracking, an org suppression list, per-message activity timelines, and funnel analytics in the console. No open/click tracking, broadcasts, or unsubscribe management in this phase.

Sending identities

You send from a verified sending identity — a domain SES has verified you own. Two paths:

IdentityDNSFrom address
Platformnone — provisioned for youno-reply@<org-slug>.<platform-send-domain>
Custom domainyour own domain, upgraded for sending (DKIM already verified + a MAIL FROM MX/SPF pair)any address @your-domain.com

Every org gets a platform identity lazily on first send — no setup required to start. To send from your own domain, verify it as an email domain, then enable Outbound sending on its detail page in the console (this adds the MAIL FROM records and re-runs verification).

Send one email

bash
curl -X POST https://api.emithook.com/v1/emails \
  -H "Authorization: Bearer $EK_KEY" \
  -H "Idempotency-Key: welcome-user-8f2a" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "no-reply@acme.send.emithook.com",
    "to": ["buyer@example.com"],
    "subject": "Your invoice is ready",
    "html": "<p>Thanks for your order — <a href=\"https://acme.com/i/INV-1\">view invoice</a>.</p>"
  }'
json
// → 202 Accepted
{ "id": "evt_01JX9..." }
  • from's domain must match one of your active sending identities, or the call returns 422 (details.check: from_address).
  • At least one of html / text is required. to/cc/bcc are arrays (≤ 50 recipients).
  • headers are additive custom X-* headers only — you can't override from/to/subject through them.
  • Send Idempotency-Key so a client retry can't double-send.
  • A recipient list that is entirely suppressed (see below) is accepted and stored as a terminal suppressed message rather than rejected; a partially-suppressed list sends only to the surviving recipients.

Attachments

Each attachment is either inline base64 or a public https URL Emithook fetches for you — exactly one of the two per item:

bash
curl -X POST https://api.emithook.com/v1/emails \
  -H "Authorization: Bearer $EK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "no-reply@acme.send.emithook.com",
    "to": ["buyer@example.com"],
    "subject": "Your invoice",
    "html": "<p>Invoice attached.</p>",
    "attachments": [
      { "filename": "invoice.pdf", "content_type": "application/pdf", "content": "JVBERi0xLjQK…" },
      { "url": "https://cdn.acme.com/terms.pdf" }
    ]
  }'
  • Limits: up to 20 attachments and 10 MB of decoded bytes across the whole request — and a batch shares one budget, so 100 messages get the same 10 MB and the same 20 URL fetches that one message gets, not 100× either. Over any of those is 422 with details.check: attachments. Above roughly 7 MB you cross SES's bandwidth-throttling line — still delivered, just paced by the provider.
  • A URL is fetched before the 202, not at delivery time. That keeps sending predictable: every retry and replay sends exactly the bytes fetched at accept, even if the origin changes or disappears. It also means a broken URL fails the call, where you can act on it, rather than surfacing later as a dead message.
  • Redirects are not followed. Signed object-store and CDN links often redirect; those are refused with a message telling you to pass the final URL, or to inline the bytes. Fetches are https only, never carry credentials, and are blocked from internal addresses.
  • filename is required for inline content and defaults to the URL's last path segment when fetching. content_id sets the part's Content-ID so HTML can reference an inline image as cid:logo.
  • File types are not policed — .zip, .csv and .xlsx are ordinary transactional attachments.

Batch

POST /v1/emails/batch accepts up to 100 messages; each is validated and suppression-checked independently, so one bad item never fails the rest:

bash
curl -X POST https://api.emithook.com/v1/emails/batch \
  -H "Authorization: Bearer $EK_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "emails": [ { "from": "no-reply@acme.send.emithook.com", "to": ["a@example.com"], "subject": "Hi", "text": "…" } ] }'
json
// → 202 — one result per input item, in order
{ "data": [ { "id": "evt_01JX9..." } ] }

Track a message

Every message carries a status and a lifecycle timeline — the SES facts (sent → delivered, or bounced/complained) correlated back as they arrive:

bash
curl https://api.emithook.com/v1/emails/evt_01JX9... \
  -H "Authorization: Bearer $EK_KEY"
json
{
  "id": "evt_01JX9...",
  "from": "no-reply@acme.send.emithook.com",
  "to": ["buyer@example.com"],
  "subject": "Your invoice is ready",
  "status": "delivered",
  "created_at": "2026-07-25T10:00:00.000Z",
  "attempts": [ { "number": 1, "status": 200, "duration_ms": 120, "at": "2026-07-25T10:00:01.000Z" } ],
  "timeline": [
    { "type": "sent", "at": "2026-07-25T10:00:01.000Z" },
    { "type": "delivered", "at": "2026-07-25T10:00:05.000Z" }
  ]
}

GET /v1/emails lists your messages, filterable by status (sending/delivered/bounced/complained/suppressed/failed/dlq) and since/until, cursor-paginated. Messages also remain visible in the general GET /v1/events — this is a more granular view, not a separate store. The console's Outbound email screen renders the same data as funnel tiles + a per-message timeline.

The suppression list

To protect your sender reputation, a hard bounce or a spam complaint auto-adds the recipient to your org's suppression list, and a suppressed address is never sent to again — the check runs at accept time and is re-checked on every retry and replay, so a listed address can never slip through. Manage the list directly:

bash
# List
curl https://api.emithook.com/v1/suppressions -H "Authorization: Bearer $EK_KEY"

# Add (always reason:"manual" — bounce/complaint entries are added automatically)
curl -X POST https://api.emithook.com/v1/suppressions \
  -H "Authorization: Bearer $EK_KEY" -H "Content-Type: application/json" \
  -d '{ "address": "opted-out@example.com" }'

# Remove (lets the address receive mail again)
curl -X DELETE https://api.emithook.com/v1/suppressions/esup_01JX9... \
  -H "Authorization: Bearer $EK_KEY"

SDK

ts
import { readFile } from 'node:fs/promises'
import { EmithookClient } from '@emithook/sdk'
const emithook = new EmithookClient({ apiKey: process.env.EK_KEY! })

const pdf = await readFile('./invoice.pdf')

const { id } = await emithook.emails.send({
  from: 'no-reply@acme.send.emithook.com',
  to: ['buyer@example.com'],
  subject: 'Your invoice is ready',
  html: '<p>Thanks for your order.</p>',
  attachments: [
    { filename: 'invoice.pdf', content_type: 'application/pdf', content: pdf.toString('base64') },
    { url: 'https://cdn.acme.com/terms.pdf' },   // fetched at accept time
  ],
}, { idempotencyKey: 'welcome-user-8f2a' })

const email = await emithook.emails.get(id)          // status + timeline
for await (const e of emithook.iterateEmails({ status: 'bounced' })) console.log(e.id)

await emithook.suppressions.create({ address: 'opted-out@example.com' })

CLI

bash
emithook email send \
  --from no-reply@acme.send.emithook.com \
  --to buyer@example.com \
  --subject "Your invoice is ready" \
  --text "Thanks for your order." \
  --attach ./invoice.pdf --attach-url https://cdn.acme.com/terms.pdf

emithook email list --status bounced --limit 20

Deliverability

Sender reputation is shared. SES watches two account-level rates: a hard bounce rate (over 2% is the warn line; over 5% SES may pause sending) and a complaint rate (over 0.1% is the danger line — it risks the whole account). Send only to recipients who expect your mail, honour the suppression list, and watch the bounce/complaint tiles on the console's Outbound email screen — they colour warn/danger at exactly these lines. If a rate spikes, self-host operators should follow the email-deliverability runbook (in the repo under docs/runbooks/).

Next

Emithook · a Finnoto product