@easybits.cloud/mailmask
Official TypeScript/JavaScript SDK for the MailMask email address, mailbox and forwarding API.
Install
npm i @easybits.cloud/mailmask
Quick start
import { MailMask } from "@easybits.cloud/mailmask";
const mm = new MailMask({ apiKey: "mk_..." });
// Get your domains
const domains = await mm.domains.list();
const domainId = domains[0].id;
// Create an address — emails to [email protected] forward to your inbox
const address = await mm.addresses.create(domainId, {
alias: "hello",
destinations: ["[email protected]"],
});
// Send an email (routed through MailMask for high deliverability)
await mm.send.send(domainId, {
from: "hello", // must be an active address; defaults to `noreply`
fromName: "Acme", // optional display name: Acme <[email protected]>
to: "[email protected]",
subject: "Welcome!",
html: "<p>Thanks for signing up.</p>", // or `body` (plain text) / `markdown`
});
// List addresses for a domain
const addresses = await mm.addresses.list(domainId);
// Add a domain — returns the DNS records to configure (MX, TXT, DKIM, SPF)
const { domain, dnsRecords } = await mm.domains.create("yourdomain.com");
// An address with an IMAP mailbox (activated domain): password is returned once
const inbox = await mm.addresses.create(domainId, { alias: "sales", mailbox: true });
// `mm.aliases` still works: it is the same resource under its old name.
console.log(inbox.buzon?.password, inbox.buzon?.imap);
MCP
The same actions are available to AI agents at https://www.mailmask.studio/mcp
(Streamable HTTP, Authorization: Bearer mk_...). See https://www.mailmask.studio/docs#mcp.
More
// Attachments, copies, threading, idempotency
const pdf = await mm.attachments.upload(domainId, { filename: "invoice.pdf", contentType: "application/pdf", data: bytes });
await mm.send.send(domainId, {
from: "billing", to: "[email protected]", cc: ["[email protected]"],
subject: "Re: Invoice 42", markdown: "Attached.", attachments: [pdf],
inReplyTo: "<[email protected]>", references: "<[email protected]>",
}, { idempotencyKey: `invoice-42` });
// Account: identity + usage, and a full JSON export
const me = await mm.account.me();
const backup = await mm.account.export();
// Upload an image to embed in an outgoing email (returns a public url)
const { url } = await mm.domains.uploadImage(domainId, imageBlob);
// Suppression list (hard bounces / complaints)
await mm.suppressions.list(domainId);
await mm.suppressions.remove(domainId, "[email protected]");
// Webhooks (activated domain) — secret is shown once
const wh = await mm.webhooks.create(domainId, { url: "https://app.example.com/hooks/mailmask", events: ["email.received", "email.delivered", "email.bounced"] });
// On your server: verify the signature with the raw body
import { verifyWebhookSignature } from "@easybits.cloud/mailmask";
const ok = await verifyWebhookSignature(secret, { signature: req.headers["x-mailmask-signature"], timestamp: req.headers["x-mailmask-timestamp"] }, rawBody);
Partners
For apps that send email on behalf of their own customers (each customer is a tenant with its
own domain, quota and reputation). Uses a partner credential (mmk_… + mms_… secret), not an mk_
key. Every request is signed with HMAC-SHA256 automatically; send/batch get an idempotencyKey
when you don't pass one (the same key on every retry).
Transport (0.6):
- Retries on rate-limit 429, 5xx, network errors and timeouts — exponential backoff with jitter
(
retryBaseMs500 → 250–500 ms, 500–1000 ms, …), honoringRetry-After. Only safe requests are retried: GET/PUT/DELETE,tenants.pause/resume,domains.addandsend/batch(they always carry an idempotency key).webhook.rotateSecretandwebhook.testare never retried. IfRetry-Afterasks for more thanretryMaxDelayMs(default 30 s) the error is thrown withretryAfterMsso you decide.maxRetriesdefault 3, max 10;onRetryhook for your logs. - Timeout per attempt with
AbortController(timeoutMs, default 30 s) →TimeoutError. - Secrets stay out of logs: the secret is not a property of the client (
console.log(mm)can't print it) and no error message carries it.
const mm = new MailMaskPartner({
keyId: process.env.MAILMASK_KEY_ID!, secret: process.env.MAILMASK_SECRET!,
timeoutMs: 10_000, maxRetries: 5,
onRetry: ({ attempt, delayMs, error }) => console.warn(`mailmask retry ${attempt} in ${delayMs} ms`, error.kind, error.requestId),
});
Example: Formmy sending a newsletter from its customer's domain.
import {
MailMaskPartner, PausedError, QuotaError, RecipientSuppressedError, ReputationHoldError, ValidationError, verifyWebhook,
} from "@easybits.cloud/mailmask";
const mm = new MailMaskPartner({ keyId: process.env.MAILMASK_KEY_ID!, secret: process.env.MAILMASK_SECRET! });
// 1. One tenant per Formmy customer (idempotent: call it on every sign-up/update)
await mm.tenants.put("formmy-user-42", { name: "Panadería La Espiga" });
// 2. Their domain: show these DNS records to the customer, then poll the status
const { records } = await mm.domains.add("formmy-user-42", "laespiga.mx");
// records: [{ type: "TXT", name: "_amazonses", value: "…", level: "requerido" }, …]
const { state } = await mm.domains.status("formmy-user-42", "laespiga.mx"); // "pending" | "verified" | "failed"
// 3. Send (pass your own idempotencyKey to make retries across processes safe).
// `stream`: "transactional" (default for send: receipts, reminders; no unsubscribe link) or
// "broadcast" (newsletters: one-click List-Unsubscribe; an unsubscribe only silences broadcast).
try {
await mm.send("formmy-user-42", {
from: "La Espiga <[email protected]>",
to: "[email protected]",
subject: "Pan de muerto ya disponible",
html: "<p>Desde hoy…</p>",
tags: { campaign: "oct-26" },
stream: "broadcast",
idempotencyKey: "campaign-oct-26:[email protected]",
});
} catch (e) {
if (e instanceof RecipientSuppressedError) { /* e.reason, e.stream, e.recipient: mark the reader, don't retry */ }
else if (e instanceof PausedError) { /* tenant paused (bounces/complaints): stop until resume */ }
else if (e instanceof QuotaError) { /* e.limit, e.resetsAt */ }
else if (e instanceof ValidationError) { /* e.field */ }
else throw e;
}
// Up to 10,000 recipients in the background (stream defaults to "broadcast"; suppressed ones are
// skipped). Recipients are plain addresses or { to, vars }: {{name}} in subject/html/text is replaced
// per recipient (escaped inside html). Broadcasts get {{unsubscribe_url}} (or a footer if unused).
const job = await mm.batch("formmy-user-42", {
from: "[email protected]", subject: "Hola {{nombre}}", html: "<p>{{nombre}}, tu cupón: {{cupon}}</p>",
recipients: ["[email protected]", { to: "[email protected]", vars: { nombre: "Bea", cupon: "OCT10" } }],
});
const st = await mm.batchStatus("formmy-user-42", job.batchId);
// st.sent / failed / skippedSuppressed, then (for up to 7 days) delivered / bounced / complained / unsubscribed
// Opens and clicks (opt-in per send/batch): `track: true` adds SES's open pixel and rewrites the html
// links. Without it nothing is added. Unique counts + rates + top links come back in batchStatus, and
// `email.opened` / `email.clicked` fire once per message (the first time). Opens are inflated by Apple
// Mail Privacy Protection: decide by clicks.
const tracked = await mm.batch("formmy-user-42", { from: "[email protected]", subject: "Oct", html: "<a href=\"https://laespiga.mx/menu?utm_source=mail\">Menú</a>", recipients: ["[email protected]"], track: true });
const ts = await mm.batchStatus("formmy-user-42", tracked.batchId);
// ts.opened / clicked (unique), ts.openRate / clickRate (0–1 over delivered, or sent while delivered is 0),
// ts.topLinks: [{ url: "https://laespiga.mx/menu", clicks, uniqueClicks }] (utm_*, fbclid, gclid stripped)
// Test mode: validates everything (signature, domain, suppression, quota) without sending or billing
await mm.send("formmy-user-42", { from: "[email protected]", to: "[email protected]", subject: "s", text: "t" }, { test: true });
// Preview exactly what one recipient gets — same function the server uses, no network
import { previewMessage } from "@easybits.cloud/mailmask";
const p = previewMessage({ subject: "Hola {{nombre}}", html: "<p>{{nombre}}</p>", stream: "broadcast", vars: { nombre: "Ana" } });
// p.subject, p.html, p.unsubscribeFooter, p.missingVars, p.unusedVars
// Monthly usage (active tenants = at least one send this month)
await mm.usage({ month: "2026-10" });
// 4. Webhook: the whsec_ secret comes back only the FIRST time (later `set` calls return null:
// changing the URL doesn't rotate it)
const { secret } = await mm.webhook.set({ url: "https://formmy.app/api/mailmask/events" });
await mm.webhook.test(); // sends a `ping`
// Rotate without losing events: during the grace period (default 24 h) every delivery carries both
// signatures; pass both secrets to verifyWebhook until you've deployed the new one.
const { secret: next, previousValidUntil } = await mm.webhook.rotateSecret({ graceMinutes: 60 });
// Pausing: tenants.resume only lifts a pause YOU set (pausedBy "partner"). A reputation pause
// (pausedBy "mailmask" | "ses") throws ReputationHoldError: MailMask lifts it after review.
try { await mm.tenants.resume("formmy-user-42"); } catch (e) { if (!(e instanceof ReputationHoldError)) throw e; }
Webhook handler — the one-liner (Workers, Deno, Bun, Next.js route handlers, Hono…): verifies the
signature on the raw body, answers 401 / 200, and 500 if your onEvent throws (MailMask retries):
import { createWebhookHandler } from "@easybits.cloud/mailmask";
export const POST = createWebhookHandler({
secret: [process.env.MAILMASK_WEBHOOK_SECRET!, process.env.MAILMASK_WEBHOOK_SECRET_OLD ?? ""],
onEvent: async (event) => { /* dedupe by event.id */ },
});
Or by hand (Remix/React Router, anything with a Request):
export async function action({ request }: { request: Request }) {
const rawBody = await request.text(); // the RAW body, before JSON.parse
const event = await verifyWebhook({ rawBody, headers: request.headers, secret: [process.env.MAILMASK_WEBHOOK_SECRET!, process.env.MAILMASK_WEBHOOK_SECRET_OLD ?? ""] });
if (!event) return new Response("bad signature", { status: 401 });
// event.id is the delivery id: use it to drop duplicates
switch (event.event) {
case "email.bounced":
case "contact.unsubscribed":
// mark the reader as unsubscribed for event.externalId
break;
case "tenant.paused":
// notify the customer
break;
case "email.clicked":
// event.data.url, event.data.recipient, event.data.batchId, event.data.tags (first click only)
break;
}
return new Response("ok");
}
Errors: all extend MailMaskError (status, code, requestId, retryable) and PartnerApiError
(body, retryAfterMs and a kind discriminant for a plain switch):
| Class | status | kind | Retried by the SDK |
|---|---|---|---|
AuthError | 401 | auth | no — clockSkewMs + a hint in the message if your clock is off |
ForbiddenError | 403 | forbidden | no |
ReputationHoldError (extends ForbiddenError) | 403 | reputation_hold | no |
NotFoundError | 404 | not_found | no |
ConflictError | 409 | conflict | no |
ValidationError (field) | 422 | validation | no |
RecipientSuppressedError (reason, stream, recipient) | 422 | suppressed | no |
PausedError | 423 | paused | no |
QuotaError (limit, resetsAt) | 429 quota_exceeded | quota | no |
RateLimitError (extends QuotaError) | 429 rate_limited | rate_limit | yes |
PartnerApiError | 5xx | server | yes (safe requests) |
NetworkError / TimeoutError | 0 | network / timeout | yes (safe requests) |
requestId comes from X-Request-Id / Fly-Request-Id: quote it when asking for support.
Admin routes (your mk_ owner key, not the partner credential): MailMaskPartnerAdmin —
listPartners, listCredentials, createCredential, rotateCredential, revokeCredential,
getFallbackZone and, for MailMask staff only, createPartner, setReputationPolicy, resumeTenant,
setFallbackZone (registers the partner's fallback zone, e.g. marketing.denik.me, and returns the
4 NS to delegate; then domains.add(id, "<label>.marketing.denik.me") writes its DNS and verifies on
its own). Issuing and rotating
are never retried (a lost response would hide a secret nobody saw).
TypeScript
Los tipos publicados se pueden consumir desde TypeScript 4.9 en adelante, también con
moduleResolution: node (el clásico, sin exports). El paquete se compila con TypeScript 7, pero
el build baja la única sintaxis que un TS viejo no entiende (export type *, de TS 5.0) a su
equivalente (scripts/postbuild-dts.mjs): sin eso, un consumidor con TS ≤ 4.9 falla en su propia
compilación con TS1383 y desde aquí no se nota. npm run check:consumer arma el paquete con
npm pack y lo compila en un proyecto con TypeScript 4.9.5 para confirmarlo.
Docs
Full API reference and examples: https://mailmask.studio/docs
Links
- MailMask — Email addresses, mailboxes & forwarding on your domain
- EasyBits — Cloud platform
- Fixter — Development team
License
MIT