Node.js / TypeScript SDK
npm install mailinfra
Требуется Node.js 20+ (нужны глобальные fetch и WebCrypto). Пакет поставляется в ESM и CJS, без
зависимостей в рантайме, и работает в Cloudflare Workers, Deno и Bun — внутри только
fetch и WebCrypto.
Первое письмо
import { MailInfra } from "mailinfra";
const client = new MailInfra(); // ключ из MAILINFRA_API_KEY
const result = await client.emails.send({
from: "hello@ваш-домен.ru",
to: "user@example.com",
subject: "Добро пожаловать!",
html: "<p>Привет!</p>",
text: "Привет!",
});
console.log(result.id, result.status);
Ключ можно передать явно: new MailInfra({ apiKey: "mi_live_…" }). Тестовый ключ
mi_test_… работает через тот же клиент: письмо сохраняется со статусом SIMULATED,
не доставляется и не расходует месячный объём плана.
new MailInfra({ requireEnvironment: "live" }) не даст клиенту создаться, если в
конфигурации оказался тестовый ключ — и наоборот.
Имена полей письма совпадают с API один в один (from, reply_to, content_base64),
поэтому примеры из справочника по отправке переносятся в SDK
без переименований.
Отправка по шаблону
await client.emails.send({
from: "no-reply@ваш-домен.ru",
to: ["anna@example.com", "boris@example.com"],
template: "welcome", // тема берётся из шаблона
variables: { user_name: "Анна", app_name: "MyApp" },
tags: ["onboarding"],
});
Вложения
Передайте байты — SDK закодирует их в base64 сам:
import { readFile } from "node:fs/promises";
await client.emails.send({
from: "billing@ваш-домен.ru",
to: "user@example.com",
subject: "Счёт за май",
text: "Счёт во вложении.",
attachments: [
{
filename: "invoice.pdf",
content: await readFile("invoice.pdf"),
content_type: "application/pdf",
},
],
});
Картинка внутри письма — вложение с content_id, в HTML на неё ссылаются как
cid:<content_id>:
await client.emails.send({
from: "billing@ваш-домен.ru",
to: "user@example.com",
subject: "Чек",
html: '<img src="cid:logo" alt="Логотип"><p>Спасибо за заказ!</p>',
attachments: [
{ filename: "logo.png", content: await readFile("logo.png"), content_type: "image/png", content_id: "logo" },
],
});
Metadata
Свои пары «ключ — значение» — номер заказа, id пользователя. Они возвращаются в
get(), list() и в data.email.metadata каждого вебхука письма:
await client.emails.send({
from: "billing@ваш-домен.ru",
to: "user@example.com",
template: "order_paid",
variables: { order_id: 4242 },
metadata: { order_id: "4242", user_id: "u-17" },
});
До 20 ключей; ключ — до 40 символов из латиницы, цифр, _, - и .; значение —
строка до 500 символов; весь объект — до 4 КБ. SDK проверяет это до запроса.
Отложенная отправка
send_at — Date или строка ISO 8601 с часовым поясом, не дальше 7 дней:
const reminder = await client.emails.send({
from: "no-reply@ваш-домен.ru",
to: "user@example.com",
template: "webinar_reminder",
send_at: "2026-11-01T09:00:00+03:00",
});
console.log(reminder.status); // "SCHEDULED"
await client.emails.reschedule(reminder.id, { send_at: new Date("2026-11-01T08:00:00Z") });
await client.emails.cancel(reminder.id); // статус CANCELED, резерв квоты вернётся
Письмо, которое уже ушло или отменено, — EmailNotScheduledError (409); время
дальше 7 дней — SendAtTooFarError (400).
Срок жизни письма
ttl — сколько секунд письмо актуально (60–86 400), отсчёт от send_at или от
приёма. Не дошедшее за это время письмо не отправляется и не повторяется:
await client.emails.send({
from: "auth@ваш-домен.ru",
to: "user@example.com",
subject: "Код входа: 4821",
text: "Код действует 5 минут.",
ttl: 300,
});
Идемпотентность
const result = await client.emails.send(
{
from: "no-reply@ваш-домен.ru",
to: "user@example.com",
template: "order_paid",
variables: { order_id: 4242 },
},
{ idempotencyKey: "order_4242_paid" },
);
if (result.replayed) {
console.log("Это письмо уже было отправлено раньше");
}
Без idempotencyKey ключ генерируется автоматически (crypto.randomUUID()) и
переиспользуется внутренними повторами. { idempotencyKey: null } отключает заголовок
для одного вызова, new MailInfra({ autoIdempotency: false }) — для всех. Бэкенд
хранит ключ 24 часа: повтор с тем же ключом позже создаст новое письмо.
Пакетная отправка
const batch = await client.emails.sendBatch([
{ from: "no-reply@ваш-домен.ru", to: "a@example.com",
template: "welcome", variables: { user_name: "Анна" } },
{ from: "no-reply@ваш-домен.ru", to: "b@example.com",
template: "welcome", variables: { user_name: "Борис" } },
]);
for (const item of batch.results) {
if (item.error) {
console.error(item.index, item.error.code, item.error.message);
}
}
Ответ 202 не означает успех всего массива: проверяйте error у каждого элемента.
Idempotency-Key ставится на весь батч так же, как у send(), и переиспользуется в
повторах: уже принятые письма повтор не дублирует, поэтому SDK повторяет батч и
после 5xx, таймаута и обрыва соединения. Если ничего нового не принято,
batch.replayed === true:
const batch = await client.emails.sendBatch(emails, { idempotencyKey: "import_2026_10_28" });
Ключ батча — до 252 символов. Без ключа ({ idempotencyKey: null }) батч
повторяется только на 429.
Чтение статусов
const email = await client.emails.get(result.id);
console.log(email.status, email.delivered_at, email.final_smtp_code);
for (const event of await client.emails.events(result.id)) {
console.log(event.timestamp, event.type, event.data);
}
const page = await client.emails.list({
status: "BOUNCED",
tag: "onboarding",
limit: 50,
from: new Date(Date.now() - 24 * 3600 * 1000),
});
console.log(page.data.length, page.has_more, page.next_cursor);
for await (const bounced of client.emails.listAll({ status: "BOUNCED", limit: 100 })) {
console.log(bounced.id, bounced.metadata);
}
list() возвращает одну страницу (data, has_more, next_cursor), следующая —
тот же вызов с cursor: page.next_cursor. listAll() обходит страницы сам и лениво:
break останавливает загрузку. До 0.2.0 list() возвращал массив.
Статусы и типы событий — строковые литеральные типы ("DELIVERED" | "BOUNCED" | …),
поэтому TypeScript подсказывает допустимые значения и ловит опечатки.
Ошибки
import {
DomainNotVerifiedError,
MailInfraError,
RateLimitError,
UnprocessableError,
} from "mailinfra";
try {
await client.emails.send({ /* … */ });
} catch (error) {
if (error instanceof DomainNotVerifiedError) {
// домен отправителя ещё не подтверждён в DNS
} else if (error instanceof UnprocessableError) {
for (const field of error.fields) {
console.error(field.field, field.message);
}
} else if (error instanceof RateLimitError) {
console.error("повторить через", error.retryAfter, "с");
} else if (error instanceof MailInfraError) {
console.error(error.code, error.message, error.requestId);
} else {
throw error;
}
}
У любой ошибки есть .code, .message, .details, .statusCode и .requestId.
Конкретный класс всегда наследует класс своего HTTP-статуса: DomainNotVerifiedError
— это и UnprocessableError (422). Полный список кодов — в разделе
Ошибки и лимиты.
Повторы и таймауты
const client = new MailInfra({
timeoutMs: 10_000,
maxRetries: 4,
retryPolicy: {
initialBackoffMs: 500,
backoffFactor: 2,
maxBackoffMs: 8_000,
maxRetryAfterMs: 60_000, // дольше не ждём — отдаём RateLimitError
},
});
Свой транспорт (прокси, undici Agent, мок в тестах): new MailInfra({ fetch: myFetch }).
Если у вашего fetch свои таймауты длиннее 30 секунд, передайте timeoutMs: null —
иначе AbortSignal от SDK оборвёт запрос раньше, чем сработает ваша настройка.
Отмена вызова
Любой вызов принимает signal. Он складывается с таймаутом SDK — срабатывает тот,
что раньше — и прерывает не только сетевой запрос, но и паузу между повторами:
const controller = new AbortController();
setTimeout(() => {
controller.abort();
}, 2_000);
await client.emails.send(
{ from: "hello@mail.example.ru", to: "user@example.com", subject: "Тема", text: "Текст" },
{ signal: controller.signal },
);
Отменённый вызов не повторяется, а причина доезжает до вызывающего как есть —
AbortError, а не APIConnectionError.
Ретраить 5xx и таймауты у send() безопасно ровно потому, что SDK ставит
Idempotency-Key и переиспользует его на повторах. С autoIdempotency: false или
{ idempotencyKey: null } этой гарантии нет, поэтому SDK повторяет такую отправку
только на 429 — как батч.
Вебхуки
import express from "express";
import { constructEvent } from "mailinfra";
const app = express();
app.post(
"/mailinfra/events",
express.raw({ type: "application/json" }), // именно сырые байты
async (req, res) => {
const event = await constructEvent({
body: req.body,
secret: process.env.MAILINFRA_WEBHOOK_SECRET!,
signatureHeader: req.header("X-MailInfra-Signature")!,
});
if (event.type === "email.bounced") {
await markBounced(event.data.email?.id, event.data.event);
}
res.json({ status: "ok" });
},
);
constructEvent проверяет HMAC-подпись и её возраст (по умолчанию 5 минут — как на
стороне MailInfra) и только потом разбирает JSON. Если нужен просто флаг — есть
verify(...) => Promise<boolean>. Во время ротации секрета в заголовке две подписи
v1 — SDK принимает любую из них, так что старый секрет работает, пока вы не
обновили конфиг.
Подпись считается от байтов запроса. express.json() отдаёт уже разобранный объект —
подпись по нему не сойдётся. Используйте express.raw() (или аналог в вашем
фреймворке). Подробнее — Проверка подписи.
Edge-р антаймы
Клиент не использует node-only API, поэтому работает в Workers без полифилов:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const client = new MailInfra({ apiKey: env.MAILINFRA_API_KEY });
await client.emails.send({
from: "hello@ваш-домен.ru",
to: "user@example.com",
subject: "Из воркера",
text: "Привет!",
});
return new Response("ok");
},
};