Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@

Versions follow the MillionSend API they target; the API stays wire-compatible with Resend.

## 0.9.0 — unreleased

- `emails.receiving`: `list` (with `wait` long-polling and the `inboxId`, `to`, `from`, `since`, `subjectContains`, `unread`, `includeQuarantined` and `quarantined` filters), `get`, `reply`, `forward`, `remove`, and `attachments.list` / `attachments.get`.
- `inboxes`: `create`, `list`, `get`, `update`, `remove`, plus `safety`, `codes.latest`, `quarantine`, `labels`, `threads` (with `threads.emails`) and `drafts` (with `send` and `reject`).
- Replies, forwards and `drafts.send` resolve to `{ kind: "sent", email }`, or `{ kind: "draft", draft }` when the API holds the message for approval (HTTP 202).
- A request with `wait` gets a deadline that outlasts it, so long-polls past `timeoutMs` no longer abort.
- An id that is empty, `.` or `..` resolves to `{ error: { name: "validation_error", statusCode: null } }` without a request: URL normalization would have sent it to another resource.
- The long-poll's `held` items carry `reason` (`pending` or `quarantined`) and `quarantine_reason`.
- `WebhookEvent` names `email.received` and the 14 `inbox.*` events; webhooks take `inboxIds`; `apiKeys.create` takes `inboxId`; `domains.update` takes `receivingCatchAll`; `Domain`, `ApiKeyListItem`, `WebhookListItem` and `Usage.limits` gain `receiving_catch_all`, `inbox_id`, `inbox_ids` and `inboxes`.
- `inboxes.create` takes `username` (the part before the @) and `domainId` (a `domains.list` row instead of `emailAddress`); `inboxes.codes.latest` takes `since`; thread list and search items carry an optional `quarantined` count.
- Domains carry `shared`; the team's shared row, listed last by `domains.list`, adds `sending_mode` (`DomainSendingMode`) and `sending_mode_reason`.
- Every `ErrorResponse` carries `retryAfter`: the `retry-after` header in seconds, or `null`. A deep-equality check on `error` needs the new key.
- Contacts batch `errors` items are `BatchContactError`, with the `statusCode` and `name` strict mode would answer.
- The README covers newest-first lists and their cursors, retries, the shared domain's row, sending rules and refusals, and the recipients the API refuses.

Each feature needs this MillionSend version or later. Older instances answer 404 on paths they lack and ignore options they don't know, so an inbox created with `username` or `domainId` on one can get another address: check its `email_address`.

| MillionSend | Features |
|---|---|
| v0.10.0 | `inboxes`; the receiving long-poll, inbox filters, `reply` and `forward` |
| v0.11.0 | `422` for recipients that can never receive mail; a batch `idempotencyKey` replayed before validation |
| v0.12.0 | `username`; inboxes that never expire by default; sends from the shared domain under its mode; `name_review_unavailable`; `statusCode` and `name` on contacts batch errors |
| v0.13.0 | newest-first lists; `since` on `codes.latest`; `quarantined` on thread items; inbox names up to 320 characters; `unread` with an inbox-scoped key; a JSON 404 for unknown routes |
| v0.14.0 | `shared`, `sending_mode` and `sending_mode_reason` on domains; `domainId` on `inboxes.create`; `subject` up to 998 characters |

## 0.8.0 — 2026-09-08

- `contacts.remove(address, { erase: true })` and `contacts.batch.remove({ ids | emails, erase: true })` also scrub the address from email history, event payloads and API logs (`?erase=true` on the single delete, body `erase` on the batch) — a GDPR/LGPD erasure. Without `erase` a delete keeps the contact's emails in the send log, as before. Responses are unchanged. Needs MillionSend v0.6.54.
Expand Down
260 changes: 250 additions & 10 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "millionsend",
"version": "0.8.0",
"version": "0.9.0",
"description": "Official MillionSend SDK for Node.js — a Resend-compatible email API you can self-host.",
"type": "module",
"license": "MIT",
Expand Down
14 changes: 12 additions & 2 deletions src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,34 @@ export interface ErrorResponse {
message: string;
/** HTTP status; null when the request never reached the API. */
statusCode: number | null;
/**
* Seconds the response's `retry-after` header asks to wait before trying
* again; null when it sent none (and on client-side failures).
*/
retryAfter: number | null;
}

/** Every method resolves to this — it never throws for an API error. */
export type Result<T> = { data: T; error: null } | { data: null; error: ErrorResponse };

/** Coerce an arbitrary parsed error body into the canonical shape. */
export function toErrorResponse(body: unknown, status: number): ErrorResponse {
export function toErrorResponse(
body: unknown,
status: number,
retryAfter: number | null,
): ErrorResponse {
if (body && typeof body === "object") {
const b = body as Record<string, unknown>;
const name = typeof b.name === "string" ? b.name : "application_error";
const message =
typeof b.message === "string" ? b.message : `Request failed with status ${status}`;
const statusCode = typeof b.statusCode === "number" ? b.statusCode : status;
return { name, message, statusCode };
return { name, message, statusCode, retryAfter };
}
return {
name: "application_error",
message: `Request failed with status ${status}`,
statusCode: status,
retryAfter,
};
}
73 changes: 64 additions & 9 deletions src/http.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { type ErrorResponse, type Result, toErrorResponse } from "./error.js";
import type { BatchValidation } from "./types.js";
import type { BatchValidation, Draft, InboxSendOutcome } from "./types.js";

const DEFAULT_BASE_URL = "https://api.millionsend.com";
const DEFAULT_TIMEOUT_MS = 30_000;
const SDK_VERSION = "0.8.0";
/** Time a long-poll answer may take to arrive after the server's own `wait` deadline. */
const LONG_POLL_MARGIN_MS = 10_000;
const SDK_VERSION = "0.9.0";

export interface MillionSendOptions {
/**
Expand All @@ -27,7 +29,7 @@ export interface MillionSendOptions {
}

export interface RequestOptions {
/** POST-only idempotency key (emails.send, batch.send). */
/** POST-only idempotency key (emails.send, batch.send, replies, forwards, drafts.send). */
idempotencyKey?: string;
}

Expand Down Expand Up @@ -64,6 +66,23 @@ function isInsecureHttpUrl(url: string): boolean {
return !LOOPBACK_HOSTS.has(parsed.hostname) && !parsed.hostname.startsWith("127.");
}

/**
* Whether a path has an empty, "." or ".." segment: URL normalization
* resolves those away, so an id such as ".." would send the request to
* another resource (DELETE /inboxes/{id}/labels/.. deletes the inbox).
*/
function hasDotSegment(path: string): boolean {
return path
.split("/")
.slice(1)
.some((segment) => segment === "" || segment === "." || segment === "..");
}

/** `retry-after` in seconds; null when absent or not a whole number of seconds (the API sends only those). */
function retryAfterSeconds(header: string | null): number | null {
return header !== null && /^\s*\d+\s*$/.test(header) ? Number(header) : null;
}

function queryString(query: Record<string, string | number | undefined> | undefined): string {
if (!query) return "";
const params = new URLSearchParams();
Expand Down Expand Up @@ -109,14 +128,42 @@ export class HttpClient {
this.userAgent = options.userAgent ? `${base} ${options.userAgent}` : base;
}

async request<T>({
async request<T>(req: DoRequest): Promise<Result<T>> {
return (await this.call<T>(req)).result;
}

/**
* A send a person may have to approve (replies, forwards, drafts.send): an
* HTTP 202 answer is the draft the API held for approval, any other success
* the sent email.
*/
async requestOutcome<T>(req: DoRequest): Promise<Result<InboxSendOutcome<T>>> {
const { status, result } = await this.call<T | Draft>(req);
if (result.error) return result;
const data: InboxSendOutcome<T> =
status === 202
? { kind: "draft", draft: result.data as Draft }
: { kind: "sent", email: result.data as T };
return { data, error: null };
}

private async call<T>({
method,
path,
body,
query,
idempotencyKey,
headers: extraHeaders,
}: DoRequest): Promise<Result<T>> {
}: DoRequest): Promise<{ status: number | null; result: Result<T> }> {
if (hasDotSegment(path)) {
const error: ErrorResponse = {
name: "validation_error",
message: 'An id in the request path is empty, "." or "..".',
statusCode: null,
retryAfter: null,
};
return { status: null, result: { data: null, error } };
}
const url = `${this.baseUrl}${path}${queryString(query)}`;
const headers: Record<string, string> = {
Authorization: `Bearer ${this.#apiKey}`,
Expand All @@ -126,7 +173,12 @@ export class HttpClient {
for (const [key, value] of Object.entries(extraHeaders ?? {})) {
if (value !== undefined) headers[key] = value;
}
const init: RequestInit = { method, headers, signal: AbortSignal.timeout(this.timeoutMs) };
// A `wait` query asks the API to hold its answer up to that many seconds
// (long-poll), so the deadline has to outlast it.
const wait = Number(query?.wait ?? 0);
const timeoutMs =
wait > 0 ? Math.max(this.timeoutMs, wait * 1000 + LONG_POLL_MARGIN_MS) : this.timeoutMs;
const init: RequestInit = { method, headers, signal: AbortSignal.timeout(timeoutMs) };
if (body !== undefined && method !== "GET" && method !== "DELETE") {
headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
Expand All @@ -142,8 +194,9 @@ export class HttpClient {
name: "application_error",
message: cause instanceof Error ? cause.message : String(cause),
statusCode: null,
retryAfter: null,
};
return { data: null, error };
return { status: null, result: { data: null, error } };
}

const text = await response.text();
Expand All @@ -156,9 +209,11 @@ export class HttpClient {
}
}

const status = response.status;
if (!response.ok) {
return { data: null, error: toErrorResponse(parsed, response.status) };
const retryAfter = retryAfterSeconds(response.headers.get("retry-after"));
return { status, result: { data: null, error: toErrorResponse(parsed, status, retryAfter) } };
}
return { data: parsed as T, error: null };
return { status, result: { data: parsed as T, error: null } };
}
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Contacts } from "./resources/contacts.js";
import { Deliverability } from "./resources/deliverability.js";
import { Domains } from "./resources/domains.js";
import { Batch, Emails } from "./resources/emails.js";
import { Inboxes } from "./resources/inboxes.js";
import { Segments } from "./resources/segments.js";
import { Suppressions } from "./resources/suppressions.js";
import { Templates } from "./resources/templates.js";
Expand Down Expand Up @@ -34,6 +35,7 @@ import { Webhooks } from "./resources/webhooks.js";
export class MillionSend {
readonly emails: Emails;
readonly batch: Batch;
readonly inboxes: Inboxes;
readonly contacts: Contacts;
readonly contactProperties: ContactProperties;
readonly broadcasts: Broadcasts;
Expand All @@ -57,6 +59,7 @@ export class MillionSend {
const http = new HttpClient(key, options);
this.emails = new Emails(http);
this.batch = new Batch(http);
this.inboxes = new Inboxes(http);
this.contacts = new Contacts(http);
this.contactProperties = new ContactProperties(http);
this.broadcasts = new Broadcasts(http);
Expand Down
10 changes: 10 additions & 0 deletions src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,13 @@ export function listQuery(
if (!options) return undefined;
return { limit: options.limit, after: options.after, before: options.before };
}

/** A boolean filter as the API reads it: "true" or "false"; undefined stays off the wire. */
export function bool(value: boolean | undefined): string | undefined {
return value === undefined ? undefined : String(value);
}

/** A list filter as the API reads it: comma-separated. An empty list stays off the wire, since `labels=` is not "no labels". */
export function csv(values: readonly string[] | undefined): string | undefined {
return values?.length ? values.join(",") : undefined;
}
2 changes: 2 additions & 0 deletions src/resources/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
export class ApiKeys {
constructor(private readonly http: HttpClient) {}

/** POST /api-keys — a `domainId` must be one of your own domains, not the `shared: true` row. */
create(payload: CreateApiKeyOptions): Promise<Result<CreateApiKeyResponse>> {
return this.http.request({
method: "POST",
Expand All @@ -22,6 +23,7 @@ export class ApiKeys {
name: payload.name,
permission: payload.permission,
domain_id: payload.domainId ?? payload.domain_id,
inbox_id: payload.inboxId,
},
});
}
Expand Down
5 changes: 4 additions & 1 deletion src/resources/broadcasts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ function toWire(o: UpdateBroadcastOptions) {
export class Broadcasts {
constructor(private readonly http: HttpClient) {}

/** POST /broadcasts — a draft unless `send: true` (optionally with `scheduledAt`). */
/**
* POST /broadcasts — a draft unless `send: true` (optionally with `scheduledAt`).
* Sending from the shared domain answers 403 `inbox_send_policy`, here and on `send`.
*/
create(payload: CreateBroadcastOptions): Promise<Result<BroadcastId>> {
return this.http.request({
method: "POST",
Expand Down
12 changes: 10 additions & 2 deletions src/resources/domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import type {
UpdateDomainOptions,
} from "../types.js";

/** Sending domains and their DNS records. */
/**
* Sending domains and their DNS records. The team's subdomain of the shared
* inbox domain is listed too, read-only, as the last row (`shared: true`).
*/
export class Domains {
constructor(private readonly http: HttpClient) {}

Expand All @@ -32,6 +35,7 @@ export class Domains {
});
}

/** GET /domains — own domains newest first, then the shared row. */
list(options?: ListOptions): Promise<Result<List<DomainListItem>>> {
return this.http.request({ method: "GET", path: "/domains", query: listQuery(options) });
}
Expand All @@ -40,7 +44,10 @@ export class Domains {
return this.http.request({ method: "GET", path: `/domains/${encodeURIComponent(id)}` });
}

/** PATCH /domains/:id — tracking settings; `trackingSubdomain: null` clears it. */
/**
* PATCH /domains/:id — tracking and catch-all settings; `trackingSubdomain: null` clears it.
* The shared row answers 403 `forbidden` here, on `verify` and on `remove`.
*/
update(payload: UpdateDomainOptions): Promise<Result<Domain>> {
return this.http.request({
method: "PATCH",
Expand All @@ -51,6 +58,7 @@ export class Domains {
tracking_subdomain: payload.trackingSubdomain,
tls: payload.tls,
capabilities: payload.capabilities,
receiving_catch_all: payload.receivingCatchAll,
},
});
}
Expand Down
19 changes: 16 additions & 3 deletions src/resources/emails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
UpdateEmailOptions,
UpdateEmailResponse,
} from "../types.js";
import { Receiving } from "./receiving.js";

function toWire(o: SendEmailOptions) {
return {
Expand Down Expand Up @@ -42,9 +43,18 @@ function toWire(o: SendEmailOptions) {
}

export class Emails {
constructor(private readonly http: HttpClient) {}
readonly receiving: Receiving;

constructor(private readonly http: HttpClient) {
this.receiving = new Receiving(http);
}

/** POST /emails — supports an Idempotency-Key. */
/**
* POST /emails — supports an Idempotency-Key. The shared domain sends only in
* `sending_mode: "open"`, from an inbox's exact address (display name omitted
* or its `from_name`), without `replyTo`, `attachments`, `headers` or
* `scheduledAt`, to at most 10 recipients.
*/
send(
payload: SendEmailOptions,
options: RequestOptions = {},
Expand Down Expand Up @@ -109,7 +119,10 @@ export class Emails {
export class Batch {
constructor(private readonly http: HttpClient) {}

/** POST /emails/batch — 1–100 emails in one call; supports an Idempotency-Key and `batchValidation`. */
/**
* POST /emails/batch — 1–100 emails in one call; supports an Idempotency-Key and `batchValidation`.
* An item from the shared domain is refused (403 `inbox_send_policy`).
*/
send(
payload: SendEmailOptions[],
options: BatchRequestOptions = {},
Expand Down
Loading
Loading