API reference
The API contract for HAMANI Email Engine as actually implemented in src/handler.ts, src/send/*.ts and src/auth/*.ts — every shape below is read off that code, not aspirational. Two audiences share one keyed surface: our own platforms (Part A, ownerType: "platform") and pay-as-you-go customers (Part B, ownerType: "customer", surfaces landing in later phases).
Status: P1 (keyed send API) + P2 (exactly-once queue) + P3 (Lambda senders → SES) + P5 (suppression/events) + P6 (templating/i18n) + P8 (customer self-serve domain verification) + P9–P11 (Stripe billing, usage metering, AUD pricing) + P13–P17 (abuse/reputation, account health, AU Spam Act, Living Circle, runtime-shape verification) + P18 (fair-use monthly bulk cap) + P19 (infrastructure as code,
infra/) + P12 (dashboard: hub-seam read-API — real per-domain analytics, open/click ingestion, suppression listing, domain health, SPF/DMARC records — plus batch send, template CRUD, and scheduled sends) + P20 (inbound mailboxes — receive: SES receipt → MIME parse → per-address mailbox store + the hub webmail seam) built and gate-green. This contract grows with each phase and never weakens a rule inCONSTITUTION.md.
Transport
A single AWS Lambda Function URL. Method + path decide routing. Every response is application/json.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /healthz | none | readiness — configured-driver health |
POST | /v1/admin/keys | X-Admin-Secret header | issue an API key (platform onboarding / customer provisioning) |
POST | /v1/send | Authorization: Bearer hme_... | enqueue one email for delivery |
POST | /v1/send/batch | Authorization: Bearer hme_... | enqueue a broadcast to many recipients (P12) |
GET | /v1/scheduled | Bearer, scope send | list this owner's scheduled sends (P12) |
GET/DELETE | /v1/scheduled/:id | Bearer, scope send | fetch / cancel a scheduled send (P12) |
POST | /v1/unsubscribe?t=<token> | signed token (query) | one-click unsubscribe (RFC 8058, AU Spam Act) |
POST/GET/PUT/DELETE | /v1/templates[/:id] | Bearer, scope templates | CRUD a customer's own branded, reusable templates (P12) |
POST | /v1/domains | Bearer, scope domains | register a customer's own domain — returns the full record set to publish: DKIM CNAMEs + SPF + DMARC + the receiving MX (P8, §2) |
GET | /v1/domains/:domain | Bearer, scope domains | verification status + the LAST per-record DNS read (no DNS I/O of its own) |
POST | /v1/domains/:domain/recheck | Bearer, scope domains | "check now": reads the published DNS per record AND polls SES; flips to verified ONLY on a genuine SES confirmation |
POST | /v1/admin/customers | X-Admin-Secret header | provision a customer's real Stripe billing identity (P9) — idempotent |
POST | /v1/billing/webhook | Stripe-Signature header (HMAC) | Stripe billing webhook receiver (P9) |
GET | /v1/dashboard/analytics | X-Hamani-Hub-Signature (HMAC) | per-owner send funnel by domain (P12) |
GET | /v1/dashboard/domains | X-Hamani-Hub-Signature (HMAC) | per-owner domain health + DNS records (P12) |
GET | /v1/dashboard/suppressions | X-Hamani-Hub-Signature (HMAC) | global suppression list, hashed + paginated (P12) |
GET | /v1/dashboard/overview | X-Hamani-Hub-Signature (HMAC) | combined hub-landing summary (P12) |
GET | /v1/dashboard/keys | hub HMAC + ?ownerId= | list this owner's API keys, secret-free (self-service) |
POST | /v1/dashboard/keys | hub HMAC + ?ownerId= | mint a send-scoped customer key; returns the secret ONCE. Body { label } |
POST | /v1/dashboard/keys/:id/revoke | hub HMAC + ?ownerId= | revoke one of this owner's keys (a key that is not theirs is a 404) |
GET | /v1/mailbox/:addr/messages | X-Hamani-Hub-Signature (HMAC) | list a mailbox folder (P20) |
GET | /v1/mailbox/:addr/messages/:id | X-Hamani-Hub-Signature (HMAC) | read one message (bodies inlined) (P20) |
GET | /v1/mailbox/:addr/messages/:id/attachments/:n | X-Hamani-Hub-Signature (HMAC) | stream an attachment (P20) |
POST | /v1/mailbox/:addr/messages/:id/read | X-Hamani-Hub-Signature (HMAC) | mark read/unread (P20) |
POST | /v1/mailbox/:addr/messages/:id/folder | X-Hamani-Hub-Signature (HMAC) | move folder, e.g. trash (P20) |
POST | /v1/mailbox/:addr/send | X-Hamani-Hub-Signature (HMAC) | compose/reply from a mailbox; stores a Sent copy (P20) |
GET | /v1/mailbox/:addr/search?q= | hub HMAC + ?ownerId= | search a mailbox (§4) |
POST | /v1/mailbox/:addr/drafts | hub HMAC + ?ownerId= | save or replace a draft (§4) |
POST | /v1/mailbox/:addr/messages/:id/spam | hub HMAC + ?ownerId= | mark as spam — files it AND suppresses the sender (§4) |
GET/POST | /v1/mailboxes | hub HMAC + ?ownerId= | list / create mailboxes on this account's verified domains (§3) |
GET/PATCH/DELETE | /v1/mailboxes/:address | hub HMAC + ?ownerId= | read / update (name, quota, aliases, signature, auto-reply, status) / delete a mailbox (§3) |
POST | /v1/mailboxes/:address/password | hub HMAC + ?ownerId= | set or reset the mailbox password (§3) |
GET/PUT/DELETE | /v1/domains/:domain/catch-all | hub HMAC + ?ownerId= | read / set / clear a domain catch-all (§3) |
**Every /v1/mailbox/* and /v1/mailboxes/* route now requires ?ownerId=**, the same tenancy parameter the dashboard routes take. This is a BREAKING change to the webmail seam and it closes a real hole: before §3 those routes verified the hub's signature and then read whatever address the path named. The signature proves the caller is the hub; it says nothing about which customer the hub is acting for, so any hub bug — or anyone holding the hub secret — could read any tenant's mail by guessing an address. Ownership resolves from the mailbox account when one exists, and otherwise from the owner of the address's verified domain, so mailboxes created before §3 keep working. A mailbox belonging to another tenant answers 404 mailbox_not_found, identical to one that does not exist — a distinguishable refusal would be an address-enumeration oracle.
Every POST route above, plus GET /v1/domains/:domain, is throttled per source IP before authentication (PRE_AUTH_RATE_LIMIT_PER_MINUTE, default 300) — an unauthenticated flood can't amplify key-store reads and the admin secret can't be brute-forced at line speed. Over the limit ⇒ 429 rate_limited.
Authentication
Authorization: Bearer hme_<keyId>_<secret>. The secret is SHA-256 hashed at rest; lookups are O(1) by keyId; comparison is timing-safe. A key carries scopes (send, domains, templates) and allowedDomains (the domains it may send FROM — may be empty at issuance: a self-serve customer key is provisioned with scopes: ["domains"] and zero domains, then gains sending rights the moment its own domain verifies via POST /v1/domains + /recheck; see P8).
| Status | error | Meaning |
|---|---|---|
| 401 | missing_api_key | no/!bearer Authorization header |
| 401 | invalid_api_key | key not recognized or wrong secret |
| 401 | api_key_revoked | key was revoked |
| 403 | insufficient_scope | valid key lacks the required scope |
POST /v1/admin/keys
Header X-Admin-Secret: <HAMANI_EMAIL_ADMIN_SECRET> (constant-time compared; no length leak). Unset secret ⇒ always 503 admin_key_issuance_not_configured.
Request: ``json { "ownerType": "platform", "ownerId": "hamani-crm", "label": "crm-prod", "scopes": ["send"], "allowedDomains": ["hamanicrm.com.au"] } ` Response 201 (the key is shown exactly once): `json { "keyId": "...", "apiKey": "hme_..._...", "ownerType": "platform", "ownerId": "hamani-crm", "scopes": ["send"], "allowedDomains": ["hamanicrm.com.au"], "warning": "store this key now — it is shown exactly once and is not recoverable" } ``
POST /v1/send (scope: send)
A superset of hamani-crm-saas/lib/email/send.ts, so a platform swaps one transport line and deletes nothing.
Request (strict — unknown keys rejected): ``json { "to": "[email protected]", "from": "HAMANI CRM <[email protected]>", "subject": "Welcome", "html": "<p>…</p>", "text": "…", "templateId": "welcome", "data": { "name": "Sam" }, "locale": "en-AU", "category": "transactional", "replyTo": "[email protected]", "headers": { "List-Unsubscribe": "<https://…/u>" }, "idempotencyKey": "order-1001" } `` Rules enforced:
to/from/replyToaccept a bare address orName <addr>; the parser rejects RFC-5322 specials (comma, parens, brackets, colon, semicolon, backslash) — no From address-list injection.- At least one of
html/text/templateIdis required. subjectmay not contain control characters (no CRLF header injection).headersare allowlisted — onlyList-*,Precedence,Auto-Submitted, orX-*custom headers, no newlines. A caller can never set From/Sender/ Reply-To/Return-Path/etc. to spoof another tenant.from's domain must EITHER be in the key'sallowedDomainsOR be a domain this key's owner has self-serve verified viaPOST /v1/domains(P8) — fail closed otherwise.idempotencyKeyis optional; if omitted the engine derives a stable one from the message content. It is namespaced by the key internally, so a human-friendly value never collides across tenants.
Responses:
| Status | body status/error | Meaning |
|---|---|---|
| 202 | status: "queued" | accepted and enqueued; messageId + idempotencyKey returned. A non-transactional send also carries fairUse: {capPerMonth, usedThisMonth, overCap, topUpAvailable} (P18) — going over cap NEVER changes the status or blocks the send |
| 202 | status: "duplicate", deduplicated: true | a prior identical send already committed — same messageId, nothing re-enqueued, no fairUse (not re-evaluated) |
| 400 | invalid_request | malformed JSON or schema validation failure (issues[] included) |
| 400 | recipient_refused | the recipient failed the pre-send hygiene gate (see below); findings[] carries each {risk, detail, suggestion?} |
| 403 | from_domain_not_allowed | key may not send from that domain |
| 409 | send_in_progress | an identical send is mid-flight (or crashed within grace) — retry shortly; it will not be sent twice |
| 409 | idempotency_conflict | the key collides with a different in-flight send (astronomically rare) |
| 413 | payload_too_large | serialized job exceeds the SQS message limit (MAX_SQS_MESSAGE_BYTES) |
| 429 | rate_limited | per-key or pre-auth per-IP minute limit exceeded |
| 429 | warmup_cap_exceeded | the sending domain is inside its automated warm-up ramp and has reached that warm-up day's ceiling (see below) |
| 503 | domain_paused | the sending domain's own bounce/complaint rate breached its brake threshold (see below); sibling domains are unaffected |
| 503 | rate_limit_unavailable / send_unavailable / enqueue_failed / warmup_unavailable / domain_health_unavailable | an infra blip — retry with the same idempotencyKey; a duplicate is safely ignored, never a fabricated success |
A 202 additionally carries hygiene: [{risk, detail, suggestion?}] whenever the recipient raised a risk the gate chose not to block on — reported so a sender can clean their list, never silently swallowed.
Automated domain warm-up ramp
A domain verified through this engine (POST /v1/domains, so it carries a verifiedAt) is held to a daily ceiling that grows over a 30-day ramp — 50 on day 1 up to 50,000 — after which it graduates and carries no warm-up ceiling of its own. This paces volume so mailbox providers build the domain's reputation gradually; it is *not* a deliverability or inbox-placement guarantee. The ramp is enforced on POST /v1/send, per recipient on POST /v1/send/batch (the excess recipients fail individually with error: "warmup_cap_exceeded", the rest still go), and on POST /v1/mailbox/:addr/send. It is independent of the per-owner reputation cap — both must pass, so the tighter one binds.
Stated honestly, because each is real:
- The day is anchored to
verifiedAt, and the counter is bucketed on that same warm-up day — not on a UTC calendar day. - Allowance is consumed only for a domain the calling key's owner actually owns, so a forged
fromcannot burn another tenant's ramp. - It counts accepted attempts reaching the gate, not delivered messages: an attempt
acceptSendlater refuses, and an idempotent duplicate, both consume. - A scheduled send is metered on the day it is accepted, not the day it is released (
releaseDueScheduledSendsruns no gate — same as the reputation gate and account brake), so a backlog aimed at one release day can exceed that day's ceiling. Allowance spent on a scheduled send later cancelled is not refunded. - A domain statically allowlisted on a key (admin-granted
allowedDomains, no identity record) has no verification date and therefore carries no ramp. - The ramp is keyed on the full host, so verified subdomains each get their own ramp even though receivers largely pool reputation at the organisational domain.
- Nothing publishes the ramp schedule yet, so a caller that hits the cap cannot currently discover the curve from an endpoint.
Self-service domain onboarding
POST /v1/domains returns the complete record set to publish — six records, each with an honest note, a purpose (dkim / spf / dmarc / mx) and an enables saying what publishing it actually buys:
| Records | enables | Effect |
|---|---|---|
| 3 × DKIM CNAME | verification | SES verifies the identity and DKIM-signs mail from it |
| SPF TXT, DMARC TXT | deliverability | recommended; neither is needed to verify |
| MX | receiving | mail sent to the domain reaches SES |
The MX value is 10 inbound-smtp.<region>.amazonaws.com — the SES *receiving* endpoint, which is an amazonaws.com host, not an amazonses.com one. The region is always passed explicitly and never defaulted: an MX aimed at the wrong region's endpoint does not bounce loudly, it silently fails to deliver.
Send-ready and receive-ready are independent. A domain can be fully verified, DKIM-signed and sending happily while every message sent *to* it bounces at the sender, because SES answers one identity-level question and knows nothing about the MX. readiness reports the two separately for exactly that reason.
Per-domain DKIM signing is SES Easy DKIM: each domain gets its own key pair and its own three CNAMEs, and SES signs mail from that domain with that domain's key. This engine does not implement a signer of its own and does not claim to — what it does is generate the per-domain records and refuse to call a domain verified until SES confirms the signing is live.
Auto-verification
POST /v1/domains/:domain/recheck is the on-demand "check now", and a scheduled sweep (hamani-email-domain-verify, every 5 minutes) does the same unprompted for every still-pending domain, across all owners. No admin step exists in this flow.
Both read the customer's published DNS and return a per-record verdict:
| Status | Meaning |
|---|---|
published | found, and the value matches |
missing | the lookup definitively returned nothing |
mismatch | something else is published there — observed[] names it (e.g. a competing MX still pointing at a previous provider) |
unknown | the lookup did not complete. Not the customer's failure, and never reported as one |
Matching is tolerant exactly where DNS genuinely varies — trailing dots and case are insignificant, any MX priority is accepted, a merged SPF record counts as published (a domain may hold only one SPF record, so merging is the correct action the note itself instructs), and a DMARC record the customer has tightened or added rua= to still counts.
Stated honestly, because each is real:
- DNS never flips
status. Only SES's own confirmation does (§4/§6). A zone can read perfect here while SES has not yet re-checked, and claiming verified first would claim mail is being signed when it is not. failedis never inferred from a DNS read — a record missing at 10:00 is indistinguishable from one published at 10:01.- A
GET /v1/domains/:domainserves the last read (dnsChecks,dnsCheckedAt), doing no DNS I/O of its own, so a dead resolver cannot break the dashboard. A domain never checked carries nodnsCheckskey at all — absent is a different state from "checked and found nothing". - The sweep polls, so a domain goes live within one interval of its DNS propagating, not instantly; "check now" exists for whoever is watching.
- A domain whose DNS never appears stays pending forever and is re-checked every interval. There is no give-up state and no backoff — a customer who pastes their records a month later is still picked up.
- The sweep's work-list is one bounded page of a sparse index; if pending domains ever outnumber the limit for a sustained period, the tail waits (it is bounded, not starvation-free).
Pre-send recipient hygiene
Every other reputation control here is reactive — it reads bounces and complaints that have already cost the domain reputation. This gate is the preventative one: it judges the recipient before the message is handed to SES, on POST /v1/send and per recipient on POST /v1/send/batch (a refused recipient fails alone with error: "recipient_refused"; the rest of the batch still goes).
Two confidence tiers, because a false positive refuses a customer's real mail:
| Tier | Risks | Effect |
|---|---|---|
| High | typo_domain (a curated list of observed misspellings — gmial.com), role_address (RFC 2142 infrastructure mailboxes such as postmaster@, abuse@, plus the no-reply family), disposable_domain (known throwaway-mailbox providers) | refuses a marketing send; flags a transactional one |
| Advisory | typo_domain_suspected — a single-edit-distance heuristic against the major mailbox providers | never refuses, in any category; reported only |
| — | unparseable_address | refuses in every category |
The category split is deliberate: nobody is waiting for marketing mail, so refusing it costs nothing, while wrongly blocking a password reset breaks the customer's product. The advisory tier never blocks because it has genuine false positives — wahoo.com is one substitution from yahoo.com, and both are real.
Stated honestly, because each is real:
- The disposable and lookalike lists are a curated snapshot, not a live feed; they will always trail the providers they track.
- There is no network check — no MX lookup, no SMTP probe, no verification service. A domain that simply does not exist is invisible here unless it is a listed lookalike. The accept path stays pure CPU with no third-party dependency.
- Role detection reads the local part only, so
postmaster@is refused for marketing at every domain, including a customer's own. POST /v1/mailbox/:addr/sendis transactional by construction, so hygiene is advisory only there; findings ride on the 202 for the webmail UI to surface.
Per-domain reputation and brake
Bounce and complaint counts are attributed to the sending domain as well as to the owner and the account-wide aggregate. A domain whose own rate breaches the threshold is braked on its own — 503 domain_paused — while every sibling domain of the same owner keeps sending. Thresholds: a 200-send minimum sample, then a brake at a 5% bounce rate or a 0.3% complaint rate.
Those look looser than the account brake (4% / 0.08%) until you do the arithmetic: the account rate is the volume-weighted average of the domain rates, so a bad domain is diluted at account scope. A domain that is 10% of volume bouncing at 20% puts the account at 2% — under the account brake, invisible, and still climbing. The per-domain brake catches it there. Where one domain *is* all the volume the two rates converge and the tighter account brake fires first, correctly, because there is nothing to isolate it from.
Stated honestly, because each is real:
- Rates are cumulative over the domain's life, not a rolling window (the same simplification
accountHealthmakes). A domain with a bad history stays braked until its lifetime rate recovers. - Keyed on the full host, like the warm-up ramp, so a sender who controls a zone can spread bad volume across subdomains and keep each under its own brake. Closing that needs a public-suffix list — the same deferred dependency.
- The
sentdenominator is counted at provider hand-off while bounces arrive later by webhook, so a domain whose events lag its sends reads healthier than it is for the length of that lag. - An ingested event carrying no domain tag is counted at owner and account scope only — it brakes no domain, so unattributable bad news can never brake a domain that did not cause it.
- The brake is consulted only for a domain the calling key's owner owns, so it cannot be used to probe another tenant's brake state.
- A domain statically allowlisted on a key (admin-granted
allowedDomains, no identity record) carries no brake — the same honest gap the warm-up ramp has, and for the same reason: there is no identity to attach the scope to. Reaching that state takes an operator decision, not a customer one. - A scheduled send already accepted is released by the due-sweep without re-checking the brake (
releaseDueScheduledSendsruns no gate — as with the ramp, the reputation gate and the account brake), so a domain braked after acceptance still emits its backlog.
Scheduled sends (sendAt, P12)
POST /v1/send accepts an optional sendAt (ISO-8601); scheduling a whole batch is a later addition. A sendAt in the future parks the send — fully gated (auth, rate-limit, reputation, account-brake) and with its marketing unsubscribe header already baked in — and returns: ``json { "status": "scheduled", "scheduledSendId": "…", "sendAt": "2026-07-20T09:00:00.000Z", "fairUse": { … } } ` from-domain ownership and payload size are checked at schedule time; a non-transactional scheduled send counts against the fair-use cap now. A sendAt` in the past/now falls through to an immediate send.
A recurring due-sweep Lambda (src/scheduledHandler.ts, an EventBridge ~1-minute rate rule — the Living Circle idiom, never a public trigger) releases due sends into the same SQS pipeline via the exact acceptSend path. Release is exactly-once by layering: a conditional claim (pending → releasing, with a re-claim grace for a crashed claimer) plus the send's own P2/P3 idempotency downstream — a double release can never double-send. The live key is re-fetched at release; a revoked or missing key cancels the send (never sent). A stored request that no longer validates, or a domain the key no longer owns, is cancelled honestly rather than looped.
Manage scheduled sends (owner-scoped): GET /v1/scheduled (list with a {to, subject, category} summary — never the full body), GET /v1/scheduled/:id (404 if absent), DELETE /v1/scheduled/:id (cancel a still-pending send; 409 scheduled_not_cancellable once released).
POST /v1/send/batch (scope: send, P12)
A broadcast to many recipients in one call — the dashboard's batch-send panel. Shared content + a recipients[] array of { to, data?, idempotencyKey? } (each recipient's data overrides the shared data for personalization):
``json { "from": "HAMANI CRM <[email protected]>", "subject": "Hi {{name}}", "templateId": "welcome", "category": "marketing", "recipients": [ { "to": "[email protected]", "data": { "name": "A" } }, { "to": "[email protected]" } ] } ``
Each recipient becomes an independent send through the exact same acceptSend path as POST /v1/send — per-recipient idempotency, per-recipient one-click unsubscribe token (marketing), suppression-at-send, and queue. This is orchestration over the single-send engine, never a second engine. Batch-level gates evaluated once: from-domain ownership (403 from_domain_not_allowed), account brake, reputation, and — for a marketing batch — that unsubscribe is configured (400 unsubscribe_required). recipients is capped at MAX_BATCH_RECIPIENTS (default 500 ⇒ 400 batch_too_large); the whole body is still bounded by MAX_SEND_BODY_BYTES.
Response 202: ``json { "status": "accepted", "total": 2, "queued": 2, "duplicates": 0, "failed": 0, "results": [ { "to": "[email protected]", "status": "queued", "messageId": "…", "idempotencyKey": "…", "deduplicated": false } ], "fairUse": { "capPerMonth": 50000, "usedThisMonth": 2, "overCap": false, "topUpAvailable": false } } ` A per-recipient failure is an entry with status: "error" (+ error`), never a thrown batch — one bad recipient never sinks the rest, and nothing is ever fabricated as sent. Non-transactional recipients count against the fair-use cap (P18); going over never blocks.
Templates (scope: templates, P12)
A customer's own branded, reusable templates, resolved at send time ahead of the shared HAMANI registry (welcome/password-reset stay code-defined). Every route is owner-scoped — a key only ever sees or edits its own owner's templates.
| Method | Path | Purpose |
|---|---|---|
POST | /v1/templates | create (create-only ⇒ 409 template_exists) — body { templateId, defaultLocale, subject, html, text } |
GET | /v1/templates | list this owner's templates |
GET | /v1/templates/:id | fetch one (404 template_not_found) |
PUT | /v1/templates/:id | replace (404 if absent) — preserves createdAt |
DELETE | /v1/templates/:id | delete (404 if absent) |
subject/html/text are locale→string maps (with {{var}} placeholders, rendered exactly as the shared templates: HTML-escaped in html, control- stripped in subject, raw in text); each must include the defaultLocale (400 otherwise). At send time, POST /v1/send { templateId } resolves the owner's stored template first and falls back to the shared registry; an id matching neither is unknown_template (preserved to the DLQ, never sent empty).
Exactly-once (P2)
Two-phase durable reservation: a send reserves a pending record (short grace TTL), enqueues to SQS, then confirms to a committed record (full dedup TTL). A duplicate against a committed record is answered from the recorded messageId. A crash between reserve and enqueue delays — never loses — a genuine retry (the pending grace elapses and the retry re-drives). Recipient- facing exactly-once at the SES boundary (against SQS at-least-once redelivery) is completed by sender-side idempotency in P3.
Delivery (P3 — internal, off the queue)
The Sender Lambda (src/senderHandler.ts) is driven by an SQS event source:
- Each record is validated against
SendJobSchema(a poison/malformed message
is re-driven so it lands in the DLQ, never silently dropped).
- Two-phase delivery claim on the tenant-namespaced idempotency key: a
sending claim (short grace) → the provider send → a delivered record (full TTL). An SQS at-least-once redelivery of an already-delivered message is suppressed (no second send) — recipient-facing exactly-once.
- Provider = SES v2 (
ap-southeast-2) via Simple content (subject, html/
text, allowlisted headers, reply-to, configuration set, correlation tags). P4 adds a fallback provider behind the same EmailProvider interface.
- Retry/backoff = SQS partial batch failure: retryable outcomes (SES
throttle/5xx/transport, delivery-in-progress, unrenderable) are reported as batchItemFailures so SQS re-drives them and, after the queue's maxReceiveCount, routes them to the DLQ. Permanent rejections (MessageRejected, domain-not-verified, suspended) are dropped, not retried. A message is never acknowledged as sent unless the provider returned a real message id.
The delivery status endpoint + full message log land with P5 (events/suppression).
Limits (src/lib/config.ts)
| Limit | Default |
|---|---|
RATE_LIMIT_PER_MINUTE (per key) | 120 |
PRE_AUTH_RATE_LIMIT_PER_MINUTE (per IP) | 300 |
MAX_SEND_BODY_BYTES (request) | 6 MB |
MAX_SQS_MESSAGE_BYTES (serialized job) | 262,144 |
PENDING_GRACE_SECONDS | 120 |
DURABLE_RECORD_TTL_SECONDS (committed dedup) | 86,400 |
Runtime-shape verification (P17)
The final engine phase adds no surface — it VERIFIES the CONSTITUTION §0 runtime shape (asynchronous, scale-to-zero, exactly-once, at-least-once-safe, no-cascade) with cross-cutting guards under test/verify/: N-way concurrency races on every conditional-write/atomic op, cross-container statelessness, runtime slice-conformance + a static narrow-slice guard, one large mixed SQS batch across every partial-batch-failure branch, the full backpressure ladder, and a bounded telemetry buffer under sustained overflow. Real scale/throughput and the DELIVERY_GRACE_SECONDS ≥ VT×maxReceiveCount CDK invariant remain INTEGRATION-GATED (Boss's deploy). See docs/SCALE-VERIFY.md and ASSESS/P17_ASSESSOR_FINDINGS.md.
Living Circle control plane (P16)
Separate from this keyed /v1 API, the engine runs the Living Circle as its own scheduled/admin Lambda (src/agents/livingCircleHandler.ts) — a one-way-door control plane, never on the customer send path. It implements LIVING-CIRCLE-CONTRACT.md as a product/client of the HAMANI Command Centre: it calls IN to pull config (GET /api/config/hamani-email-engine, HCK_READ) and push telemetry (POST /api/ingest/hamani-email-engine, HCK_WRITE) over HTTPS; the Centre never calls in and is never a runtime dependency. With COMMAND_CENTER_URL unset the client is a silent no-op and the engine serves on last-good config / code defaults. It manages the two agent lanes (deliverability, concierge) under a forward-only lifecycle + self-heal layer + two-gate spend permission. Model calls and the Command Centre round-trip are INTEGRATION-GATED. See docs/LIVING-CIRCLE.md.
Customer self-serve domain verification (P8)
A customer proves ownership of their OWN sending domain with SES Easy DKIM — never a static admin-set list alone. POST /v1/domains {domain} registers the identity and returns 3 DKIM CNAME records (<token>._domainkey.<domain> → <token>.dkim.amazonses.com) for the customer to publish; the domain starts status: "pending". POST /v1/domains/:domain/recheck polls SES and flips to "verified" only on a genuine SES confirmation (both VerifiedForSendingStatus and DKIM Status: "SUCCESS") — never optimistic (CONSTITUTION §6). A domain is globally claimed by whichever owner registers it first (DomainStore's conditional create); a later claim by a different owner is refused (409 domain_already_claimed), closing the same spoofing hole allowedDomains already closes for platform keys. Once verified, POST /v1/send accepts that domain for this owner exactly as if it were in the key's static allowedDomains (see src/send/acceptSend.ts's isDomainSendAllowed).
SesDomainProvider (production) and InMemoryDomainProvider (local dev/tests, with a verifyNow() test hook standing in for real DNS propagation) implement the same DomainProvider interface — selected by the existing EMAIL_PROVIDER driver (P8 shares SES-vs-memory with sending rather than introducing a second flag for the same choice).
Billing & usage metering (P9–P11)
POST /v1/admin/customers {ownerId} provisions a real Stripe customer for an owner (idempotent — a repeat call returns the existing billingCustomerId, never a second Stripe customer). Every real delivery (the Sender Lambda's markDelivered success path) increments a durable, permanent, per-owner per-UTC-calendar-month usage counter (UsageStore) — this is the CANONICAL record of what a customer sent, independent of Stripe. If the owner has a provisioned billing account, the same delivery is ALSO reported to Stripe as one Billing Meter Event (STRIPE_METER_EVENT_NAME), using the send's own messageId as Stripe's dedup identifier — the same exactly-once discipline P2 already applies to the send itself. Both the local counter and the Stripe report are best-effort relative to the send: neither can ever fail or retroactively un-send an already-delivered message.
Billing that would charge stays honestly disabled (DisabledBillingProvider) until ALL THREE of STRIPE_SECRET_KEY, STRIPE_METER_EVENT_NAME, and SELL_RATE_AUD_CENTS_PER_1000 are set — usage is still metered locally regardless. The moment a real sell rate IS set, the engine asserts a ≥60% margin over the ~A$0.15/1,000 SES cost (src/billing/pricing.ts) and refuses to serve a price that breaches it (CONSTITUTION §5) — this engine never invents a customer-facing price, and the Boss creates the Stripe Meter and its recurring Price in the Dashboard; this engine only ever reports raw usage against it.
POST /v1/billing/webhook verifies Stripe's Stripe-Signature header (a hand-rolled HMAC-SHA256 reimplementation of Stripe's documented scheme, src/billing/webhookVerify.ts — no live Stripe SDK call needed to verify), then dispatches into the subscription state machine (src/billing/ subscription.ts) by resolving the event's Stripe customer id back to an ownerId — via BillingProvider.getOwnerIdForCustomer, which reads the hamani_owner_id tag createCustomer already writes into Stripe customer metadata, so no second store/index is needed for the mapping:
| Event | Effect |
|---|---|
invoice.payment_failed | active → past_due (markOverdue) on first failure. If ALREADY past_due for 5+ days, escalates past_due → paused (pauseForNonPayment) instead — Harry's rule, "invoice, five days, then pause, never cancel." Sending is already blocked at past_due (sendingAllowed treats past_due and paused identically), so this window is about the state escalating honestly on repeated failures, not a sending grace period. |
invoice.paid | past_due/paused → active immediately (restoreAfterPayment) — a customer who pays is not made to wait for a sweep. |
customer.subscription.deleted | → cancelled (cancel, which deletes nothing — mail, mailboxes and domains stay). Keeps our record in sync with Stripe's actual state; this is not how non-payment is handled. |
customer.subscription.created / customer.subscription.updated | Reads which mailbox tier and which storage add-ons the subscription's line items carry (classifyStripeSubscription, keyed on the hamani_key metadata scripts/stripe-setup.py writes on every product/price — never on amount or name), stores it (setStoragePlan), then recomputes and writes the owner's total storage allowance (plans.ts's totalStorageBytes) to every mailbox they own. Not a price migration — no existing price is ever created twice or altered, so an existing line item's key is unaffected by a later catalogue change; this only mirrors what the customer's OWN subscription currently and actually contains. |
An unmapped customer id (Stripe's data ahead of ours — e.g. a test event) is a 200 no-op, logged, never a 500 that would provoke a Stripe retry storm.
Fair-use monthly bulk cap (P18)
A per-tenant, per-UTC-calendar-month ceiling (FAIR_USE_BULK_CAP_PER_MONTH, default 50,000) on non-transactional (category !== "transactional") sends only — transactional mail is exempt and never counted, matching "unlimited transactional." The cap is soft: a genuinely new (non-deduplicated) accepted send always increments the counter and is always queued regardless of the result — going over cap never blocks, throttles the HTTP response, or changes the 202. The response's fairUse block simply makes the overage honest (overCap: true, topUpAvailable: true) so the calling platform (e.g. Marketing) can offer the customer a top-up itself — this engine never calls back into a product to push the offer (CONSTITUTION §4, one-way door). A deduplicated retry is not re-evaluated (no fairUse on that response) since it was already counted on its first acceptance.
Dashboard read-API — the hub seam (P12)
The premium dashboard lives in the hub (hamaniservices.com.au), a HAMANI product; this engine exposes only the read-only data behind a signed one-way seam (CONSTITUTION §4). The hub authenticates the human and calls GET /v1/dashboard/* IN with an X-Hamani-Hub-Signature: t=<unix-seconds>, v1=<hmac> header over ${t}.${METHOD}.${pathWithQuery}.${sha256(body)} (src/dashboard/hubSignature.ts, mirroring the Stripe/unsubscribe HMAC idiom). There is no viewer login in the engine.
DASHBOARD_HUB_SECRETunset ⇒503 dashboard_not_configured(closed until the Boss provisions it, never defaulted open — §5).- Bad/missing/expired signature ⇒
401 invalid_hub_signature; the timestamp is replay-windowed (DASHBOARD_HUB_SIGNATURE_TOLERANCE_SECONDS, default 300s). analytics/domains/overviewrequire anownerIdquery param (400 invalid_requestotherwise);analytics/overviewaccept an optionalperiod=YYYY-MM(default current UTC month).
Data source: real store rollups (AnalyticsStore) fed by the send path (sent) and the SES-event ingest (delivered/opened/clicked/bounced/ complained), attributed per sending domain via a reversible hamani_domain SES tag. Opens/clicks are total (not unique — PRIVACY LAW §3 forbids storing the recipient). Suppression listing is hashed-only. Domain health is SES's own verification status; SPF/DMARC are reported "provided" (given, not DNS-probed). See docs/DASHBOARD.md for the full endpoint contract. The premium UI itself is built in the hub repo, not here.
Inbound mailboxes — receive (P20)
Real business mailboxes ([email protected]) that RECEIVE mail, so HAMANI runs its own email off SES instead of paying per-seat elsewhere. Distinct from the delivery-EVENT ingest (P5): that records bounces/opens about mail we SENT; this stores inbound messages you can read.
Pipeline (INTEGRATION-GATED — real mail needs the deploy + MX records): SES receipt rule (scan on) → S3 raw MIME + SNS → Inbound Lambda (src/inboundHandler.ts) → mailparser → ObjectStore (S3: bodies/attachments) + MailboxStore (DynamoDB metadata index). Address-keyed: a mailbox is its own address, owned by the domain's verified owner — no identity subsystem in the engine ("identity separate, never a hard dependency"). Idempotent by the SES receipt id (a redelivery never duplicates). Spam/virus verdicts from SES route a FAIL to a quarantine folder; threading is by References/In-Reply-To (else a subject key). PRIVACY §3: the store necessarily holds the mail, but the log sink only ever sees ids/hashes/counts.
The webmail lives in the hub and reads/acts over the SAME signed one-way seam as the dashboard (X-Hamani-Hub-Signature; unset secret ⇒ 503): GET .../messages (folder list, metadata only), GET .../messages/:id (bodies inlined from S3), GET .../messages/:id/attachments/:n (streamed, base64), POST .../read {read}, POST .../folder {folder}. Folders: inbox|sent|trash|quarantine. See docs/DASHBOARD.md for the seam mechanics.
Compose / reply — POST /v1/mailbox/:addr/send {to, subject, html|text, replyTo?, inReplyTo?, references?}: the mailbox's domain must be verified (403 otherwise), the shared-reputation gate still applies, it sends through the same acceptSend path (a HAMANI-owned platform send), and stores a copy in the mailbox's Sent folder (best-effort — a mailbox-write blip never fails an accepted send). In-Reply-To/References are allowlisted send headers so a reply threads with the received conversation.
Super-admin — cross-tenant control plane (/v1/admin/tenants/*)
Level 1: the Boss's own reach across every tenant, reached THROUGH the hub — never called directly by a browser (no HMAC secret there). Gated three ways, none skippable: (1) the hub's X-Hamani-Hub-Signature; (2) the acting owner's SEALED SESSION (X-Hamani-Actor names the mailbox, X-Hamani-Session proves it is signed in); (3) assertSuperAdmin — the acting mailbox must be an exact member of OWNER_EMAILS and have ACTIVE 2FA. There is no opt-out anywhere in the chain, and owner-ness is re-derived on every call, never cached — strip an address from OWNER_EMAILS and its next call is refused even with a perfectly valid, still-signed session.
Routes: GET /v1/admin/tenants (registry), GET /v1/admin/tenants/:id (control record), GET /v1/admin/tenants/:id/audit (read-only, see below), POST /v1/admin/tenants/:id/{suspend,restore,delete,kill,restore-sending,cap, flag,grant,impersonate}. grant ({mailbox?, storageBytes?, freeSending?}) is the CUSTOMER COMP mechanism: it can raise (never shrink) a mailbox's quota and sets the tenant comp flag, which the send path treats as free — skipping the non-payment pause and the fair-use cap — without touching Stripe at all. kill/restore-sending is the emergency brake, checked first on every send; neither owner nor comp status bypasses it. impersonate writes ONLY an audit row (admin.impersonation.started) — the engine never mints an impersonation session; that is the hub's job, not yet built.
Every mutating action appends an entry to the TARGET tenant's own audit trail (actor, action, detail, at) via a store that exposes append + read only — there is no update or delete method, so not even the Boss can erase the record of an administrative action.
Owner console UI: GET /admin in the hub (session + owner flag gated, otherwise a plain 404) relays every route above through hub/routes/console.ts — the acting mailbox always comes from the signed cookie, never the request.
Platform staff, credit, and approvals (/v1/staff/*, /v1/credits/*)
Level 1.5: HAMANI's own people, sitting between the super-admin (unlimited, OWNER_EMAILS, deploy-gated) and a customer's own workers (/v1/workers/*, tenant-scoped). Deliberately not self-service at the super-admin level — minting a super-admin from a web session would let one stolen session hand over the company permanently, so that power stays in OWNER_EMAILS only. Staff (admin | support) are durable records a super-admin manages.
Gated by requireStaff — the same hub-signature + sealed-session chain as the super-admin plane, but resolved through resolveStaffActor: OWNER_EMAILS → super_admin (unlimited); else an ACTIVE StaffRecord → its role and limits; else 403 not_staff. A suspended staff record is refused immediately, on the very next call — the revoke does not wait for a session to expire.
GET/POST /v1/staff,PATCH/DELETE /v1/staff/:email— roster management, super-admin only (enforced instaffService, not just the route).GET /v1/staff/approvals,POST /v1/staff/approvals/:id/{approve,deny}— the approval queue, super-admin only. Deciding requires an ACTIVE 2FA on the decider (requireSecondFactor), same as issuing credit below. Nobody approves their own request; a decided request cannot be decided twice; an expired one (default 7 days) cannot be approved at all.GET /v1/credits/:tenantId— a tenant's credit ledger + running balance. Any staff tier may read it.POST /v1/credits{tenantId, amountAudCents, reason}— issue a credit. Requires ACTIVE 2FA on the issuer, founder included. Asuper_adminissues any amount directly. Staff are bounded by two ceilings, both checked: the single amount againstperCreditAudCents, and this calendar month's total (Australia/Sydney) againstmonthlyAudCents— exceeding EITHER raises apending_approvalresult rather than failing outright, so a legitimate large credit has a path that does not require the founder at a keyboard. The monthly ceiling is what stops a per-credit limit being defeated by simply issuing it several times.
The ledger is append-only by contract — the store exposes append + read only, exactly like the super-admin audit trail. A credit issued in error is corrected by appending a REVERSAL (a negative entry naming who reversed it), never by rewriting history. Honesty note: nothing in this engine generates invoices yet (no production caller for buildInvoice), so a credit is a durable, audited standing balance to be honoured — not a claim that a Stripe refund or invoice adjustment has happened. See LAUNCH-AUDIT.md §3.
Owner console UI: the same GET /admin page adds a Team section (add/ suspend/remove staff, their limits shown plainly), an Approvals section with a pending-count badge, and a per-customer "Give credit" action — all relayed through hub/routes/console.ts, the acting mailbox always from the cookie.
Deliberately not promised yet
- No per-message delivery status endpoint yet (the message log is stored but not surfaced per-message).
- No engine-side viewer login — dashboard access is the hub's signed seam only (by design; the hub owns authentication).
Hamanimail
← Back to site