The official Mailloop SDK for JavaScript and TypeScript. Zero dependencies, full type safety, and dual ESM/CommonJS builds.
Installation
# npm
npm install @mailloop/sdk
# yarn
yarn add @mailloop/sdk
# pnpm
pnpm add @mailloop/sdk Requirements: Node.js 18 or later (uses native fetch).
MailloopClient
The main entry point for the SDK.
import { MailloopClient } from '@mailloop/sdk';
const client = new MailloopClient({
apiKey: 'ml_live_your_api_key_here'
}); Options
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
apiKey | string | Yes | -- | API key from the dashboard |
baseUrl | string | No | https://api.mailloop.io/v1 | API base URL |
timeout | number | No | 30000 | Request timeout in milliseconds |
Sandboxes
Manage email testing sandboxes.
client.sandboxes.create(params)
Create a new persistent sandbox.
const sandbox = await client.sandboxes.create({
name: 'my-sandbox'
}); Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Sandbox name (1–100 characters) |
Returns: Sandbox
client.sandboxes.createTemporary(params)
Create a sandbox that auto-deletes after the specified duration. Ideal for CI/CD and automated tests.
const sandbox = await client.sandboxes.createTemporary({
duration: 30,
name: 'ci-test'
}); Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
duration | number | Yes | Seconds until auto-deletion (1–86400, up to 24 hours) |
name | string | No | Optional sandbox name |
Returns: Sandbox with isTemporary: true and deleteAfter set.
client.sandboxes.list()
List all sandboxes for your organization.
const { sandboxes } = await client.sandboxes.list(); const sandbox = await client.sandboxes.get('sandbox-id'); const updated = await client.sandboxes.update('sandbox-id', {
name: 'renamed-sandbox'
}); Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
name | string | No | New sandbox name |
Returns: Sandbox
client.sandboxes.delete(id)
Permanently delete a sandbox and all its emails.
await client.sandboxes.delete('sandbox-id'); Returns: void
Emails
List, retrieve, and wait for emails in a sandbox.
client.emails.list(sandboxId, params?)
List emails in a sandbox, ordered by received time (newest first).
const { emails, total } = await client.emails.list('sandbox-id', {
limit: 10,
offset: 0
});
for (const email of emails) {
console.log(`${email.from}: ${email.subject}`);
} Parameters:
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
limit | number | No | 20 | Max emails to return (1–100) |
offset | number | No | 0 | Number of emails to skip |
after | string | No | -- | Cursor: an email ID you already saw. Returns emails older than it, since the list is newest first. Takes precedence over offset. |
Returns: { emails: EmailSummary[], total: number, nextCursor: string | null, hasMore: boolean }
total is the sandbox-wide count on every page, including cursor pages. Page to the end by passing nextCursor back as after until hasMore is false.
client.emails.get(sandboxId, emailId)
Get the full details of a specific email, including HTML body, headers, and attachments.
const email = await client.emails.get('sandbox-id', 'email-id');
console.log(email.subject);
console.log(email.html);
console.log(email.headers);
console.log(email.attachments); Returns: EmailDetail
client.emails.waitFor(sandboxId, params?)
Wait for an email matching the given filters. The server watches the sandbox and returns as soon as a match is found, including one that already landed in the last 15 seconds. If the send happened earlier in the run, widen that lookback with sinceMs.
// Wait for any email
const email = await client.emails.waitFor('sandbox-id');
// Wait for an email from a specific sender
const email = await client.emails.waitFor('sandbox-id', {
from: '[email protected]',
timeout: 45000
});
// Wait for an email with a specific subject
const email = await client.emails.waitFor('sandbox-id', {
subject: 'Password Reset'
}); Parameters:
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
timeout | number | No | 30000 | Max wait time in ms. Must be between 1000 and 60000; anything else throws ValidationError and the API rejects it too, it is never clamped. |
sinceMs | number | No | 15000 | How far back to look for an email that already arrived, in ms. Max 900000 (15 minutes). Pass 0 to match only mail arriving during the call, and keep it tight in a sandbox you reuse across cases. |
from | string | No | -- | Filter by sender address |
to | string | No | -- | Filter by recipient address |
subject | string | No | -- | Filter by subject |
Filters are case-insensitive literal substrings, never patterns, so %, _, ., *, +, [, ], (, ), $ and ^ match themselves. All supplied filters must match. Both sides are Unicode-normalised and whitespace-collapsed before comparing, so a subject copied straight out of a listing works as-is.
Returns: EmailDetail
Throws: TimeoutError if no matching email arrives within the timeout.
import { TimeoutError } from '@mailloop/sdk';
try {
const email = await client.emails.waitFor('sandbox-id', {
subject: 'Welcome',
timeout: 10000
});
} catch (error) {
if (error instanceof TimeoutError) {
console.log('No matching email arrived within 10 seconds');
}
} client.emails.send(params) (Experimental, not generally available)
Note: Outbound sending is experimental. The endpoint requires the
emails:sendpermission, which cannot be selected when creating an API key,so a call returns a forbidden error unless Mailloop granted it to your key.
Mailloop captures mail; it does not send on your behalf. Documented here only
so the method in your editor's autocomplete is explained.
Send a transactional email with block-based content.
const result = await client.emails.send({
from: '[email protected]',
to: ['[email protected]'],
subject: 'Your order has shipped',
blocks: [
{ type: 'heading', content: 'Order Shipped', level: 2 },
{ type: 'paragraph', content: 'Your order #4821 is on its way.' },
{ type: 'button', text: 'Track Package', href: 'https://acme.com/track/4821' }
],
layout: {
primaryColor: '#4F46E5',
companyName: 'Acme Inc.'
}
});
console.log(result.id); // Email ID
console.log(result.status); // "sent" or "failed" Block types: paragraph, heading, button, image, spacer, divider, code, list, callout.
Screenshots (beta)
Render a captured email the way a real email client shows it. Needs the emails:screenshot scope on the API key. Clients, fidelity tiers and plan limits are described in Email Screenshots.
client.screenshots.listClients()
List the email clients you can render, with the fidelity tier of each and this month's quota. Needs no email, so a test suite can choose its client ids up front.
const { clients, quota } = await client.screenshots.listClients();
const ids = clients.filter(c => c.available).map(c => c.id);
// ['gmail-web', 'gmail-android', 'outlook-com', ...] Each client has id, displayName, aliases, device, fidelityTier, engine, engineRendered, engineMismatch, windowWidth, paneWidth, darkSupported and available (false when your plan does not include it).
client.screenshots.list(sandboxId, emailId)
List the clients and themes you can render for an email, which already have a stored render, and this month's quota.
const { variants, quota } = await client.screenshots.list(sandbox.id, email.id);
for (const v of variants.filter(v => v.available)) {
console.log(v.client, v.theme, v.fidelityTier, v.rendered ? v.url : 'not rendered yet');
}
console.log(`${quota.limit - quota.used} previews left this month`); client.screenshots.create(sandboxId, emailId, params)
Render one client in one theme. The image is stored for seven days; asking again for the same client and theme returns the stored render and spends nothing.
const shot = await client.screenshots.create(sandbox.id, email.id, {
client: 'gmail-web',
theme: 'light'
});
console.log(shot.url); // Signed link, valid 30 minutes, works without an API key
console.log(shot.fidelityTier); // "A", "B" or "B-" | Parameter | Type | Description |
|---|---|---|
client | string | Client id from screenshots.list, for example gmail-web |
theme | 'light' | 'dark' | Defaults to light. Refused for a client without dark support |
force | boolean | Render again although a stored render is live. Spends a preview |
An organization renders one email at a time on the Free plan and four or more on a paid plan, across the dashboard, the API and MCP. Extra calls wait their turn on the server, and create retries a few times when it is told to come back, so Promise.all over several emails works and simply runs a few at a time.
Throws QuotaExceededError when the month's previews are used up, RateLimitError over the plan's renders a minute for the organization, ValidationError for UNKNOWN_CLIENT, NO_HTML and TOO_LARGE, a PermissionError on the Free plan until an email address is confirmed, and a MailloopError with code PLAN_UPGRADE_REQUIRED or RENDER_FAILED.
client.screenshots.download(variant)
Fetch the image bytes of a rendered variant, or of a signed url.
import { writeFile } from 'node:fs/promises';
await writeFile('welcome-gmail.png', await client.screenshots.download(shot)); The signed link is its own credential, so the API key is not sent to the storage host. When the link has expired, call screenshots.list for a fresh one.
Types
Sandbox
interface Sandbox {
id: string;
name: string;
username: string;
password: string;
emailAddress: string; // [email protected]
emailCount: number;
isTemporary: boolean;
deleteAfter: string | null; // ISO timestamp
createdAt: string; // ISO timestamp
updatedAt: string; // ISO timestamp
} interface EmailSummary {
id: string;
from: string;
to: string[];
subject: string;
preview: string; // First 128 characters of plain text
hasAttachments: boolean;
receivedAt: string; // ISO timestamp
} EmailDetail
Returned by emails.get() and emails.waitFor(). Contains the full email body, headers, and attachment metadata.
interface EmailDetail {
id: string;
messageId: string;
from: string;
to: string[];
cc: string[] | null;
bcc: string[] | null;
subject: string;
text: string | null;
html: string | null;
headers: Record<string, string>;
hasAttachments: boolean;
attachments: EmailAttachment[];
receivedAt: string; // ISO timestamp
} interface EmailAttachment {
id: string;
filename: string;
contentType: string;
size: number; // Size in bytes
checksum: string;
} MailloopError (base class)
├── AuthenticationError (401 - invalid or missing API key)
├── PermissionError (403 - insufficient permissions)
├── NotFoundError (404 - resource not found)
├── RateLimitError (429 - too many requests)
├── QuotaExceededError (429 - a monthly quota is used up)
├── TimeoutError (waiting for email timed out)
└── ValidationError (400 - invalid request data) All errors extend MailloopError and include these properties:
| Property | Type | Description |
|---|---|---|
message | string | Human-readable message |
code | string | Machine-readable code |
status | number | undefined | HTTP status code |
details | Record<string, unknown> | undefined | Additional context |
RateLimitError also includes a retryAfter property (seconds until the limit resets). A QuotaExceededError does not: the quota comes back with the next billing month or a plan upgrade, so retrying does not help.
Example
import {
MailloopClient,
AuthenticationError,
NotFoundError,
RateLimitError,
TimeoutError,
ValidationError,
} from '@mailloop/sdk';
const client = new MailloopClient({ apiKey: process.env.MAILLOOP_API_KEY });
try {
const email = await client.emails.waitFor('sandbox-id', {
subject: 'Welcome',
timeout: 15000
});
} catch (error) {
if (error instanceof TimeoutError) {
console.log('No email arrived in time');
} else if (error instanceof AuthenticationError) {
console.log('Check your API key');
} else if (error instanceof NotFoundError) {
console.log('Sandbox does not exist');
} else if (error instanceof RateLimitError) {
console.log(`Rate limited. Retry after ${error.retryAfter}s`);
} else if (error instanceof ValidationError) {
console.log('Invalid parameters:', error.details);
} else {
throw error;
}
} REST API
The full interactive API documentation is available at api.mailloop.io (Swagger UI).
Authentication
All API requests require a Bearer token in the Authorization header:
Authorization: Bearer ml_live_your_api_key_here https://api.mailloop.io/v1 Rate Limits
Rate limits are applied per API key:
| Endpoint | Limit | Window |
|---|---|---|
| General | 200 req | 1 minute |
| Sandbox create | 30 req | 1 minute |
| Email list | 180 req | 1 minute |
| Email wait | 60 req | 1 minute |
| Email send | 60 req | 1 minute |
Rate limit information is included in response headers:
| Header | Description |
|---|---|
X-RateLimit-Limit | Request limit per window |
X-RateLimit-Remaining | Remaining requests in current window |
X-RateLimit-Reset | Unix timestamp when the limit resets |
Retry-After | Seconds until the limit resets (on 429) |