# MailInfra — полная документация
> Транзакционный email API для российского рынка: отправка писем через REST или SMTP, верификация доменов, шаблоны, вебхуки и список подавления.
- Базовый URL Public API — `https://api.mailinfra.ru/v1`.
- Аутентификация отправки и чтения писем: `Authorization: Bearer mi_live_…` (продакшен) или `mi_test_…` (sandbox, письма не доставляются).
- Management API (домены, ключи, шаблоны, вебхуки, подавления) работает на сессионном токене пользователя, а не на API-ключе проекта.
- Вся документация одним файлом: https://docs.mailinfra.ru/llms-full.txt
Источник: https://docs.mailinfra.ru. Файл собран автоматически из документации сайта.
---
# Начало работы
## Быстрый старт
Источник: https://docs.mailinfra.ru/intro
За пять минут от регистрации до первого отправленного письма.
## 1. Получите API-ключ
В дашборде [mailinfra.ru/emails](https://mailinfra.ru/emails) откройте **Проект → API-ключи → Создать ключ**. Скопируйте показанный токен — повторно его получить нельзя.
- `mi_test_…` — sandbox, без верификации домена и без реальной доставки.
- `mi_live_…` — продакшен, требует верифицированный домен.
Начните с `mi_test_…` — это самый быстрый способ проверить интеграцию.
## 2. Отправьте письмо
Все запросы аутентифицируются заголовком `Authorization: Bearer <ваш ключ>`.
**cURL**
```bash
curl -X POST https://api.mailinfra.ru/v1/emails \
-H "Authorization: Bearer mi_test_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"from": "hello@example.com",
"to": ["you@example.com"],
"subject": "Тест MailInfra",
"html": "
Привет!
"
}'
```
**Python**
```python
import httpx
response = httpx.post(
"https://api.mailinfra.ru/v1/emails",
headers={"Authorization": "Bearer mi_test_xxxxxxxx"},
json={
"from": "hello@example.com",
"to": ["you@example.com"],
"subject": "Тест MailInfra",
"html": "Привет!
",
},
)
data = response.json()["data"]
print(data["id"], data["status"])
```
**TypeScript**
```typescript
const res = await fetch("https://api.mailinfra.ru/v1/emails", {
method: "POST",
headers: {
Authorization: "Bearer mi_test_xxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "hello@example.com",
to: ["you@example.com"],
subject: "Тест MailInfra",
html: "Привет!
",
}),
});
const { data } = await res.json();
console.log(data.id, data.status);
```
Ответ:
```json
{
"data": {
"id": "018e1b7a-1111-7000-aaaa-111111111111",
"status": "SIMULATED",
"skipped_recipients": []
}
}
```
`SIMULATED` — sandbox-режим, реальной доставки нет. С `mi_live_…` и верифицированным доменом будет `QUEUED` → `DELIVERED`.
## 3. Что дальше
| Задача | Гайд |
|--------|------|
| Все параметры `POST /v1/emails` | [Отправка письма →](https://docs.mailinfra.ru/sending/single-email) |
| Подключить домен для реальной отправки | [Настройка DNS →](https://docs.mailinfra.ru/domains/dns-setup) |
| Шаблоны Jinja2 | [Шаблоны →](https://docs.mailinfra.ru/sending/templates) |
| Получать события доставки | [Вебхуки →](https://docs.mailinfra.ru/webhooks/overview) |
| Коды ошибок и лимиты | [Ошибки →](https://docs.mailinfra.ru/errors) |
| Обратиться в поддержку | [Обращения в дашборде →](https://docs.mailinfra.ru/dashboard/support) |
## API-ключи
Источник: https://docs.mailinfra.ru/api-keys
API-ключ — единственный способ аутентификации в Public API. JWT и cookie дашборда здесь не работают.
```http
Authorization: Bearer mi_live_ab12cd34ef56gh78ij90kl12mn34op56
```
Ключ привязан к конкретному **проекту** и определяет, от чьего имени и в какой среде выполняется запрос. Создание, ротация и отзыв ключей — в дашборде: **Проект → API-ключи**.
> **Plain-токен показывается один раз**
>
> При создании дашборд один раз отдаёт полное значение ключа. Сохраните его сразу — потом видна только префикс-часть (`mi_live_ab12cd…`).
## Окружение: LIVE и TEST
Окружение зашито в префикс ключа.
| | `mi_live_…` (LIVE) | `mi_test_…` (TEST / sandbox) |
|---|---|---|
| Реальная отправка | да | нет, статус `SIMULATED` |
| Нужен верифицированный домен | да | нет |
| Подписанный DPA | да | нет |
| Месячный объём и live RPS | применяются | не расходуются |
TEST удобен для CI и интеграционных тестов: тот же endpoint, та же схема ответа,
но письма только сохраняются со статусом `SIMULATED` и не доставляются получателям.
Защитные ограничения размера запроса, числа получателей и вложений сохраняются.
Не путайте TEST-ключ с одноразовым onboarding trial через адрес
`*@sandbox.mailinfra.ru` и LIVE-ключ. Такой trial разрешает одно реальное письмо
только на email создателя ключа и не поддерживает resend. Для многократных тестов
используйте `mi_test_…`.
## Права доступа
| Право | `POST /v1/emails`
`POST /v1/emails/batch` | `GET /v1/emails`
`GET /v1/emails/{id}`
`GET /v1/emails/{id}/events` |
|---|:---:|:---:|
| `FULL_ACCESS` | да | да |
| `SENDING_ONLY` | да | нет |
| `READ_ONLY` | нет | да |
Запрос с недостаточными правами возвращает `403 Forbidden` — ключ остаётся валидным, отзывать его не нужно.
Типичные сценарии:
- `SENDING_ONLY` — backend-сервис, который только шлёт транзакционные письма и не должен иметь доступ к содержимому логов.
- `READ_ONLY` — внутренние BI-дашборды, скрипты сверки, инциденты.
- `FULL_ACCESS` — небольшие приложения, которым удобно делать оба класса операций одним ключом.
## Срок действия и отзыв
- `expires_at` (опционально) — после этой даты запросы возвращают `401 Unauthorized`. По умолчанию ключ бессрочный.
- Отзыв ключа в дашборде применяется немедленно: следующий запрос с этим ключом получит `401`.
## Хранение и ротация
**Храните ключ как пароль** — в переменных окружения или секретах CI, не в репозитории.
**Python**
```python
import os
api_key = os.environ["MAILINFRA_API_KEY"]
```
**TypeScript / Node.js**
```typescript
const apiKey = process.env.MAILINFRA_API_KEY!;
```
**Go**
```go
apiKey := os.Getenv("MAILINFRA_API_KEY")
```
При утечке или плановой смене:
1. Создайте новый ключ в дашборде.
2. Обновите секрет в окружении приложения.
3. Отзовите старый ключ.
## Ошибки аутентификации
| HTTP | Когда возникает |
|------|-----------------|
| `401 Unauthorized` | Ключ отсутствует, неверный, отозван или истёк |
| `403 Forbidden` | Ключ валиден, но прав недостаточно для этого endpoint |
---
## Управление ключами через API
Создавать, листать и отзывать ключи можно не только в дашборде, но и программно. Эти эндпоинты — часть [Management API](https://docs.mailinfra.ru/management-api): они идут **не** с `mi_live_…` ключом, а с сессионным токеном пользователя.
Базовый префикс: `/v1/organizations/{organization_id}/projects/{project_id}/api-keys`.
### Создать ключ
```http
POST /v1/organizations/{organization_id}/projects/{project_id}/api-keys
Authorization: Bearer
Content-Type: application/json
```
```json
{
"name": "Backend production",
"permissions": "FULL_ACCESS",
"env": "LIVE",
"expires_at": null
}
```
| Поле | Тип | По умолчанию | Описание |
|------|-----|--------------|----------|
| `name` | `string` | — | 1–100 символов; уникален среди активных ключей проекта |
| `permissions` | `FULL_ACCESS \| SENDING_ONLY \| READ_ONLY` | `FULL_ACCESS` | См. [таблицу прав выше](#права-доступа) |
| `env` | `LIVE \| TEST` | `LIVE` | Окружение |
| `expires_at` | `datetime \| null` | `null` | ISO-8601, UTC; `null` — бессрочный |
**Ответ `201 Created`** содержит `key` — plain-токен, показывается **только в этом ответе**:
```json
{
"data": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"project_id": "...",
"name": "Backend production",
"prefix": "mi_live_ab12cd",
"permissions": "FULL_ACCESS",
"env": "LIVE",
"last_used_at": null,
"expires_at": null,
"revoked_at": null,
"created_at": "2026-05-12T09:00:00Z",
"key": "mi_live_ab12cd34ef56gh78ij90kl12mn34op56qr78st90uv12wx34yz56"
}
}
```
### Список ключей
```http
GET /v1/organizations/{organization_id}/projects/{project_id}/api-keys
Authorization: Bearer
```
Параметр `?include_revoked=true` дополнительно возвращает отозванные ключи. По умолчанию отозванные скрыты. Поле `key` в листинге **не возвращается** — видна только префикс-часть (`prefix`) для опознавания.
### Отозвать ключ
```http
DELETE /v1/organizations/{organization_id}/projects/{project_id}/api-keys/{key_id}
Authorization: Bearer
```
После этого следующий запрос с этим ключом получит `401`. Имя отозванного ключа можно переиспользовать — уникальность держится только среди активных.
### Возможные ошибки
| HTTP | Код | Когда |
|------|-----|-------|
| `409` | `conflict` | Активный ключ с таким именем уже существует в проекте |
| `404` | `not_found` | Проект не найден или не принадлежит организации |
| `403` | `forbidden` | У пользователя меньше прав, чем `DEVELOPER` в организации |
## Management API
Источник: https://docs.mailinfra.ru/management-api
Public API (`/v1/emails*`) — для отправки и чтения писем по `mi_live_…` / `mi_test_…` ключу. Всё остальное — управление ресурсами организации (проекты, ключи, домены, шаблоны, вебхуки, список подавления, статистика, аудит) — это **Management API**.
Management API делает то же самое, что дашборд, и подходит для:
- автоматизации создания доменов и ключей при онбординге клиентов;
- терраформинг-стиля управления конфигурацией (CI поднимает шаблоны, вебхуки, ключи);
- BI-скриптов и внутренних админок поверх ваших организаций;
- self-service: ваш собственный продукт оборачивает MailInfra и создаёт суб-проекты под клиентов.
## Аутентификация
Management API использует **сессионный токен** пользователя — обычный `Authorization: Bearer …`, но это **не** API-ключ проекта.
```http
Authorization: Bearer
```
Получить токен — обменять email/password на пару access + refresh:
```http
POST /v1/auth/login
Content-Type: application/json
```
```json
{
"email": "you@company.ru",
"password": "•••••••••"
}
```
```json
{
"data": {
"access_token": "eyJhbGciOi...",
"token_type": "bearer",
"expires_in": 900
}
}
```
`access_token` живёт 15 минут. Когда он истёк — обменивайте refresh-token (приходит в HttpOnly cookie) через `POST /v1/auth/refresh`.
Передавайте `access_token` в заголовке `Authorization: Bearer …` для всех управляющих эндпоинтов.
> **Не путайте токены**
>
> - `mi_live_…` / `mi_test_…` — Public API, отправка и чтение писем.
> - `` — Management API, всё остальное.
>
> Передача API-ключа на управляющий endpoint вернёт `401`, как и наоборот.
## Роли в организации
Каждый пользователь — участник одной или нескольких организаций с одной из ролей:
| Роль | Чтение | Создание / изменение ресурсов | Удаление ключевых ресурсов |
|------|:------:|:-----------------------------:|:--------------------------:|
| `OWNER` | да | да | да (включая саму организацию) |
| `ADMIN` | да | да | да (домены, участники) |
| `DEVELOPER` | да | да (ключи, шаблоны, вебхуки, suppressions) | нет |
| `VIEWER` | да | нет | нет |
Если у пользователя меньше прав, чем требует endpoint, ответ — `403 Forbidden`. Конкретные требования к роли указаны в каждом разделе.
## Структура ресурсов
```
Organization
├── Member участник с ролью
├── Domain верифицируется один раз через DNS
├── Suppression общий список подавления
├── LegalAcceptance DPA, Privacy Policy и т.п.
└── Project
├── ProjectDomain привязка верифицированного домена к проекту
├── ApiKey mi_live_…/mi_test_…
├── Template Jinja2-шаблоны
├── Webhook подписки на события доставки
└── Email отправленные письма (создаются Public API)
```
Все управляющие пути имеют префикс `/v1/organizations/{organization_id}/...`, эндпоинты, относящиеся к проекту — `/v1/organizations/{organization_id}/projects/{project_id}/...`.
## Карта эндпоинтов
| Ресурс | Endpoint | Гайд |
|--------|----------|------|
| API-ключи | `…/projects/{id}/api-keys` | [API-ключи →](https://docs.mailinfra.ru/api-keys#управление-ключами-через-api) |
| Домены организации | `…/domains` | [Домены →](https://docs.mailinfra.ru/domains/overview#api-доменов) |
| Привязка доменов к проекту | `…/projects/{id}/domains` | [Домены →](https://docs.mailinfra.ru/domains/overview#привязка-к-проекту) |
| Шаблоны | `…/projects/{id}/templates` | [Шаблоны →](https://docs.mailinfra.ru/sending/templates#управление-шаблонами) |
| Вебхуки | `…/projects/{id}/webhooks` | [Вебхуки →](https://docs.mailinfra.ru/webhooks/overview#управление-вебхуками) |
| Список подавления | `…/suppressions` | [Список подавления →](https://docs.mailinfra.ru/suppressions#api-списка-подавления) |
| Организации | `/v1/organizations` | Создание, просмотр и изменение организации |
| Проекты | `…/projects` | Создание, просмотр, настройка и архивирование проектов |
| Команда | `…/members`, `…/invitations` | Участники, роли и приглашения |
| Использование тарифа | `…/usage` | Текущий месячный объём и лимит |
| Статистика | `…/stats` | Метрики организации и проекта |
| Аудит | `…/audit-logs` | История действий и CSV-экспорт |
| Письма в дашборде | `…/projects/{id}/emails` | Просмотр и CSV-экспорт |
| Повторная отправка | `…/emails/{email_id}/resend` | Создание нового письма из сохранённого; недоступно для одноразового LIVE sandbox trial |
> **Область стабильного контракта**
>
> Пути, параметры и ответы, описанные на отдельных страницах этой документации,
> считаются публичным контрактом. Остальные dashboard endpoints перечислены здесь для
> ориентации, но перед автоматизацией критичного процесса сверяйте их поддержку с
> MailInfra. Не используйте operator/admin namespace `/v1/admin/*` — он не является
> клиентским Management API.
## Настройки проекта
Настройки проекта обновляются через `PATCH /v1/organizations/{organization_id}/projects/{project_id}`. Переданные ключи мержатся с существующими настройками.
```http
PATCH /v1/organizations/{organization_id}/projects/{project_id}
Authorization: Bearer
Content-Type: application/json
```
```json
{
"settings": {
"click_tracking": true
}
}
```
`click_tracking` включает [отслеживание кликов](https://docs.mailinfra.ru/sending/click-tracking) для новых live-писем проекта. По умолчанию значение `false`.
## Ошибки
Формат и коды совпадают с Public API — см. [Ошибки и лимиты](https://docs.mailinfra.ru/errors). Дополнительно к общим:
| HTTP | Код | Когда |
|------|-----|-------|
| `401` | `unauthorized` | Сессионный токен отсутствует, протух или невалиден |
| `403` | `forbidden` | У пользователя нет нужной роли в организации |
| `404` | `not_found` | Ресурс не найден или не принадлежит этой организации/проекту |
## MailInfra в ИИ-ассистентах
Источник: https://docs.mailinfra.ru/ai-agents
Если вы пишете интеграцию с помощью ИИ-ассистента, дайте ему нашу документацию —
модель перестанет угадывать названия полей и придумывать несуществующие эндпоинты.
Документация опубликована в машиночитаемом виде по стандарту
[llms.txt](https://llmstxt.org):
| Файл | Что внутри | Когда использовать |
|------|-----------|--------------------|
| [`/llms.txt`](https://docs.mailinfra.ru/llms.txt) | Оглавление: ссылки на все страницы с описаниями, базовый URL, схема аутентификации | Ассистент умеет ходить в интернет и подтянет нужную страницу сам |
| [`/llms-full.txt`](https://docs.mailinfra.ru/llms-full.txt) | Вся документация одним файлом, ~115 КБ | Ассистент без доступа в интернет — приложите файл к диалогу |
Оба файла пересобираются при каждом обновлении документации, поэтому не отстают
от сайта.
## Быстрый способ
Вставьте в диалог с ассистентом:
```text
Изучи документацию MailInfra: https://docs.mailinfra.ru/llms-full.txt
Дальше отвечай строго по ней, не выдумывай параметры.
```
Если ассистент не умеет открывать ссылки — скачайте файл и приложите его:
```bash
curl -O https://docs.mailinfra.ru/llms-full.txt
```
## Настройка по клиентам
**Cursor**
Настройки → Features → Docs → **Add new doc**, URL `https://docs.mailinfra.ru`.
После индексации обращайтесь к документации через `@Docs` в чате.
Чтобы правила действовали на весь проект, положите файл `.cursor/rules/mailinfra.md`:
```markdown
Проект отправляет письма через MailInfra (https://docs.mailinfra.ru/llms.txt).
Базовый URL — https://api.mailinfra.ru/v1, аутентификация: Authorization: Bearer mi_live_… .
Всегда передавай заголовок Idempotency-Key в POST /v1/emails.
Ключ бери из переменной окружения MAILINFRA_API_KEY, никогда не вставляй в код.
```
**Claude Code**
Добавьте ссылку в `CLAUDE.md` в корне проекта — файл читается автоматически в
начале каждой сессии:
```markdown
## Отправка писем
Используем MailInfra. Документация: https://docs.mailinfra.ru/llms-full.txt
Ключ — в переменной окружения MAILINFRA_API_KEY.
```
**GitHub Copilot / VS Code**
Создайте `.github/copilot-instructions.md` с тем же содержимым — Copilot
подхватывает его как контекст репозитория.
**ChatGPT, YandexGPT, GigaChat и прочие чаты**
Приложите `llms-full.txt` к диалогу файлом. Это надёжнее ссылки: не зависит от
того, включён ли у модели веб-доступ.
## Что стоит сказать ассистенту
Модели чаще всего ошибаются в четырёх местах. Скопируйте этот блок в контекст —
он снимает почти все типовые ошибки в сгенерированном коде:
```text
MailInfra — транзакционный email API.
1. Две разные схемы аутентификации:
- Отправка и чтение писем (/v1/emails*) — API-ключ проекта:
Authorization: Bearer mi_live_… (или mi_test_… для sandbox).
- Всё остальное (домены, ключи, шаблоны, вебхуки, подавления) —
Management API на сессионном токене пользователя. API-ключ там НЕ работает.
2. Требования к полю "from" зависят от окружения ключа:
- mi_live_… (LIVE) — адрес обязан быть на верифицированном домене,
иначе 422 domain_not_verified.
- mi_test_… (TEST) — любой адрес принимается как есть,
верифицировать домен не нужно.
3. POST /v1/emails на новый запрос отвечает 202 Accepted. Повтор с тем же
Idempotency-Key — это 200 OK, заголовок Idempotent-Replayed: true и то же
тело, что и в первый раз. 200 здесь тоже успех: не пиши клиент, который
принимает только 202.
Статус в теле ответа зависит от окружения ключа:
- LIVE → "QUEUED". Это значит «принято в очередь», а НЕ «доставлено».
Итоговый статус приходит вебхуком или через GET /v1/emails/{id}.
- TEST → "SIMULATED". Письмо сохранено, но никому не отправляется.
Вебхук события simulated при этом приходит, так что интеграцию
можно проверить end-to-end. TEST не применяет список подавления
и не расходует месячную квоту.
Не пиши код, который ждёт "QUEUED" в ответ на запрос с TEST-ключом.
4. Тело письма задаётся либо html/text, либо template + variables.
Одновременно html и template не передаются.
Всегда передавай Idempotency-Key в POST /v1/emails — при ретрае
без него клиент получит дубль письма.
```
## Безопасность
> **Не отдавайте ассистенту боевой ключ**
>
> `mi_live_…` даёт право реальной отправки от имени вашего домена. Агент,
> запущенный в цикле, способен израсходовать месячную квоту или разослать
> писем на реальных получателей.
Разумный порядок работы:
- Для отладки с ассистентом заведите отдельный ключ `mi_test_…` — тот же
эндпоинт и та же схема ответа, но письма только сохраняются со статусом
`SIMULATED` и никому не доставляются.
- Ключ держите в переменной окружения, а не в тексте диалога: всё, что вы
вставили в чат, уходит провайдеру модели.
- Права ключа режьте под задачу: `SENDING_ONLY` для сервиса отправки,
`READ_ONLY` для аналитики. Подробности — в [API-ключах](https://docs.mailinfra.ru/api-keys).
- Боевой ключ выпускайте, когда интеграция уже работает на `mi_test_…`.
## Индексация ИИ-краулерами
Документация открыта для индексации целиком, включая краулеры OpenAI,
Anthropic, Perplexity, Google и Яндекса — см.
[robots.txt](https://docs.mailinfra.ru/robots.txt). Отдельная регистрация или
плагин для этого не нужны: ассистент с веб-доступом найдёт страницы сам.
---
# Отправка писем
## Одно письмо
Источник: https://docs.mailinfra.ru/sending/single-email
# Отправка письма
```http
POST /v1/emails
Authorization: Bearer mi_live_…
Content-Type: application/json
```
Возвращает `202 Accepted` и присвоенный `id` ещё до фактической доставки. Итоговый статус приходит через [вебхуки](https://docs.mailinfra.ru/webhooks/overview) или его можно [запросить по `id`](#чтение-письма-и-событий).
## Минимальный пример
**cURL**
```bash
curl -X POST https://api.mailinfra.ru/v1/emails \
-H "Authorization: Bearer mi_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"from": "hello@ваш-домен.ru",
"to": ["user@example.com"],
"subject": "Добро пожаловать!",
"html": "Привет!
",
"text": "Привет!"
}'
```
**Python**
```python
import httpx
r = httpx.post(
"https://api.mailinfra.ru/v1/emails",
headers={"Authorization": "Bearer mi_live_xxxxxxxx"},
json={
"from": "hello@ваш-домен.ru",
"to": ["user@example.com"],
"subject": "Добро пожаловать!",
"html": "Привет!
",
"text": "Привет!",
},
)
r.raise_for_status()
print(r.json()["data"]["id"])
```
**TypeScript**
```typescript
const res = await fetch("https://api.mailinfra.ru/v1/emails", {
method: "POST",
headers: {
Authorization: "Bearer mi_live_xxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "hello@ваш-домен.ru",
to: ["user@example.com"],
subject: "Добро пожаловать!",
html: "Привет!
",
text: "Привет!",
}),
});
const { data } = await res.json();
console.log(data.id);
```
**Go**
```go
body := strings.NewReader(`{
"from": "hello@ваш-домен.ru",
"to": ["user@example.com"],
"subject": "Добро пожаловать!",
"html": "Привет!
"
}`)
req, _ := http.NewRequest("POST", "https://api.mailinfra.ru/v1/emails", body)
req.Header.Set("Authorization", "Bearer mi_live_xxxxxxxx")
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
```
Ответ:
```json
{
"data": {
"id": "018e1b7a-1111-7000-aaaa-111111111111",
"status": "QUEUED",
"skipped_recipients": []
}
}
```
## Параметры запроса
| Поле | Тип | Обязательно | Описание |
|------|-----|:-----------:|----------|
| `from` | `string` | да | Email отправителя. Домен должен быть [верифицирован и привязан к проекту](https://docs.mailinfra.ru/domains/dns-setup). |
| `from_name` | `string` | — | Отображаемое имя (до 200 символов). В клиенте: `Имя `. |
| `to` | `string[]` | да | Получатели. Сумма `to + cc + bcc` ≤ 50. |
| `cc` | `string[]` | — | CC. |
| `bcc` | `string[]` | — | BCC. |
| `reply_to` | `string[]` | — | До 10 адресов. |
| `subject` | `string` | * | До 998 символов, без переводов строк. |
| `html` | `string` | * | HTML-тело письма. |
| `text` | `string` | * | Текстовая версия. Рекомендуется указывать вместе с `html`. |
| `headers` | `object` | — | Пользовательские заголовки (см. [ниже](#пользовательские-заголовки)). |
| `tags` | `string[]` | — | До 10 меток, каждая до 32 символов. |
| `template` | `string` | * | Slug [шаблона](https://docs.mailinfra.ru/sending/templates). |
| `variables` | `object` | — | Переменные для рендера шаблона. |
| `attachments` | `object[]` | — | До 10 вложений в base64 (см. [ниже](#вложения)). |
\* Без `template` обязательны `subject` и хотя бы одно из `html` / `text`. С `template` тема и тело берутся из шаблона.
## Пользовательские заголовки
```json
{
"headers": {
"X-Order-Id": "12345",
"X-User-Id": "usr_abc"
}
}
```
Нельзя переопределить системные заголовки: `from`, `to`, `cc`, `bcc`,
`reply-to`, `subject`, `message-id`, `date`, `dkim-signature`, `received`,
`return-path` и любые `x-mailinfra-*`.
Такой запрос отклоняется целиком с `422 reserved_email_header`; заголовок не
игнорируется молча:
```json
{
"error": {
"code": "reserved_email_header",
"message": "Системные заголовки письма нельзя переопределять.",
"details": {
"code": "reserved_email_header",
"header": "From"
}
},
"request_id": "…"
}
```
Значения пользовательских заголовков не могут содержать переводы строк (`\r` или
`\n`). Это защищает письмо от header injection.
## Вложения
Содержимое файла передаётся обычной base64-строкой без префикса `data:`:
```json
{
"from": "hello@ваш-домен.ru",
"to": ["user@example.com"],
"subject": "Документы по заказу",
"text": "Документы во вложении.",
"attachments": [
{
"filename": "invoice.pdf",
"content_type": "application/pdf",
"content_base64": "JVBERi0xLjQK…"
}
]
}
```
| Поле | Обязательно | Описание |
|------|:-----------:|----------|
| `filename` | да | Отображаемое имя файла, до 255 символов. Пути удаляются. |
| `content_type` | — | MIME-тип; по умолчанию `application/octet-stream`. |
| `content_base64` | да | Байты файла в base64. Переносы MIME-base64 допустимы. |
Ограничения: до 10 вложений, до 10 МиБ на файл и до 25 МиБ суммарно. Размер
считается после декодирования base64. Невалидный base64 и превышение лимита
отклоняют письмо до его создания.
## Идемпотентность
```http
POST /v1/emails
Idempotency-Key: order-12345-welcome
```
Повтор того же запроса с тем же ключом:
- `200 OK` (вместо `202`)
- заголовок `Idempotent-Replayed: true`
- тело идентично первому ответу, повторной отправки не происходит
Используйте, если вызываете API по сетевому таймауту — это безопаснее `try/except` с retry.
## Пропущенные получатели
Адреса из [списка подавления](https://docs.mailinfra.ru/suppressions) тихо исключаются:
```json
{
"data": {
"id": "...",
"status": "QUEUED",
"skipped_recipients": [
{ "email": "blocked@example.com", "reason": "suppressed" }
]
}
}
```
Если **все** получатели подавлены — письмо не создаётся, возвращается `422` с кодом `all_recipients_suppressed`.
## Статусы письма
```
QUEUED → SENDING → SENT → DELIVERED
↘ BOUNCED
↘ COMPLAINED
↘ FAILED
```
| Статус | Значение |
|--------|----------|
| `QUEUED` | Принято в очередь |
| `SENDING` | Передаётся в MTA |
| `SENT` | Передано SMTP-серверу получателя |
| `DELIVERED` | Получено получателем |
| `BOUNCED` | Недоставка |
| `COMPLAINED` | Жалоба на спам |
| `FAILED` | Внутренняя ошибка отправки |
| `SIMULATED` | TEST-ключ, реальной отправки не было |
## Click tracking
Если в проекте включён [Click tracking](https://docs.mailinfra.ru/sending/click-tracking), MailInfra перепишет абсолютные HTTP- и HTTPS-ссылки в HTML-теле письма и создаст событие `clicked` при переходе получателя.
## Чтение письма и событий
```http
GET /v1/emails
GET /v1/emails/{id}
GET /v1/emails/{id}/events
```
Доступно ключам с правом чтения (`FULL_ACCESS`, `READ_ONLY`). Реал-тайм без поллинга — через [вебхуки](https://docs.mailinfra.ru/webhooks/overview).
## Лимиты
Все лимиты — на странице [Ошибки и лимиты →](https://docs.mailinfra.ru/errors).
## Пакетная отправка
Источник: https://docs.mailinfra.ru/sending/batch
```http
POST /v1/emails/batch
Authorization: Bearer mi_live_…
Content-Type: application/json
```
До **100 писем** за один HTTP-запрос. Параметры каждого письма — те же, что в [`POST /v1/emails`](https://docs.mailinfra.ru/sending/single-email#параметры-запроса). Письма обрабатываются независимо.
## Пример
**cURL**
```bash
curl -X POST https://api.mailinfra.ru/v1/emails/batch \
-H "Authorization: Bearer mi_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"emails": [
{
"from": "no-reply@ваш-домен.ru",
"to": ["ivan@example.com"],
"template": "welcome",
"variables": { "user_name": "Иван" }
},
{
"from": "no-reply@ваш-домен.ru",
"to": ["maria@example.com"],
"template": "welcome",
"variables": { "user_name": "Мария" }
}
]
}'
```
**Python**
```python
import httpx
users = [
{"email": "ivan@example.com", "name": "Иван"},
{"email": "maria@example.com", "name": "Мария"},
]
r = httpx.post(
"https://api.mailinfra.ru/v1/emails/batch",
headers={"Authorization": "Bearer mi_live_xxxxxxxx"},
json={
"emails": [
{
"from": "no-reply@ваш-домен.ru",
"to": [u["email"]],
"template": "welcome",
"variables": {"user_name": u["name"]},
}
for u in users
]
},
)
r.raise_for_status()
for result in r.json()["data"]["results"]:
if result["error"]:
print(f"#{result['index']} rejected: {result['error']['code']}")
else:
print(f"#{result['index']} accepted: {result['id']} {result['status']}")
```
**TypeScript**
```typescript
const users = [
{ email: "ivan@example.com", name: "Иван" },
{ email: "maria@example.com", name: "Мария" },
];
const res = await fetch("https://api.mailinfra.ru/v1/emails/batch", {
method: "POST",
headers: {
Authorization: "Bearer mi_live_xxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
emails: users.map((u) => ({
from: "no-reply@ваш-домен.ru",
to: [u.email],
template: "welcome",
variables: { user_name: u.name },
})),
}),
});
const { data } = await res.json();
data.results.forEach((result) => {
if (result.error) {
console.error(`#${result.index} rejected`, result.error.code);
} else {
console.log(`#${result.index} accepted`, result.id, result.status);
}
});
```
## Ответ
```json
{
"data": {
"results": [
{
"index": 0,
"id": "018e1b7a-1111-7000-aaaa-000000000001",
"status": "QUEUED",
"skipped_recipients": [],
"error": null
},
{
"index": 1,
"id": null,
"status": null,
"skipped_recipients": [],
"error": {
"status_code": 404,
"code": "not_found",
"message": "Template 'missing-template' not found",
"details": {}
}
}
],
"accepted_count": 1,
"failed_count": 1
}
}
```
`202 Accepted` означает, что batch обработан, но не гарантирует успех каждого
письма. Всегда проверяйте `error` у каждого результата. Поле `index` соответствует
позиции письма во входном массиве; результаты возвращаются в том же порядке.
У успешного результата заполнены `id` и `status`, а `error` равен `null`. У
отклонённого результата `id` и `status` равны `null`, а `error` содержит HTTP-статус
и стандартную ошибку именно этого элемента.
## Особенности
- **Частичный успех**: ожидаемая ошибка одного письма (например, шаблон не найден)
не отменяет уже принятые письма и не останавливает следующие.
- Если невалидна сама структура batch (`emails` пуст, больше 100 элементов или поле
письма не прошло проверку схемы), весь запрос отклоняется с `422` до начала обработки.
- Неожиданная серверная ошибка возвращается как `5xx`. Поскольку batch неатомарный,
ранее обработанные элементы к этому моменту уже могут быть приняты.
- **`Idempotency-Key` не поддерживается** для batch. Если нужна идемпотентность — отправляйте по одному.
- Лимит — 100 писем на запрос. Это технический лимит на HTTP-payload, не маркетинговая рассылка.
## Шаблоны
Источник: https://docs.mailinfra.ru/sending/templates
# Шаблоны писем
Шаблон хранит тему и тело письма на сервере. В запросе отправки достаточно передать `slug` и переменные — рендеринг выполняется через **Jinja2 в безопасной песочнице** (без доступа к файловой системе и внешним I/O).
Шаблоны создаются и редактируются в дашборде: **Проект → Шаблоны**. У каждого шаблона есть уникальный в проекте `slug` и автоинкрементный `version` — он повышается при каждом обновлении.
## Отправка по шаблону
**cURL**
```bash
curl -X POST https://api.mailinfra.ru/v1/emails \
-H "Authorization: Bearer mi_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"from": "no-reply@ваш-домен.ru",
"to": ["user@example.com"],
"template": "welcome",
"variables": {
"user_name": "Анна",
"app_name": "MyApp"
}
}'
```
**Python**
```python
httpx.post(
"https://api.mailinfra.ru/v1/emails",
headers={"Authorization": "Bearer mi_live_xxxxxxxx"},
json={
"from": "no-reply@ваш-домен.ru",
"to": ["user@example.com"],
"template": "welcome",
"variables": {"user_name": "Анна", "app_name": "MyApp"},
},
)
```
**TypeScript**
```typescript
await fetch("https://api.mailinfra.ru/v1/emails", {
method: "POST",
headers: {
Authorization: "Bearer mi_live_xxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "no-reply@ваш-домен.ru",
to: ["user@example.com"],
template: "welcome",
variables: { user_name: "Анна", app_name: "MyApp" },
}),
});
```
При использовании `template` поля `subject`, `html`, `text` берутся из шаблона — их можно не передавать. Можно при желании переопределить любое из них прямо в запросе.
## Slug
- только строчные буквы, цифры, `-` и `_`
- начинается с буквы или цифры
- до 100 символов
- уникален в пределах проекта
Примеры: `welcome`, `password-reset`, `order_confirmation`, `email-verification`.
## Синтаксис Jinja2
### Подстановка
```jinja2
{{ variable_name }}
{{ order.total | round(2) }}
{{ code | upper }}
```
### Условия
```jinja2
{% if promo_code %}
Промокод: {{ promo_code }}
{% endif %}
```
### Циклы
```jinja2
{% for item in order_items %}
- {{ item.name }} — {{ item.qty }} шт. × {{ item.price }} ₽
{% endfor %}
```
### Экранирование HTML
В HTML-шаблонах переменные **автоматически экранируются** — это защита от XSS:
```jinja2
{# user_input = "" #}
{{ user_input }}
{# рендерится как: <script>alert(1)</script>
#}
```
Если значение действительно содержит доверенный HTML и нужно его вывести как есть — используйте фильтр `| safe`. Применяйте только к контенту, который контролируете сами.
```jinja2
{{ trusted_html | safe }}
```
## Готовые шаблоны
### Подтверждение email
```
subject: Подтвердите email — {{ app_name }}
html: Ваш код: {{ code }}. Действителен {{ expires_min }} мин.
text: Код: {{ code }}\nДействителен {{ expires_min }} мин.
```
### Сброс пароля
```
subject: Сброс пароля
html: Сбросить пароль
Ссылка живёт {{ expires_hours }} ч.
text: Сброс пароля: {{ reset_url }}\nСсылка живёт {{ expires_hours }} ч.
```
### Подтверждение заказа
```
subject: Заказ #{{ order_number }} подтверждён
html: Заказ #{{ order_number }}
{% for item in items %}
| {{ item.name }} | {{ item.qty }} шт. | {{ item.price }} ₽ |
{% endfor %}
Итого: {{ total }} ₽
```
## Возможные ошибки при отправке
| Ситуация | HTTP | Код |
|----------|------|-----|
| Slug не найден при отправке | `404` | `not_found` |
| Не передана переменная, использованная в шаблоне | `422` | `template_variable_missing` |
| Ошибка рендера существующего шаблона | `422` | `template_render_error` |
---
## Управление шаблонами
CRUD шаблонов — часть [Management API](https://docs.mailinfra.ru/management-api): операции идут на `/v1/organizations/{organization_id}/projects/{project_id}/templates` с сессионным токеном.
| Метод | Путь | Минимальная роль | Описание |
|-------|------|------------------|----------|
| `GET` | `/templates` | `VIEWER` | Список шаблонов проекта |
| `POST` | `/templates` | `DEVELOPER` | Создать шаблон |
| `GET` | `/templates/{id}` | `VIEWER` | Один шаблон со всеми полями |
| `PATCH` | `/templates/{id}` | `DEVELOPER` | Изменить шаблон, поднимает `version` |
| `DELETE` | `/templates/{id}` | `DEVELOPER` | Удалить шаблон |
### Создание
```http
POST /v1/organizations/{organization_id}/projects/{project_id}/templates
Authorization: Bearer
Content-Type: application/json
```
```json
{
"name": "Приветственное письмо",
"slug": "welcome",
"subject_template": "Добро пожаловать, {{ user_name }}!",
"html_template": "Привет, {{ user_name }}!
Аккаунт в {{ app_name }} создан.
",
"text_template": "Привет, {{ user_name }}!\n\nАккаунт в {{ app_name }} создан.",
"variables": {
"user_name": "Иван",
"app_name": "MyApp"
}
}
```
| Поле | Тип | Обязательно | Описание |
|------|-----|:-----------:|----------|
| `name` | `string` | да | Человекочитаемое имя (1–200 символов) |
| `slug` | `string` | да | См. [правила slug](#slug); уникален в проекте |
| `subject_template` | `string` | да | Тема, без переводов строк |
| `html_template` | `string` | * | HTML-тело |
| `text_template` | `string` | * | Текстовое тело |
| `variables` | `object` | — | Подсказки и примеры значений переменных, видны в дашборде |
\* Хотя бы одно из `html_template` / `text_template` должно быть указано.
> **Поле `variables` — это документация, не значения по умолчанию**
>
> Это пример того, какие переменные ждёт шаблон, и какие значения они принимают. При отправке передавайте все нужные переменные в теле запроса — содержимое `variables` шаблона **не подставляется** автоматически.
**Ответ `201 Created`:**
```json
{
"data": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"project_id": "...",
"name": "Приветственное письмо",
"slug": "welcome",
"subject_template": "Добро пожаловать, {{ user_name }}!",
"html_template": "...",
"text_template": "...",
"variables": { "user_name": "Иван", "app_name": "MyApp" },
"version": 1,
"created_at": "2026-05-12T09:00:00Z",
"updated_at": "2026-05-12T09:00:00Z"
}
}
```
### Обновление и версионирование
```http
PATCH /v1/.../templates/{template_id}
Authorization: Bearer
Content-Type: application/json
```
```json
{
"subject_template": "Привет, {{ user_name }} 👋",
"html_template": "Новая верстка
"
}
```
При каждом успешном `PATCH` поле `version` увеличивается на 1 — это нужно, чтобы вы могли видеть в логах писем, какой версией шаблона отрендерено каждое письмо. Slug изменить нельзя — для смены slug создайте новый шаблон.
### Ошибки управления
| Ситуация | HTTP | Код |
|----------|------|-----|
| Slug уже занят в проекте | `409` | `template_slug_taken` |
| Синтаксическая ошибка Jinja2 при создании/обновлении | `400` | `validation_error` |
| Шаблон не найден или не принадлежит проекту | `404` | `not_found` |
## Отправка по SMTP
Источник: https://docs.mailinfra.ru/sending/smtp
Если не хочется писать интеграцию с REST API — отправляйте письма **обычным
SMTP**. Подходит для WordPress, Tilda, 1С, Битрикс, CRM и любых библиотек с
настройками «SMTP host / port / user / password».
Письмо, принятое по SMTP, проходит **те же проверки, что и REST-отправка**
(верифицированный домен, suppression, лимиты тарифа) и точно так же видно
в логах, статистике и [вебхуках](https://docs.mailinfra.ru/webhooks/overview) — только с пометкой
источника `smtp`.
## Параметры подключения
| Параметр | Значение |
|----------|----------|
| Сервер | `smtp.mailinfra.ru` |
| Порт | `587` — STARTTLS, основной · `465` — SSL/TLS · `2525` — как 587, если хостинг блокирует 587 |
| Шифрование | обязательно (STARTTLS или SSL) |
| Логин | `apikey` — буквально эта строка |
| Пароль | ваш SMTP-ключ (`mi_live_…`) |
**Перед первой отправкой:**
1. [Верифицируйте домен](https://docs.mailinfra.ru/domains/overview) и привяжите его к проекту —
адрес в поле `From` должен быть на этом домене, иначе письмо отклоняется
кодом 550.
2. Создайте SMTP-ключ: дашборд → **Настройки → SMTP → Создать SMTP-доступ**.
Такой ключ умеет только отправлять по SMTP — если он утечёт из конфига
CMS, доступа к вашим письмам и настройкам не будет. Обычные
[API-ключи](https://docs.mailinfra.ru/api-keys) тоже принимаются как пароль.
Ключ `mi_test_…` работает в sandbox-режиме: письмо появится в дашборде со
статусом `simulated`, но реально не отправится — удобно для проверки
интеграции.
## Примеры
**WordPress**
Установите плагин **WP Mail SMTP** → Mailer «Other SMTP»:
| Поле | Значение |
|------|----------|
| SMTP Host | `smtp.mailinfra.ru` |
| Encryption | TLS |
| SMTP Port | 587 |
| Auto TLS | On |
| Authentication | On |
| SMTP Username | `apikey` |
| SMTP Password | ваш SMTP-ключ |
«From Email» — адрес на вашем верифицированном домене.
**Nodemailer**
```js
const nodemailer = require("nodemailer");
const transporter = nodemailer.createTransport({
host: "smtp.mailinfra.ru",
port: 587,
secure: false, // STARTTLS включится автоматически
auth: { user: "apikey", pass: "mi_live_xxxxxxxx" },
});
await transporter.sendMail({
from: "Магазин ",
to: "client@example.com",
subject: "Заказ подтверждён",
html: "Спасибо за заказ!",
});
```
**Python**
```python
import smtplib
from email.message import EmailMessage
msg = EmailMessage()
msg["From"] = "hello@ваш-домен.ru"
msg["To"] = "client@example.com"
msg["Subject"] = "Заказ подтверждён"
msg.set_content("Спасибо за заказ!")
with smtplib.SMTP("smtp.mailinfra.ru", 587) as smtp:
smtp.starttls()
smtp.login("apikey", "mi_live_xxxxxxxx")
smtp.send_message(msg)
```
**PHP (PHPMailer)**
```php
use PHPMailer\PHPMailer\PHPMailer;
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = 'smtp.mailinfra.ru';
$mail->Port = 587;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->SMTPAuth = true;
$mail->Username = 'apikey';
$mail->Password = 'mi_live_xxxxxxxx';
$mail->setFrom('hello@ваш-домен.ru', 'Магазин');
$mail->addAddress('client@example.com');
$mail->Subject = 'Заказ подтверждён';
$mail->msgHTML('Спасибо за заказ!');
$mail->send();
```
## Как обрабатывается письмо
- **Получатели** берутся из SMTP-конверта (`RCPT TO`). Получатель, не
указанный в заголовках To/Cc, считается скрытым (Bcc): письмо ему
доставляется, а заголовок `Bcc` в исходящее письмо не попадает.
- **Успешный ответ** — `250 OK queued as `, где `id` — идентификатор
письма для поиска в логах и API. Ответ приходит только после того, как
письмо прошло все проверки и поставлено в очередь; любой другой код
означает, что письмо **не принято**.
- **Message-ID** заменяется на платформенный (по нему трекаются
bounce/жалобы). Для тредов используйте `In-Reply-To` / `References` —
они проходят без изменений, как и ваши `X-*` заголовки.
- **Теги**: заголовок `X-MailInfra-Tag: имя-тега` (до 10 штук) — аналог
`tags` в REST-запросе, работает в фильтрах логов и статистики.
- **Return-Path** выставляет платформа: отбойники приходят в нашу обработку
и попадают в вашу статистику и suppression-список автоматически.
## Лимиты
| Лимит | Значение |
|-------|----------|
| Размер письма | 25 МБ |
| Получателей на письмо | 50 |
| Писем на одно соединение | 100, затем переподключитесь |
| Одновременных соединений с одного IP | 10 |
| Простой соединения | 60 секунд |
| Скорость и месячный объём | по тарифу, общие с REST API |
Не поддерживается в текущей версии: интернационализированные адреса
(SMTPUTF8), рассылки без получателей в заголовке `To`
(undisclosed-recipients), механизмы AUTH кроме `PLAIN`/`LOGIN`.
## Коды ответов
| Код | Что значит | Что делать |
|-----|------------|------------|
| `250` | Письмо принято и поставлено в очередь. | Ничего — дальше следите по логам/вебхукам. |
| `530 5.7.0` | Нет TLS или не выполнен вход. | Включите шифрование и авторизацию в настройках. |
| `535 5.7.8` | Неверный логин или ключ. | Логин — строго `apikey`; проверьте ключ, он мог быть отозван. |
| `538 5.7.11` | Попытка входа без шифрования. | Включите STARTTLS (587/2525) или используйте порт 465. |
| `550 5.7.1` | Домен `From` не верифицирован / у ключа нет прав. | Верифицируйте домен и привяжите к проекту; создайте SMTP-ключ. |
| `550 5.1.1` | Получатель в списке подавления. | Адрес отписался или давал жёсткий возврат — см. [список подавления](https://docs.mailinfra.ru/suppressions). |
| `550 5.6.0` | Некорректное письмо (нет `From`, битый формат). | Проверьте формирование письма в вашей библиотеке. |
| `552 5.3.4` | Письмо больше 25 МБ. | Уменьшите вложения. |
| `421 4.7.0` | Слишком быстро: превышен RPS или лимиты соединений. | Притормозите, переподключитесь позже. |
| `452 4.3.1` | Исчерпана месячная квота тарифа. | Дождитесь нового месяца или повысьте тариф. |
| `452 4.5.3` | Больше 50 получателей в письме. | Разбейте отправку на части. |
| `451 4.3.0` | Временная ошибка на нашей стороне. | Повторите позже — библиотеки обычно делают это сами. |
Коды 4xx — временные («повтори позже»), 5xx — постоянные («не повторяй,
исправь причину»).
## Click tracking
Источник: https://docs.mailinfra.ru/sending/click-tracking
Click tracking фиксирует переходы по ссылкам в HTML-письмах. Когда настройка включена для проекта, MailInfra заменяет подходящие `href` на короткие ссылки вида:
```text
https://track.mailinfra.ru/c/
```
При переходе получатель сразу перенаправляется на исходный URL, а в событиях письма появляется `clicked`.
## Включение
В дашборде: **Настройки → Проект → Click tracking**.
Через Management API:
```http
PATCH /v1/organizations/{organization_id}/projects/{project_id}
Authorization: Bearer
Content-Type: application/json
```
```json
{
"settings": {
"click_tracking": true
}
}
```
Чтобы выключить трекинг, передайте `false`.
## Какие ссылки отслеживаются
MailInfra переписывает только HTML-ссылки `` с абсолютным `http` или `https` URL.
Не переписываются:
- `mailto:`, относительные ссылки и другие схемы;
- ссылки на сам tracking-домен;
- ссылки с `rel="unsubscribe"`;
- ссылки с атрибутом `data-mailinfra-no-track`.
Пример исключения:
```html
Личный кабинет
```
## События и вебхуки
Клик создаёт событие `clicked` у письма. В `data.event` приходят:
```json
{
"url": "https://example.com/order/123",
"ip": "203.0.113.42",
"user_agent": "Mozilla/5.0 ..."
}
```
Чтобы получать клики по HTTP, подпишите вебхук проекта на `clicked`.
## Аналитика в дашборде
В разделе **Метрики → Переходы по ссылкам** выбранный период фильтрует переходы
по времени самого клика. Дашборд показывает:
- общее число переходов, число писем с кликом и количество разных URL;
- самые популярные ссылки с числом переходов и писем;
- время последнего клика по каждой ссылке;
- последние переходы с IP-адресом и типом браузера или устройства.
Показатель **Клики** в верхней части страницы считается по cohort отправок: это
доля доставленных писем, созданных в выбранном периоде, по которым был хотя бы
один переход. Поэтому numerator и denominator относятся к одному набору писем.
Повторные клики учитываются в детализации URL, но не увеличивают число писем с
кликом.
## Повторная отправка
При resend MailInfra разворачивает старые tracking-ссылки и создаёт новые токены для нового письма. Клик по повторно отправленному письму привязывается к новому `email_id`.
## Ограничения
Click tracking работает только для HTML-тела письма. Текстовая версия (`text`) не переписывается.
Для тестовых API-ключей письмо сохраняется как `SIMULATED`, реальная отправка не выполняется и ссылки не переписываются.
---
# Домены
## Обзор доменов
Источник: https://docs.mailinfra.ru/domains/overview
# Домены
Чтобы отправлять письма с адреса `hello@example.ru`, нужно один раз верифицировать домен `example.ru` и привязать его к проекту. Без этого `mi_live_…` ключ вернёт `422 domain_not_verified`.
## Зачем
Принимающие почтовые серверы (Gmail, Mail.ru, Яндекс и др.) проверяют:
- **SPF** — что MailInfra авторизован отправлять от имени вашего домена;
- **DKIM** — криптоподпись письма не подделана и совпадает с публичным ключом в DNS;
- **DMARC** — политика обработки писем, не прошедших SPF/DKIM.
Если этих записей нет — письма попадают в спам или отвергаются.
## Модель
```
Organization
└── Domain ← верифицируется один раз через DNS
└── ProjectDomain ← привязка к проекту
└── Project ← API-ключ работает в этом проекте
```
Один верифицированный домен можно привязать к нескольким проектам одной организации — DNS настраивается один раз.
## Статусы
| Статус | Что означает |
|--------|--------------|
| `PENDING` | Домен создан, DNS-записи ещё не выставлены или не распространились. Если verify запущен, но записи ещё не проходят, новый домен остаётся в `PENDING`. |
| `VERIFIED` | SPF + DKIM + DMARC прошли проверку |
| `FAILED` | Legacy/ошибочное состояние. В обычном verify-флоу новый домен не переводится в `FAILED`. |
| `DNS_ERROR` | Ранее был верифицирован; записи перестали проходить проверку (фоновая пере-проверка или ручной verify) |
## Поддомены
Если отправляете с поддомена — добавляйте именно его, не корневой домен:
| Адрес `from` | Что добавить |
|--------------|--------------|
| `hello@example.ru` | `example.ru` |
| `no-reply@mail.example.ru` | `mail.example.ru` |
| `alerts@team.corp.ru` | `team.corp.ru` |
## Что дальше
[Настройка DNS →](https://docs.mailinfra.ru/domains/dns-setup) — пошаговый гайд по SPF/DKIM/DMARC для популярных провайдеров.
---
## API доменов
Управление доменами — часть [Management API](https://docs.mailinfra.ru/management-api). Все запросы используют сессионный токен.
### Домены организации
Базовый префикс: `/v1/organizations/{organization_id}/domains`.
| Метод | Путь | Минимальная роль | Что делает |
|-------|------|------------------|------------|
| `GET` | `/domains` | `VIEWER` | Список всех доменов организации |
| `POST` | `/domains` | `ADMIN` | Создать домен; в ответе — DNS-записи для выставления |
| `GET` | `/domains/{id}` | `VIEWER` | Детали домена + актуальные DNS-записи |
| `POST` | `/domains/{id}/verify` | `ADMIN` | Запустить верификацию (проверка SPF/DKIM/DMARC) |
| `DELETE` | `/domains/{id}` | `ADMIN` | Удалить домен (отвяжется от всех проектов) |
#### Создание домена
```http
POST /v1/organizations/{organization_id}/domains
Authorization: Bearer
Content-Type: application/json
{ "name": "mail.example.ru" }
```
В ответе вместе с самим доменом приходит массив `verification_records` — три TXT-записи (SPF, DKIM, DMARC), которые нужно выставить у DNS-провайдера. Сами записи генерируются уникально для каждого домена; копируйте их именно из этого ответа.
#### Запуск верификации
```http
POST /v1/organizations/{organization_id}/domains/{domain_id}/verify
Authorization: Bearer
```
Идемпотентна: можно вызывать сколько угодно раз, пока DNS распространяется. Если записи ещё не проходят, новый домен остаётся в `PENDING`; ранее верифицированный домен при потере DNS переходит в `DNS_ERROR`. Ответ показывает результат каждой из трёх проверок:
```json
{
"data": {
"id": "3fa85f64-...",
"status": "VERIFIED",
"checks": {
"spf": {
"valid": true,
"found": "v=spf1 include:_spf.mailinfra.ru ~all",
"error": null,
"hint": null,
"extra": {},
"transient": false
},
"dkim": {
"valid": true,
"found": "v=DKIM1; k=rsa; p=MIIBIjAN...",
"error": null,
"hint": null,
"extra": {},
"transient": false
},
"dmarc": {
"valid": true,
"found": "v=DMARC1; p=none; rua=mailto:dmarc@mailinfra.ru",
"error": null,
"hint": null,
"extra": {},
"transient": false
}
}
}
}
```
### Привязка к проекту
Базовый префикс: `/v1/organizations/{organization_id}/projects/{project_id}/domains`.
| Метод | Путь | Минимальная роль | Что делает |
|-------|------|------------------|------------|
| `GET` | `/domains` | `VIEWER` | Список доменов, привязанных к проекту |
| `POST` | `/domains` | `ADMIN` | Привязать верифицированный домен к проекту |
| `DELETE` | `/domains/{id}` | `ADMIN` | Отвязать домен от проекта |
#### Привязать
```http
POST /v1/organizations/{organization_id}/projects/{project_id}/domains
Authorization: Bearer
Content-Type: application/json
{ "domain_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }
```
Привязать можно только домен в статусе `VERIFIED`. После привязки ключи проекта могут отправлять с адресов этого домена. Один домен — в нескольких проектах одновременно: DNS настраивается один раз.
## Настройка DNS
Источник: https://docs.mailinfra.ru/domains/dns-setup
От пустого домена до письма со статусом `DELIVERED`. Все шаги — в дашборде на странице **Организация → Домены**.
## 1. Добавить домен
**Дашборд**
**Организация → Домены → Добавить домен**, введите имя (`example.ru` или `mail.example.ru`) и нажмите **Добавить**. Страница покажет три DNS-записи, сгенерированные специально для вашего домена.
**API**
```http
POST /v1/organizations/{organization_id}/domains
Authorization: Bearer
Content-Type: application/json
{ "name": "mail.example.ru" }
```
В ответе — поле `verification_records` со SPF, DKIM и DMARC. См. [API доменов →](https://docs.mailinfra.ru/domains/overview#api-доменов).
> **Не копируйте значения из этой документации**
>
> DKIM-ключ уникален для каждого домена и проекта. Используйте те значения, которые показал дашборд.
Пример того, как они выглядят:
| Тип | Имя | Значение | Назначение |
|-----|-----|----------|------------|
| `TXT` | `mail.example.ru` | `v=spf1 include:_spf.mailinfra.ru ~all` | SPF |
| `TXT` | `mail._domainkey.mail.example.ru` | `v=DKIM1; k=rsa; p=MIIBIjANBgkq...` | DKIM (публичный ключ) |
| `TXT` | `_dmarc.mail.example.ru` | `v=DMARC1; p=none; rua=mailto:dmarc@mailinfra.ru` | DMARC-политика |
## 2. Выставить записи у DNS-провайдера
**Cloudflare**
1. **DNS → Records → Add record** — три раза подряд для SPF, DKIM, DMARC.
2. Тип `TXT`, имя и значение — как показал дашборд.
3. **Отключите Proxy** (оранжевая тучка → серая) для всех трёх записей. Cloudflare proxy ломает TXT-проверки.
**Reg.ru / Beget / TimeWeb**
В разделе DNS-записи добавьте три записи типа `TXT`. В поле «поддомен/хост» укажите имя без основной части домена. Например, для `mail._domainkey.mail.example.ru` хост будет `mail._domainkey.mail`.
**Яндекс 360**
**Домены → Ваш домен → DNS-записи → Добавить запись**. Для каждой из трёх записей выберите `TXT` и подставьте имя и значение из дашборда.
### Проверка локально
```bash
dig TXT mail.example.ru +short
dig TXT mail._domainkey.mail.example.ru +short
dig TXT _dmarc.mail.example.ru +short
```
Или [dnschecker.org](https://dnschecker.org) — увидит запись из разных точек мира.
> **Время распространения**
>
> DNS-изменения распространяются от нескольких минут до 24 часов в зависимости от TTL и провайдера. Обычно 15–60 минут.
## 3. Запустить верификацию
**Дашборд**
Нажмите **Верифицировать** напротив домена.
**API**
```http
POST /v1/organizations/{organization_id}/domains/{domain_id}/verify
Authorization: Bearer
```
Идемпотентно — можно повторять, пока DNS распространяется.
MailInfra проверит все три записи и переведёт домен в `VERIFIED`, иначе оставит в `PENDING` до успеха; после успешной верификации при потере записей возможен статус `DNS_ERROR` (детали по каждой записи в ответе verify):
```json
{
"checks": {
"spf": {
"valid": true,
"found": "v=spf1 include:_spf.mailinfra.ru ~all",
"error": null,
"hint": null,
"extra": {},
"transient": false
},
"dkim": {
"valid": true,
"found": "v=DKIM1; k=rsa; p=MIIBIjAN...",
"error": null,
"hint": null,
"extra": {},
"transient": false
},
"dmarc": {
"valid": false,
"found": null,
"error": "DMARC TXT record not found",
"hint": "Добавьте TXT-запись _dmarc. со значением v=DMARC1; p=none",
"extra": {},
"transient": false
}
}
}
```
Исправьте проблемные записи и нажмите «Верифицировать» ещё раз — это идемпотентно.
## 4. Привязать к проекту
**Дашборд**
**Проект → Домены → Привязать домен** и выберите верифицированный домен.
**API**
```http
POST /v1/organizations/{organization_id}/projects/{project_id}/domains
Authorization: Bearer
Content-Type: application/json
{ "domain_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }
```
После привязки ключи проекта могут отправлять письма с адресов этого домена.
## SPF: что делать, если домен уже используется
Если на домене уже есть SPF (Google Workspace, Microsoft 365, ваш SMTP-релей) — нельзя добавлять вторую запись. Это сломает доставку. Объедините `include` в одной строке:
```
# Было:
v=spf1 include:_spf.google.com ~all
# Стало:
v=spf1 include:_spf.mailinfra.ru include:_spf.google.com ~all
```
## Частые проблемы
| Проблема | Что проверить |
|----------|---------------|
| `checks.spf.valid: false` | SPF-запись одна, в ней есть `include:_spf.mailinfra.ru` |
| `checks.dkim.valid: false` | Имя записи начинается с `mail._domainkey.`, значение скопировано целиком |
| `checks.dmarc.valid: false` | Есть TXT-запись `_dmarc.<домен>` со значением как минимум `v=DMARC1; p=none` |
| Домен `VERIFIED`, но письмо не уходит | Домен **привязан к проекту** этого ключа? |
| Запись «не видна» сразу | Подождите TTL (15–60 мин), проверьте через `dig` |
---
# Вебхуки
## Вебхуки
Источник: https://docs.mailinfra.ru/webhooks/overview
При каждом событии жизненного цикла письма MailInfra шлёт HTTP `POST` на URL, указанный в дашборде. Это альтернатива поллингу `GET /v1/emails/{id}/events`.
Создание, редактирование, ротация секрета и тестовая отправка — в дашборде: **Проект → Вебхуки**.
## События
| Тип | Когда срабатывает |
|-----|-------------------|
| `queued` | Письмо принято в очередь |
| `sending` | Передача в MTA началась |
| `sent` | Передано SMTP-серверу получателя |
| `delivered` | Письмо доставлено |
| `clicked` | Клик по отслеживаемой ссылке, если в проекте включён [Click tracking](https://docs.mailinfra.ru/sending/click-tracking) |
| `bounced` | Недоставка; адрес попадает в [список подавления](https://docs.mailinfra.ru/suppressions) |
| `complained` | Жалоба на спам; адрес попадает в [список подавления](https://docs.mailinfra.ru/suppressions) |
| `failed` | Внутренняя ошибка отправки |
| `received` | Входящее письмо (источник готовится) |
| `campaign.*` | Жизненный цикл кампании (источник готовится) |
В дашборде можно подписаться на все события или на конкретное подмножество.
## Формат входящего запроса
### Заголовки
| Заголовок | Значение |
|-----------|----------|
| `Content-Type` | `application/json` |
| `User-Agent` | `MailInfra-Webhook/1.0` |
| `X-MailInfra-Event-Id` | UUID события — для дедупликации между retry |
| `X-MailInfra-Signature` | HMAC-SHA256 — обязательно [проверяйте](https://docs.mailinfra.ru/webhooks/signature) |
### Тело
```json
{
"id": "evt_018e1b7a-2c3d-7000-8d4e-5f6a7b8c9d0e",
"type": "email.delivered",
"created_at": "2026-05-11T20:15:30.123456Z",
"data": {
"email": {
"id": "018e1b7a-1111-7000-aaaa-111111111111",
"from": "hello@mail.example.ru",
"to": ["user@gmail.com"],
"subject": "Добро пожаловать!",
"tags": ["welcome"],
"status": "DELIVERED"
},
"event": {}
}
}
```
`type` всегда в формате **`email.<тип>`**.
Для `email.clicked` поле `data.event` содержит исходный URL, IP и User-Agent:
```json
{
"url": "https://example.com/order/123",
"ip": "203.0.113.42",
"user_agent": "Mozilla/5.0 ..."
}
```
Для тестового пинга из дашборда используется `test.ping`:
```json
{
"id": "evt_test_ping",
"type": "test.ping",
"created_at": "2026-05-11T20:00:00Z",
"data": { "message": "This is a test webhook event from MailInfra" }
}
```
## Что должен возвращать ваш endpoint
- **Любой `2xx`** в течение **10 секунд** — событие считается обработанным.
- Тяжёлую обработку выносите в фоновую очередь: ответьте `200` сразу, обрабатывайте после.
- Не возвращайте `2xx`, если данные не приняли — иначе вы потеряете событие.
## Повторы и автоотключение
Если ваш endpoint вернул не-`2xx` или превысил таймаут, MailInfra повторяет доставку с экспоненциальной задержкой:
| Попытка | Задержка |
|---------|----------|
| 2-я | 1 минута |
| 3-я | 5 минут |
| 4-я | 30 минут |
| 5-я | 2 часа |
Всего выполняется до пяти попыток доставки одного события. Если пять событий подряд
исчерпали все попытки и остались недоставленными, вебхук автоматически
деактивируется. В дашборде он отобразится как «отключён» — там же его можно снова
активировать после починки endpoint.
## Чек-лист интеграции
- [ ] URL использует HTTPS
- [ ] [Подпись](https://docs.mailinfra.ru/webhooks/signature) проверяется на **сыром теле** запроса (до парсинга JSON)
- [ ] `X-MailInfra-Event-Id` логируется и используется для дедупликации
- [ ] Endpoint отвечает `2xx` в пределах 10 секунд
- [ ] Тяжёлая обработка вынесена в фоновую задачу
- [ ] Секрет вебхука хранится в переменных окружения
---
## Управление вебхуками
CRUD вебхуков, тестовый запуск и ротация секрета — часть [Management API](https://docs.mailinfra.ru/management-api). Базовый префикс: `/v1/organizations/{organization_id}/projects/{project_id}/webhooks`.
| Метод | Путь | Минимальная роль | Что делает |
|-------|------|------------------|------------|
| `GET` | `/webhooks` | `VIEWER` | Список вебхуков проекта |
| `POST` | `/webhooks` | `DEVELOPER` | Создать вебхук, в ответе показывается `secret` |
| `GET` | `/webhooks/{id}` | `VIEWER` | Детали вебхука без секрета |
| `PATCH` | `/webhooks/{id}` | `DEVELOPER` | Изменить URL, события или `is_active` |
| `POST` | `/webhooks/{id}/rotate-secret` | `DEVELOPER` | Сгенерировать новый секрет; старый сразу инвалидируется |
| `POST` | `/webhooks/{id}/test` | `DEVELOPER` | Послать на endpoint синтетическое событие `test.ping` |
| `GET` | `/webhooks/{id}/deliveries` | `VIEWER` | История с фильтрами `status`, `from`, `to`, `limit`, `offset` |
| `GET` | `/webhooks/{id}/deliveries/{delivery_id}` | `VIEWER` | Payload и сохранённый ответ одной попытки |
| `POST` | `/webhooks/{id}/deliveries/{delivery_id}/redeliver` | `DEVELOPER` | Поставить новую попытку в очередь |
| `DELETE` | `/webhooks/{id}` | `DEVELOPER` | Удалить вебхук |
Каждая HTTP-попытка хранится отдельной неизменяемой записью. В списке ответ
endpoint усечён, а detail возвращает сохранённый payload и ответ (ответы
ограничиваются по размеру и очищаются от типовых секретов). Ручной `redeliver`
создаёт новую попытку, не переписывает историю, ограничен по частоте и проходит
ту же SSRF-проверку URL в dispatcher, что и обычная доставка.
### Создать вебхук
```http
POST /v1/organizations/{organization_id}/projects/{project_id}/webhooks
Authorization: Bearer
Content-Type: application/json
```
```json
{
"url": "https://api.example.com/hooks/mailinfra",
"events": ["delivered", "bounced", "complained"]
}
```
| Поле | Тип | Описание |
|------|-----|----------|
| `url` | `string` (HTTPS) | Куда слать `POST` с событием |
| `events` | `string[]` | Подписанные события: 1–20 значений из таблицы выше |
**Ответ `201 Created`:**
```json
{
"data": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"project_id": "...",
"url": "https://api.example.com/hooks/mailinfra",
"events": ["delivered", "bounced", "complained"],
"is_active": true,
"auto_disabled": false,
"created_at": "2026-05-12T09:00:00Z",
"updated_at": "2026-05-12T09:00:00Z",
"secret": "whsec_a3f4e1c2d7b89012345678901234567890abcdef1234567890abcdef12345678"
}
}
```
> **Секрет показывается один раз**
>
> `secret` возвращается **только** при создании и при ротации. Сохраните его как пароль — повторно получить нельзя. Без секрета вы не сможете [проверять подпись](https://docs.mailinfra.ru/webhooks/signature).
### Изменить вебхук
```http
PATCH /v1/.../webhooks/{webhook_id}
Authorization: Bearer
Content-Type: application/json
```
```json
{
"events": ["delivered", "bounced"],
"is_active": true
}
```
Любые из полей `url`, `events`, `is_active` опциональны — обновляется только то, что передали. Чаще всего `is_active: true` нужен после автоматического отключения вебхука из-за серии неудач.
### Тестовый прогон
```http
POST /v1/.../webhooks/{webhook_id}/test
Authorization: Bearer
```
```json
{
"data": {
"success": true,
"http_status": 200,
"error": null
}
}
```
MailInfra сделает `POST` на ваш `url` со специальным событием `test.ping` и подпишет его текущим секретом. Удобно дёргать из CI после деплоя обработчика.
### Ротация секрета
```http
POST /v1/.../webhooks/{webhook_id}/rotate-secret
Authorization: Bearer
```
```json
{ "data": { "secret": "whsec_<новые_64_hex>" } }
```
Старый секрет немедленно перестаёт быть валидным — обновите переменную окружения **до следующего события**, иначе обработчик начнёт отвергать входящие как `Invalid signature`.
## Проверка подписи
Источник: https://docs.mailinfra.ru/webhooks/signature
# Проверка подписи вебхука
Каждый запрос содержит заголовок `X-MailInfra-Signature`. Без проверки подписи endpoint можно подделать любым внешним клиентом.
## Формат заголовка
```
X-MailInfra-Signature: t=1715461200,v1=3d4f2e1a...
```
- `t` — Unix timestamp (секунды UTC) в момент отправки;
- `v1` — HMAC-SHA256 hex-дайджест строки `f"{t}." + body`.
Секрет (`whsec_…`) выдаётся при создании вебхука в дашборде и показывается **один раз**. Он же возвращается заново только при ротации. Храните его как пароль в переменных окружения.
## Алгоритм
1. Получить **сырое тело** запроса (байты до парсинга JSON).
2. Извлечь `t` и `v1` из заголовка.
3. Проверить, что `|now − t| ≤ 300 секунд` — защита от replay-атак.
4. Собрать строку для подписи: `f"{t}.".encode() + body_bytes`.
5. Вычислить HMAC-SHA256 с ключом-секретом (включая префикс `whsec_`).
6. Сравнить с `v1` через **timing-safe** функцию.
> **Сырое тело, не JSON-объект**
>
> Body parsers и middleware могут изменить байты тела (порядок ключей, пробелы). Подпись считается по тем же байтам, что прислал MailInfra. Всегда вычитывайте `request.body` до парсинга и подавайте именно его в верификацию.
## Реализации
**Python**
```python
import hashlib
import hmac
import time
def verify_mailinfra_signature(
body: bytes,
secret: str,
signature_header: str,
max_age_seconds: int = 300,
) -> bool:
"""Проверяет X-MailInfra-Signature.
Args:
body: Сырое тело запроса до парсинга JSON.
secret: Секрет вебхука (whsec_...).
signature_header: Значение X-MailInfra-Signature.
max_age_seconds: Защита от replay-атак.
Returns:
True если подпись корректна и событие свежее.
"""
try:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
timestamp = int(parts["t"])
provided_sig = parts["v1"]
except (KeyError, ValueError):
return False
if abs(int(time.time()) - timestamp) > max_age_seconds:
return False
signing_string = f"{timestamp}.".encode("utf-8") + body
expected_sig = hmac.new(
secret.encode("utf-8"),
signing_string,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected_sig, provided_sig)
```
С FastAPI:
```python
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
WEBHOOK_SECRET = "whsec_..."
@app.post("/hooks/mailinfra")
async def handle_webhook(
request: Request,
x_mailinfra_signature: str = Header(...),
) -> dict:
body = await request.body()
if not verify_mailinfra_signature(body, WEBHOOK_SECRET, x_mailinfra_signature):
raise HTTPException(status_code=400, detail="Invalid signature")
payload = await request.json()
if payload["type"] == "email.delivered":
...
return {"ok": True}
```
**TypeScript / Node.js**
```typescript
import * as crypto from "crypto";
function verifyMailInfraSignature(
body: Buffer | string,
secret: string,
signatureHeader: string,
maxAgeSeconds = 300,
): boolean {
const rawBody = Buffer.isBuffer(body) ? body : Buffer.from(body);
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=", 2) as [string, string]),
);
const timestamp = parseInt(parts.t, 10);
const providedSig = parts.v1;
if (!timestamp || !providedSig) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > maxAgeSeconds) return false;
const signingString = Buffer.concat([
Buffer.from(`${timestamp}.`, "utf-8"),
rawBody,
]);
const expected = crypto
.createHmac("sha256", secret)
.update(signingString)
.digest("hex");
const expectedBuffer = Buffer.from(expected, "hex");
const providedBuffer = Buffer.from(providedSig, "hex");
return expectedBuffer.length === providedBuffer.length &&
crypto.timingSafeEqual(expectedBuffer, providedBuffer);
}
```
С Express обязательно используйте `express.raw`, не `express.json`:
```typescript
import express from "express";
const app = express();
const SECRET = process.env.MAILINFRA_WEBHOOK_SECRET!;
app.post(
"/hooks/mailinfra",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["x-mailinfra-signature"] as string;
if (!verifyMailInfraSignature(req.body, SECRET, sig)) {
return res.status(400).json({ error: "Invalid signature" });
}
const payload = JSON.parse(req.body.toString());
setImmediate(() => handle(payload));
res.json({ ok: true });
},
);
```
**PHP**
```php
$maxAgeSeconds) {
return false;
}
$expected = hash_hmac(
'sha256',
$timestamp . '.' . $rawBody,
$secret
);
return hash_equals($expected, $parts['v1']);
}
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_MAILINFRA_SIGNATURE'] ?? '';
if (!verifyMailInfraSignature($rawBody, $_ENV['MAILINFRA_WEBHOOK_SECRET'], $signature)) {
http_response_code(400);
exit('Invalid signature');
}
$payload = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
http_response_code(200);
```
**Go**
```go
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
)
func VerifySignature(body []byte, secret, signatureHeader string, maxAgeSec int64) bool {
parts := make(map[string]string)
for _, p := range strings.Split(signatureHeader, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
ts, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil || parts["v1"] == "" {
return false
}
if int64(math.Abs(float64(time.Now().Unix()-ts))) > maxAgeSec {
return false
}
signing := append([]byte(fmt.Sprintf("%d.", ts)), body...)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(signing)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}
func WebhookHandler(secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
sig := r.Header.Get("X-MailInfra-Signature")
if !VerifySignature(body, secret, sig, 300) {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
}
```
## Дедупликация
При retry MailInfra шлёт **то же событие** с тем же `X-MailInfra-Event-Id`. Сохраняйте обработанные ID и пропускайте дубли — это самый простой способ сделать обработчик идемпотентным.
## Ротация секрета
При утечке создайте новый секрет в дашборде: **Проект → Вебхуки → Ротация**. Старый секрет немедленно перестаёт быть валидным — обновите переменную окружения до следующего события.
---
# Работа в дашборде
## Обращения в поддержку
Источник: https://docs.mailinfra.ru/dashboard/support
Откройте **Поддержка** в дашборде, чтобы создать обращение и продолжить переписку
с оператором. Для отправки обращения email аккаунта должен быть подтверждён.
## Как составить обращение
1. Выберите тип и раздел проблемы.
2. Коротко сформулируйте тему.
3. Опишите ожидаемый и фактический результат.
4. Для бага добавьте шаги воспроизведения и вложите скриншот или лог.
Не помещайте в текст и вложения API-ключи, пароли, cookie, приватные DKIM-ключи и
другие секреты. Для поиска API-запроса обычно достаточно передать `request_id` из
ответа с ошибкой, идентификатор письма и примерное время события.
## Статусы
| Статус | Что означает | Что делать |
|--------|--------------|------------|
| Новый | Обращение ожидает первичной проверки | Ждать ответа оператора |
| В работе | Оператор разбирает обращение | При необходимости дополнять деталями |
| Ждёт ответа клиента | Поддержке нужна дополнительная информация | Ответить в переписке |
| Решён | Решение или ответ уже предоставлены | Проверить результат; при необходимости ответить |
| Закрыт | Работа по обращению завершена | Создать новое обращение для другой проблемы |
Обращение в статусе «Ждёт ответа клиента» автоматически закрывается после семи
дней без ответа. Решённое обращение можно возобновить ответом в течение 14 дней.
## Вложения
За один раз можно приложить до 10 файлов размером до 50 МиБ каждый. Поддерживаются:
- изображения;
- текстовые файлы и логи;
- PDF;
- JSON.
SVG не принимается. Это ограничение защищает просмотр от активного содержимого.
Вложения открываются авторизованно в отдельной вкладке браузера. Пользователь видит
только файлы своего обращения и публичной переписки; внутренние заметки операторов
недоступны. Интерфейс не предоставляет отдельной кнопки или endpoint для скачивания.
HTML, JSON и неизвестные форматы показываются как обычный текст, чтобы вложение не
могло выполнить скрипт в контексте дашборда.
## Закрытие обращения
Автор может закрыть обращение из его карточки. Если возникла другая проблема, лучше
создать новое обращение — так история и приоритеты не смешиваются.
---
# Справочник
## Список подавления
Источник: https://docs.mailinfra.ru/suppressions
Список подавления — общий для организации список адресов, которым MailInfra **не доставляет** письма, даже если вы передаёте их в `to/cc/bcc`. Защищает репутацию домена и IP от отправки на адреса, которые гарантированно вернутся ошибкой или жалобой.
Управление списком (просмотр, ручное добавление, удаление) — в дашборде: **Организация → Список подавления**.
## Как адреса попадают в список
У каждой записи есть пара полей: `reason` (почему адрес заблокирован) и `source` (как именно он попал в список).
| `source` | `reason` | Что произошло |
|----------|----------|---------------|
| `AUTO` | `HARD_BOUNCE` | Принимающий сервер вернул `5xx` — адрес не существует или закрыт |
| `AUTO` | `COMPLAINT` | Получатель пожаловался на спам (FBL) |
| `MANUAL` | `UNSUBSCRIBE` / `MANUAL` / любой | Добавлен через API или дашборд (например, после нажатия «Отписаться») |
## Как это видно в API отправки
При вызове `POST /v1/emails` каждый адрес из `to`, `cc`, `bcc` сверяется со списком. Подавлённые тихо исключаются и возвращаются в `skipped_recipients`:
```json
{
"data": {
"id": "018e1b7a-1111-7000-aaaa-111111111111",
"status": "QUEUED",
"skipped_recipients": [
{ "email": "blocked@example.com", "reason": "suppressed" }
]
}
}
```
`reason: "suppressed"` означает, что адрес найден в suppression list. Конкретная
причина записи (`HARD_BOUNCE`, `COMPLAINT`, `UNSUBSCRIBE` и т.д.) доступна при
просмотре самого списка, но не раскрывается в ответе отправки.
Если **все** получатели подавлены — письмо не создаётся, ответ `422`:
```json
{
"error": {
"code": "all_recipients_suppressed",
"message": "All recipients are in the suppression list"
}
}
```
## Причины подавления
| Значение | Когда возникает |
|----------|----------------|
| `HARD_BOUNCE` | Адрес не существует или домен не принимает почту |
| `COMPLAINT` | Получатель пометил письмо как спам |
| `UNSUBSCRIBE` | Получатель отписался (через ваш сайт или ссылку отписки) |
| `MANUAL` | Добавлен оператором |
## Удаление из списка
Удалять адрес стоит только если вы уверены, что причина исчезла: пользователь подтвердил, что хочет получать письма; адрес действительно существует. Повторная отправка на несуществующий адрес ухудшает репутацию домена и IP.
`HARD_BOUNCE` особенно: если адрес действительно умер, его повторная попытка снова закончится bounce — и подавление вернётся.
---
## API списка подавления
Список подавления общий для всей **организации**, поэтому базовый префикс — `/v1/organizations/{organization_id}/suppressions`. Управление — часть [Management API](https://docs.mailinfra.ru/management-api).
| Метод | Путь | Минимальная роль | Что делает |
|-------|------|------------------|------------|
| `GET` | `/suppressions` | `VIEWER` | Список с фильтрацией |
| `POST` | `/suppressions` | `DEVELOPER` | Добавить адрес вручную |
| `DELETE` | `/suppressions/{id}` | `DEVELOPER` | Удалить запись |
### Просмотр
```http
GET /v1/organizations/{organization_id}/suppressions
Authorization: Bearer
```
Поддерживает фильтры:
| Параметр | Тип | Описание |
|----------|-----|----------|
| `email` | `string` | Поиск по конкретному адресу |
| `reason` | `HARD_BOUNCE \| COMPLAINT \| UNSUBSCRIBE \| MANUAL` | Фильтр по причине |
| `limit` | `int` | 1–500, по умолчанию 100 |
```http
GET /v1/.../suppressions?email=user@example.com
GET /v1/.../suppressions?reason=HARD_BOUNCE&limit=50
```
**Ответ:**
```json
{
"data": [
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"email": "user@example.com",
"reason": "HARD_BOUNCE",
"source": "AUTO",
"notes": null,
"created_at": "2026-05-11T20:00:00Z"
}
]
}
```
### Ручное добавление
Пригодится для серверной реализации кнопки «Отписаться» на вашем сайте: пользователь нажимает — вы кладёте адрес в список подавления, MailInfra перестаёт ему писать.
```http
POST /v1/organizations/{organization_id}/suppressions
Authorization: Bearer
Content-Type: application/json
```
```json
{
"email": "user@example.com",
"reason": "UNSUBSCRIBE",
"notes": "Пользователь отписался через форму на сайте"
}
```
| Поле | Тип | По умолчанию | Описание |
|------|-----|--------------|----------|
| `email` | `string` | — | Адрес, который добавляем |
| `reason` | `HARD_BOUNCE \| COMPLAINT \| UNSUBSCRIBE \| MANUAL` | `MANUAL` | Причина подавления |
| `notes` | `string \| null` | `null` | Свободный комментарий, виден только в дашборде |
В ответе вернётся запись с `source: "MANUAL"` — поле `source` отличает ручные записи от автоматических (`source: "AUTO"`), которые MailInfra ставит на bounce и FBL.
### Удаление
```http
DELETE /v1/organizations/{organization_id}/suppressions/{suppression_id}
Authorization: Bearer
```
После удаления адрес снова попадает в выборку при отправке. Не удаляйте `HARD_BOUNCE` без явной причины — см. предупреждение [выше](#удаление-из-списка).
## Ошибки и лимиты
Источник: https://docs.mailinfra.ru/errors
Это центральная страница со всеми кодами и лимитами Public API.
## Формат ответа
Все ошибки приходят с JSON-телом единого формата:
```json
{
"error": {
"code": "domain_not_verified",
"message": "Домен ещё не подтверждён.",
"details": {
"code": "domain_not_verified",
"domain": "example.ru"
}
},
"request_id": "3d6f0dce5d1b47bf8b3f9f98c9a70fd4"
}
```
Передавайте `request_id` поддержке: по нему можно найти конкретный запрос, не
раскрывая API-ключ или содержимое письма.
Ошибки схемы запроса (`422`) возвращают безопасный список проблем в
`details.fields`:
```json
{
"error": {
"code": "request_validation_failed",
"message": "Проверьте заполненные поля.",
"details": {
"fields": [
{
"field": "to.0",
"message": "укажите корректный email",
"code": "value_error"
}
]
}
},
"request_id": "3d6f0dce5d1b47bf8b3f9f98c9a70fd4"
}
```
## HTTP-коды
| Код | Значение | Когда |
|-----|----------|-------|
| `400` | Bad Request | Некорректное содержимое после разбора схемы: base64, шаблон, значение header и т.д. |
| `401` | Unauthorized | API-ключ отсутствует, невалиден, отозван или истёк |
| `403` | Forbidden | Прав ключа недостаточно (например, `READ_ONLY` пытается отправлять) |
| `404` | Not Found | Ресурс не найден или не принадлежит проекту/организации |
| `409` | Conflict | Дублирующийся ресурс (slug шаблона, имя ключа, домен и т.д.) |
| `422` | Unprocessable Entity | Ошибка схемы или бизнес-проверки: зарезервированный header, домен не готов и т.д. |
| `429` | Too Many Requests | Превышен RPS или месячный объём тарифа |
| `5xx` | Server Error | Внутренняя ошибка — повторите запрос с backoff |
## Rate Limits
При `429` API возвращает заголовок `Retry-After` — минимальное число секунд до
безопасной повторной попытки:
```http
Retry-After: 1
```
Для секундного ограничения обычно возвращается `Retry-After: 1`. При исчерпании
месячного объёма значение указывает время до начала следующего календарного месяца
по UTC. Заголовки `X-RateLimit-*` API сейчас не предоставляет.
## Лимиты по плану
| Параметр | FREE | PAID | PREMIUM |
|----------|:----:|:----:|:-------:|
| Объём отправки в месяц | 100 | 10 000 | 100 000 |
| Доменов | 1 | 5 | 50 |
| Webhook endpoints | 3 | 10 | 1 000 |
Объём считается по получателям: одно письмо с тремя адресами в `to/cc/bcc`
расходует три единицы. Счётчик сбрасывается в начале календарного месяца по UTC.
Для live-отправки действует секундный лимит проекта, по умолчанию 10 запросов на
API-ключ. TEST-ключи создают записи со статусом `SIMULATED`: реальной доставки нет,
месячный объём и live RPS не расходуются.
## Лимиты на письмо
| Параметр | Значение |
|----------|----------|
| Получателей (`to + cc + bcc`) | 50 |
| `reply_to` адресов | 10 |
| Тегов | 10 |
| Длина тега | 32 символа |
| Длина `subject` | 998 символов (RFC 5321) |
| Размер вложения | 10 МиБ |
| Вложений в письме | 10 |
| Размер письма целиком | 25 МиБ |
| Писем в одном `POST /v1/emails/batch` | 100 |
## Retry с экспоненциальным backoff
Корректная стратегия для интеграции:
```python
import time
import httpx
def send_with_retry(payload: dict, api_key: str, max_retries: int = 3) -> dict:
"""Отправляет письмо с retry на 429 и 5xx.
Args:
payload: Тело запроса POST /v1/emails.
api_key: Plain API-ключ (mi_live_... или mi_test_...).
max_retries: Максимум повторных попыток.
Returns:
Поле `data` успешного ответа.
Raises:
RuntimeError: Если все попытки исчерпаны.
"""
delay = 1.0
for _ in range(max_retries):
r = httpx.post(
"https://api.mailinfra.ru/v1/emails",
headers={"Authorization": f"Bearer {api_key}"},
json=payload,
)
if r.status_code == 429:
wait = int(r.headers.get("Retry-After", delay))
time.sleep(wait)
delay *= 2
continue
if r.status_code >= 500:
time.sleep(delay)
delay *= 2
continue
r.raise_for_status()
return r.json()["data"]
raise RuntimeError("Max retries exceeded")
```
Для безопасного retry на сетевых таймаутах (`ConnectError`, чтение прервалось) добавляйте заголовок `Idempotency-Key` — повтор не приведёт к дубликату письма. См. [Идемпотентность](https://docs.mailinfra.ru/sending/single-email#идемпотентность).
## Changelog
Источник: https://docs.mailinfra.ru/changelog
## 2026-07-15
### Изменено
- `POST /v1/emails/batch` возвращает результат каждого элемента с `index`,
`error`, `accepted_count` и `failed_count`. Ожидаемая ошибка одного письма не
останавливает следующие элементы.
- Зарезервированные пользовательские email headers больше не игнорируются молча:
запрос отклоняется с `422 reserved_email_header`.
- Отсутствующая переменная шаблона больше не рендерится как пустая строка: отправка
отклоняется с `422 template_variable_missing` и не расходует месячный объём.
- Resend одноразового LIVE sandbox trial явно недоступен; TEST/SIMULATED-письма
можно переотправлять для повторяемых интеграционных проверок.
### Документация
- Уточнены фактический формат ошибок, `request_id`, `Retry-After` и месячные лимиты.
- Добавлены формат email-вложений и правила безопасного просмотра вложений поддержки.
- Исправлено значение `skipped_recipients.reason` для suppression list.
## 2026-05-12
### Добавлено
- Право доступа `READ_ONLY` для API-ключей: разрешает только `GET /v1/emails*`, без отправки. Дополняет существующие `FULL_ACCESS` и `SENDING_ONLY`.
### Изменено
- Права доступа `SENDING_ONLY` и `READ_ONLY` теперь действительно ограничивают доступ: запрос вне разрешённого набора возвращает `403 Forbidden`.
## 2026-05-11
### Добавлено
- Уникальные имена API-ключей в пределах активных ключей проекта (`409` при дублировании).
- Поддержка нескольких проектов в рамках одной организации.
### Исправлено
- Корректная обработка `skipped_recipients` в пакетной отправке.
## 2026-05-01 — v1.0
### Добавлено
- `POST /v1/emails`, `POST /v1/emails/batch` — отправка одиночных и пакетных писем.
- `GET /v1/emails`, `GET /v1/emails/{id}`, `GET /v1/emails/{id}/events` — чтение писем и их событий.
- Шаблоны Jinja2: рендер при отправке, версионирование.
- Вебхуки: подписка на события, HMAC-SHA256 подпись, retry с экспоненциальным backoff.
- Список подавления: автоматическое добавление при bounce/complaint.
- Идемпотентность: заголовок `Idempotency-Key`.
- API-ключи: `LIVE` / `TEST` среды, права `FULL_ACCESS` / `SENDING_ONLY`, срок действия.
---
> **Версионирование**
>
> Текущая версия — **v1** (префикс `/v1`). Обратно несовместимые изменения будут выходить под `/v2` с предварительным уведомлением.