Перейти к основному содержимому

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");
},
};