That’s My Mail Docs/llms.txt
← All docs

SDK reference

@thatsmymail/sdk — the official TypeScript SDK. Zero runtime dependencies (native fetch + node:crypto), full type definitions, HMAC signing built in.

It mirrors the API paths 1:1. Product layers map as client.<product>.<resource>.<action>()/<product>/v1/<resource>/<action>. The shared, product-agnostic layer maps as client.v1.<table>.<verb>()/v1/<table>/<verb> (data) and client.v1.services.<action>()/v1/services/<action> (shared services, e.g. client.v1.services.sendEmail()). Path params (:id) are leading arguments. If you can read the call, you know the URL.

v0.17.0 is a breaking change (alpha — still 0.x). The old client mail and api accessors are removed; the data layer + a new shared-services accessor move to the product-agnostic client.v1.*. Migrate as follows: the old top-level mail send becomes client.v1.services.sendEmail(msg); the old mail message reads become client.v1.messages.get(id) / client.v1.messages.events(id); and the old per-table api accessor becomes client.v1.<table>.<verb>(). Other products (client.console.*, client.admin.*, client.auth.*, client.modules.templates.*) are unchanged.

v0.5.0 is a breaking change from 0.4.x: the flat surface (client.send, client.project(id), client.messages, client.projects) is gone — everything now lives under client.console.* / the shared send service (later client.v1.services.sendEmail).

v0.7.0 (additive): adds client.auth.* (signup/login/logout), client.console.projects.senderDomains.* (multiple sender domains per project), and makes keyId/secret optional for an auth-only client.

v0.8.0: product-first URLs (services moved to /<product>/v1/*) — SDK method names are unchanged, only the URLs they hit. Adds the generic data layer client.v1.<table>.{get,list}() (/v1/<table>/*, row-scoped + secret-redacted) and client.admin.* (/admin/v1/*).

v0.9.0 (additive): the data layer gains WRITES — client.v1.<table>.create(body) and .delete(body) (POST /v1/<table>/{create,delete}). Server-side these are allowlisted to safe single-table writes (currently suppressions); the project_id in the body is validated against your credential, and all other tables' writes stay service-only.

v0.10.0 (additive): data layer is full CRUD — adds client.v1.<table>.update(body) and .upsert(body); .delete(body) now returns { ok: true }. Writes are row-scoped to your credential; enabled across the tenant tables (suppressions, projects, api_keys, account_tokens, webhook_endpoints, sender_domains). Permanent carve-outs stay service-only/read-only: messages create (use v1.services.sendEmail), audit_log (append-only), and the engine tables rate_limits/webhook_deliveries/message_events.

v0.11.0 (breaking, data layer only): the data layer is now product-firstclient.v1.<table> moves to a product-scoped accessor; the product-agnostic /v1/* is retired.

v0.12.0 (breaking, data layer only): the data layer collapses to a single api product data accessor (later superseded by the shared client.v1.* in v0.17.0). The backend's tables all live in one Postgres schema (the separable unit); console/auth are service facets, not schemas, so the per-product data split from 0.11.0 was wrong. Service-layer calls are unchanged. Tables: projects, api_keys, sender_domains, webhook_endpoints, suppressions, idempotency_keys, audit_log, messages, users, account_tokens.

v0.13.0 (additive + one small breaking change): organizations — the ownership + billing layer. Adds client.console.organizations.{create,list,get,setPlan} and client.console.organizations.tokens.{create,list,revoke}, plus the data-layer reads client.v1.{organizations,organization_members,organization_tokens}. Breaking: client.console.projects.create() now requires { name, organizationId } (a project is created under an org), and .list() takes an optional { organizationId } filter. Quota: sending on a free org throws quota_exceeded (429) at 500 accepted messages/day.

v0.14.0 (additive): password reset/change — client.auth.password.forgot(email) (public; always resolves, emails a reset link), .reset(token, password) (public; consumes the emailed token), and .change(currentPassword, newPassword) (account token; logged-in change). Reset emails are sent by TMM itself from notification@noreply.thatsmymail.com.

v0.15.0 (additive): organization management — client.console.organizations.update(id, { name }) (rename), .invitations.{create,list,revoke} + .acceptInvitation(token) (invite teammates by email; the accept link is emailed and returned to copy), and .members.remove(id, userId).

v0.16.0 (additive): the templates module — client.modules.templates.{create,list,get,update,delete,render} (org-scoped reusable email bodies with {{variables}}), plus v1.services.sendEmail now accepts templateId + variables to render the body from a stored template at send time.

v0.29.0 (additive): custom webmail domains (TMM-43) — client.console.organizations.customDomains.{create,list,verify,delete}. An org registers an own (sub)domain CNAME'd to the webmail; once verify confirms the CNAME, the webmail is served there with automatic HTTPS. create returns the CNAME record to publish.

v0.38.0 (additive): organization join codes (TMM-86) — client.console.organizations.joinCodes.{create,list,revoke} and client.console.organizations.join(code). An owner generates a one-time code (with a role) to onboard a colleague, who redeems it to join the org. Also (TMM-87): client.console.organizations.create() now throws 409 already_in_organization if the caller already belongs to an organization — join an existing one with a code instead.

v0.39.0 (additive): organization roles & permissions (TMM-88) — five fixed roles (owner/admin/member/ auditor/billing). client.console.organizations.members.setRole(id, userId, role) changes a member's role, client.console.organizations.roles() returns the static role → permissions matrix, and invitations + join codes now accept any of the five roles. A write whose permission your role lacks returns 403 forbidden.

Install

Private package on GitHub Packages. Your project needs a .npmrc:

@adrikesteren:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
pnpm add @thatsmymail/sdk   # GITHUB_TOKEN needs read:packages

Construct a client

import { TmmClient } from "@thatsmymail/sdk";

const tmm = new TmmClient({
  keyId:  process.env.TMM_KEY_ID!,   // public id (tmm_… / tmma_…)
  secret: process.env.TMM_SECRET!,   // HMAC secret — treat like a password
  // baseUrl: "http://localhost:18001",  // optional; defaults to https://api.thatsmymail.com
  // timeoutMs: 30000,                    // optional
});

One client = one credential = one environment. The credential you pass decides what you can call (the server authorizes per endpoint) — a project key for v1.services.sendEmail, an account token for console.projects.create/list. See Authentication.

keyId/secret are optional: construct new TmmClient() with no credentials to call the public tmm.auth.signup() / tmm.auth.login(), which return the account token you then build a full client with.

Surface

Call Endpoint
tmm.auth.signup({ email, password }) POST /auth/v1/signup (public)
tmm.auth.login({ email, password }) POST /auth/v1/login (public)
tmm.auth.logout() POST /auth/v1/logout
tmm.auth.password.forgot(email) POST /auth/v1/password/forgot (public; always ok)
tmm.auth.password.reset(token, password) POST /auth/v1/password/reset (public)
tmm.auth.password.change(currentPassword, newPassword) POST /auth/v1/password/change (account token)
tmm.v1.services.sendEmail(msg) POST /v1/services/sendEmail
tmm.v1.services.whoami() GET /v1/services/whoami (which principal am I? org token → its own organizationId)
tmm.v1.messages.get(id) GET /v1/messages/get?id=
tmm.v1.messages.events(id) GET /v1/messages/events?id=
tmm.console.organizations.create({ name }) POST /console/v1/organizations (→ org + first tmmo_ token)
tmm.console.organizations.list() GET /console/v1/organizations
tmm.console.organizations.get(id) GET /console/v1/organizations/:id (members + projects + usage)
tmm.console.organizations.setPlan(id, "free" | "payg") POST /console/v1/organizations/:id/plan
tmm.console.organizations.update(id, { name }) PATCH /console/v1/organizations/:id
tmm.console.organizations.tokens.create(id, { label? }) / .list(id) / .revoke(id, tokenId) /console/v1/organizations/:id/tokens*
tmm.console.organizations.invitations.create(id, { email, role? }) / .list(id) / .revoke(id, invId) /console/v1/organizations/:id/invitations*
tmm.console.organizations.acceptInvitation(token) POST /console/v1/organizations/invitations/accept
tmm.console.organizations.joinCodes.create(id, { role? }) / .list(id) / .revoke(id, codeId) /console/v1/organizations/:id/join-codes* (one-time onboarding codes; the plaintext code is shown once)
tmm.console.organizations.join(code) POST /console/v1/organizations/join (redeem a join code → join the org)
tmm.console.organizations.roles() GET /console/v1/organizations/roles (static role → permissions matrix, TMM-88)
tmm.console.organizations.members.setRole(id, userId, role) POST /console/v1/organizations/:id/members/:userId/role (one of owner/admin/member/auditor/billing)
tmm.console.organizations.members.remove(id, userId) DELETE /console/v1/organizations/:id/members/:userId
tmm.console.organizations.customDomains.create(id, { domain }) POST /console/v1/organizations/:id/custom-domains (custom webmail domain; returns the CNAME to publish)
tmm.console.organizations.customDomains.list(id) GET /console/v1/organizations/:id/custom-domains
tmm.console.organizations.customDomains.verify(id, domId) POST /console/v1/organizations/:id/custom-domains/:domId/verify (re-check the CNAME)
tmm.console.organizations.customDomains.delete(id, domId) DELETE /console/v1/organizations/:id/custom-domains/:domId
tmm.console.projects.create({ name, organizationId }) POST /console/v1/projects
tmm.console.projects.list({ organizationId? }) GET /console/v1/projects
tmm.console.projects.me() GET /console/v1/projects/me
tmm.console.projects.get(id) GET /console/v1/projects/:id
tmm.console.projects.update(id, name) PATCH /console/v1/projects/:id
tmm.console.projects.delete(id) DELETE /console/v1/projects/:id
tmm.console.projects.apiKeys.create(id, { label?, scopes? }) POST /console/v1/projects/:id/api-keys
tmm.console.projects.apiKeys.list(id) GET /console/v1/projects/:id/api-keys
tmm.console.projects.apiKeys.update(id, keyId, { label }) PATCH /console/v1/projects/:id/api-keys/:keyId (rename; secret + scopes immutable)
tmm.console.projects.apiKeys.revoke(id, keyId) DELETE /console/v1/projects/:id/api-keys/:keyId
tmm.console.projects.domains.claim(id, { domain }) POST /console/v1/projects/:id/domains (claim — apex first, platform-wide unique; returns ownership TXT)
tmm.console.projects.domains.list(id) / .get(id, domId) GET /console/v1/projects/:id/domains[/:domId]
tmm.console.projects.domains.verify(id, domId) POST /console/v1/projects/:id/domains/:domId/verify
tmm.console.projects.domains.delete(id, domId) DELETE /console/v1/projects/:id/domains/:domId
tmm.console.projects.domains.addOutbound(id, domId, { subdomain? }) / .verifyOutbound(id, domId, capId) / .setOutboundDisplayName(id, domId, capId, name|null) / .deleteOutbound(id, domId, capId) …/domains/:domId/outbound[/:capId...] (DKIM sending; default display name; many per claim, apex or subdomain)
tmm.console.projects.domains.addInbound(id, domId, { subdomain? }) / .verifyInbound(id, domId, capId) / .deleteInbound(id, domId, capId) …/domains/:domId/inbound[/:capId...] (MX receiving; many per claim)
tmm.console.projects.domains.addCapabilities(id, domId, { subdomain?, outbound?, inbound? }) POST …/domains/:domId/capabilities (sending + receiving in one call)
tmm.console.projects.domains.inboxes.create(id, domId, inboundId, { localpart, type? }) POST …/domains/:domId/inbound/:inboundId/inboxes (initial password generated + returned once)
tmm.console.projects.domains.inboxes.list(id, domId, inboundId) / .delete(id, domId, inboundId, inboxId) GET|DELETE …/domains/:domId/inbound/:inboundId/inboxes[/:inboxId] (list items carry storageUsedBytes + storageLimitBytes)
tmm.console.projects.domains.inboxes.setStorage(id, domId, inboundId, inboxId, { limitGb }) POST …/inboxes/:inboxId/storage — set the storage limit in whole GB (1 GB free, €0.50/GB/mo above); returns { usedBytes, limitBytes }
tmm.console.projects.domains.inboxes.import(id, domId, inboundId, inboxId, { messages }) POST …/inboxes/:inboxId/import — Mail Transfer: import existing mail (base64 MIME), deduped + idempotent
tmm.console.projects.webhooks.register(id, { url, events? }) POST /console/v1/projects/:id/webhooks
tmm.console.projects.webhooks.list(id) GET /console/v1/projects/:id/webhooks
tmm.console.projects.webhooks.delete(id, webhookId) DELETE /console/v1/projects/:id/webhooks/:webhookId
tmm.console.projects.suppressions.add(id, { address, reason?, detail? }) POST /console/v1/projects/:id/suppressions
tmm.console.projects.suppressions.list(id, address?) GET /console/v1/projects/:id/suppressions
tmm.console.projects.suppressions.remove(id, address) DELETE /console/v1/projects/:id/suppressions/:address
tmm.console.projects.messages(id, { limit?, status? }) GET /console/v1/projects/:id/messages
tmm.console.projects.audit(id, { limit?, action? }) GET /console/v1/projects/:id/audit
tmm.console.projects.testMessage(id, msg) POST /console/v1/projects/:id/test-message
tmm.console.accountTokens.create({ label? }) / .list() / .revoke(id) /console/v1/account-tokens*
tmm.modules.templates.create({ organizationId, name, subject, html?, text?, variables?, isPublic? }) POST /modules/templates/v1/templates/create (isPublic = usable cross-org; owner-only)
tmm.modules.templates.list(orgId) / .listPublic(orgId) / .get(orgId, id) / .update(input) / .delete(orgId, id) / .render(input) /modules/templates/v1/templates/* (get succeeds for own or public; listPublic returns the cross-org set)
tmm.v1.<table>.list({ limit? }) / .get(id) GET /v1/<table>/{list,get} — data layer (row-scoped, secret-redacted)
tmm.v1.<table>.create / .update / .upsert / .delete (body) POST /v1/<table>/{create,update,upsert,delete} — full CRUD, row-scoped
e.g. tmm.v1.suppressions.create(), tmm.v1.messages.get(), tmm.v1.users.update()
tmm.admin.stats() / .users() / .userProjects(id) / .audit() / .messages() GET /admin/v1/* — god-key or staff token

Examples

Log in, then act as the user (no credentials needed to log in):

const auth = new TmmClient();                       // no keyId/secret → auth-only
const { user, accountToken } = await auth.login({ email, password });
const tmm = new TmmClient({ keyId: accountToken.tokenId, secret: accountToken.secret });
const projects = await tmm.console.projects.list();
// …end the session:
await tmm.auth.logout();                             // revokes accountToken

Send a message (project key, messages:send):

const { providerMessageId, status } = await tmm.v1.services.sendEmail({
  to: "alice@example.com",
  from: "noreply@yourdomain.com",
  subject: "Welcome!",
  text: "Thanks for signing up.",
});

Who am I? (any credential) — resolve the calling principal. An organization token gets its own organizationId back, so an integrator signing with a tmmo_ token no longer has to probe org-scoped endpoints to find which org it controls:

const me = await tmm.v1.services.whoami();
if (me.principal === "organization_token") {
  console.log(me.organizationId); // the org this tmmo_ token controls
}

Onboard: create an org, then a project under it (account token):

// A fresh account owns no organization yet — create one first.
const { organization, organizationToken } = await tmm.console.organizations.create({ name: "Acme Inc" });
// store organizationToken.secret now — the org-scoped admin credential (tmmo_), shown once

// Projects are created UNDER an organization (billing/quota live on the org):
const { project, apiKey } = await tmm.console.projects.create({
  name: "My App",
  organizationId: organization.id,
});
// store apiKey.secret now — it is not retrievable again
const key = await tmm.console.projects.apiKeys.create(project.id, {
  label: "server",
  scopes: ["messages:send", "messages:read"],
});

Check today's usage / switch tier (the daily allowance is per organization):

const { usage, inboxUsage } = await tmm.console.organizations.get(organization.id);
// usage      = { tier, dailyLimit: 500, usedToday, remaining, overageToday }  — messages today
// inboxUsage = { activeInboxes, billedCents, storageOverageGb, storageBilledCents, totalBilledCents, monthStart }
//   billedCents = inbox-days (€1/inbox/mo); storageBilledCents = extra storage (€0.50/GB/mo above 1 GB free); totalBilledCents = sum
await tmm.console.organizations.setPlan(organization.id, "payg"); // owner only — lifts the daily cap

Claim a domain, then set up sending (project:admin):

// 1. Claim the apex ONCE (publish the ownership TXT it returns, then verify)
const claim = await tmm.console.projects.domains.claim(project.id, { domain: "example.com" });
// publish claim.dnsRecords.ownership, then:
await tmm.console.projects.domains.verify(project.id, claim.id);

// 2. Add sending on a subdomain — noreply.example.com — under the SAME claim
const updated = await tmm.console.projects.domains.addOutbound(project.id, claim.id, { subdomain: "noreply" });
const cap = updated.outbound.find((o) => o.domain === "noreply.example.com")!;
// publish cap.dnsRecords.{dkim,spf,dmarc}, then verify THAT capability by its id:
await tmm.console.projects.domains.verifyOutbound(project.id, claim.id, cap.id);

// (or set up sending + receiving for one (sub)domain in a single call)
await tmm.console.projects.domains.addCapabilities(project.id, claim.id, { subdomain: "mail" });

Custom webmail domain (TMM-43) — let an org reach its webmail on its own host:

// 1. Register the (sub)domain; publish the CNAME it returns.
const { customDomain, dnsRecord } = await tmm.console.organizations.customDomains.create(
  organization.id,
  { domain: "mail.acme.com" },
);
// dnsRecord = { host: "mail.acme.com", type: "CNAME", value: "mail.thatsmymail.com", … }

// 2. Once the CNAME is live, verify it. After this we serve the webmail there with auto-HTTPS.
await tmm.console.organizations.customDomains.verify(organization.id, customDomain.id);
// → { status: "verified", domain: "mail.acme.com" } (throws `cname_not_found` until it points at us)

const domains = await tmm.console.organizations.customDomains.list(organization.id);

Wrapping for a Mailer interface (the SDK has no top-level send() — sending is the shared service):

import { TmmClient, type MailMessage, type MailerResult } from "@thatsmymail/sdk";
const tmm = new TmmClient({ keyId, secret });
const mailer = { send: (m: MailMessage): Promise<MailerResult> => tmm.v1.services.sendEmail(m) };

Errors

Every failure throws a TmmError (code, status, message, details?, requestId?). See Errors.

import { TmmError } from "@thatsmymail/sdk";
try {
  await tmm.v1.services.sendEmail({ /* … */ });
} catch (err) {
  if (err instanceof TmmError) console.error(err.code, err.status, err.requestId);
  else throw err;
}