Node.js / TypeScript SDK
npm install mailinfra
Требуется Node.js 18+ (нужен глобальный fetch). Пакет поставляется в 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",
},
],
});
Идемпотентность
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 }) — для всех.
Пакетная отправка
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);
}
}
POST /v1/emails/batch не принимает Idempotency-Key, а письма внутри батча
принимаются по одному. Поэтому SDK не повторяет батч после 5xx — повтор мог бы
отправить часть писем дважды. Повторяются только 429 и отказы на соединении.
Ответ 202 не означает успех всего массива: проверяйте error у каждого элемента.
Чтение статусов
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 bounced = await client.emails.list({
status: "BOUNCED",
tag: "onboarding",
limit: 50,
from: new Date(Date.now() - 24 * 3600 * 1000),
});
Статусы и типы событий — строковые литеральные типы ("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.
Полный список кодов — в разделе Ошибки и лимиты.
Повторы и таймауты
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 }).
Вебхуки
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>.
Подпись считается от байтов запроса. 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");
},
};