# 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 %} {% endfor %}
{{ item.name }}{{ item.qty }} шт.{{ item.price }} ₽

Итого: {{ 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` с предварительным уведомлением.