Документация / Приём писем

Приём писем

Приём — зеркало отправки. Вы направляете на fmailer MX-запись поддомена, которым владеете, и каждое пришедшее на него письмо возвращается вам вебхуком inbound.received: уже разобранным на поля, подписанным, со ссылками на исходный .eml и каждое вложение. Обзор сценария целиком, включая переход с Mailgun Inbound Routes, SendGrid Inbound Parse и связки Amazon SES + Lambda, — на странице «Приём входящих писем».

Письма не хранятся для чтения человеком в почтовом клиенте, нет ни IMAP, ни POP3, и отвечать с адреса приёма нельзя — ответ отправляется обычным способом, через SMTP или API. Приём нужен, чтобы письмо попадало в ваш код: в тикет-систему, CRM, парсер, обработчик ответов.

Как это работает

  1. Вы задаёте хост приёма — поддомен уже подтверждённого домена, например inbound.example.ru.
  2. Публикуете для него одну MX-запись, и мы убеждаемся, что она резолвится на нас.
  3. Добавляете маршруты: какие адреса принимаете и в какой вебхук-эндпоинт отдавать каждый из них.
  4. Сервер отправителя подключается к нашему, мы принимаем письмо, сохраняем его и отправляем POST в эндпоинт, указанный в сработавшем маршруте.
  5. Письмо остаётся видимым в панели, пока его не удалит срок хранения журнала по тарифу.

Что нужно заранее

  • Тариф с приёмом. Он же определяет, сколько маршрутов может быть у домена. Чтение остаётся доступным и после понижения тарифа — запрещаются только создание и изменение.
  • Подтверждённый домен. Должен быть подтверждён DKIM домена. Это ключевая проверка: MX-запись — публичные данные зоны, ожидать их может кто угодно, поэтому право на почту доказывает подтверждение домена, а не наличие MX.
  • Вебхук-эндпоинт этого домена, подписанный на событие inbound.received.

1. Задайте хост приёма

В панели откройте домен и перейдите в раздел Приём → Настройка. Поле уже заполнено значением inbound.<ваш домен>; подойдёт любой поддомен.

  • Это должен быть именно поддомен, а не сам домен. Направив на нас MX корневого домена, вы перенаправите рабочую корпоративную почту.
  • Нельзя использовать хост внутри нашего собственного домена.
  • Сохранение пустого значения выключает приём и убирает MX-запись, которую мы отслеживаем.

При смене хоста запись создаётся заново, вместе с ней сбрасывается и подтверждение — новое имя проверяется с нуля.

2. Опубликуйте MX-запись

Экран настройки показывает готовую запись, включая нужный адрес назначения:

inbound.example.ru.   IN   MX   10   mx.fmailer.ru.

Приоритет 10, и другой MX на этом хосте быть не должно. Сохранение хоста сразу запускает проверку, дальше запись перепроверяется ежедневно, а кнопка Проверить запускает проверку вручную. Пока запись не подтверждена, письма от отправителей отклоняются.

Ни одно письмо не будет принято, пока домен не подтверждён и MX не резолвится на нас. Маршрут, созданный раньше, спокойно сохранится и не получит ничего.

3. Подпишите эндпоинт на inbound.received

Приём использует тот же механизм вебхуков, что и события доставки: та же схема подписи, те же заголовки, те же повторы — см. страницу «Вебхуки». Важны два отличия:

  • inbound.received не включено по умолчанию. Его нужно отметить явно, иначе эндпоинт выглядит настроенным и ничего не получает. Редактор маршрута предупреждает об этом и предлагает включить событие на месте.
  • Рассылки по всем эндпоинтам не происходит. Обычные события уходят всем подписанным эндпоинтам, а входящее письмо — только тому, который указан в маршруте. Подписки недостаточно.

Что будет, если ваш эндпоинт не ответил

То же, что и для любого другого события: всё, кроме 2xx — а также сетевая ошибка и молчание дольше 10 секунд — считается неудачей, и доставка повторяется по обычному расписанию (60с → 5м → 30м → 2ч → 6ч → 24ч), после чего помечается окончательно неудавшейся. Подробности на странице «Вебхуки».

Особенность приёма: само письмо при этом не теряется. Оно сохраняется до первого вызова вашего эндпоинта, поэтому письмо, у которого кончились повторы, по-прежнему лежит в разделе Письма вместе с телом, .eml и вложениями — до истечения срока хранения. Если сервис лежал полдня, заберите пропущенное оттуда или через GET /api/inbound/messages/, а не просите отправителей слать заново.

4. Добавьте маршруты

Приём → Маршруты. Маршрут — это шаблон локальной части адреса; хост всегда текущий хост приёма домена, поэтому маршрут не может «пережить» смену хоста и указывать в никуда. Тип маршрута определяется по шаблону:

ШаблонТипЧто попадает
supportexactтолько support@
ticket-*prefixticket-91@, ticket-abc@, но не голое ticket-@
*catch_allвсё, что не забрал другой маршрут

Выигрывает самый конкретный маршрут: сначала точное совпадение, затем самый длинный подходящий префикс, затем catch-all. Адрес, который не подходит ни под один включённый маршрут, отклоняется на уровне SMTP — мы не принимаем письма просто потому, что знаем домен.

Теги после плюса не отрезаются

Локальная часть сравнивается дословно, только приводится к нижнему регистру. Никакой обработки +tag и никакой другой нормализации нет: support+zakaz42 — это другая локальная часть, чем support.

АдресМаршрут supportМаршрут support*Маршрут *
support@✅ подходит❌ (после префикса ничего нет)
support+zakaz42@не подходит✅ подходит

Если вы генерируете адреса с тегами, заводите префиксный маршрут support* — catch-all не единственный вариант. Учтите, что это обычный строковый префикс: он заберёт и supportnaya@. Точный маршрут support имеет приоритет над префиксным, поэтому их можно держать вместе.

Каждый маршрут указывает один эндпоинт, может иметь описание и может быть выключен без удаления. Максимальное число маршрутов задаёт тариф; на экране маршрутов видно, сколько из них занято.

Пересылка в обычный ящик

Маршрут может дополнительно — или вместо вебхука — пересылать письмо в обычный почтовый ящик. Если вам нужно просто, чтобы sales@ приходило в вашу Gmail, укажите адрес пересылки и обойдитесь без вебхука: у маршрута должен быть эндпоинт, адрес пересылки или и то и другое.

Адрес получателя сначала должен подтвердиться. Мы отправляем туда письмо со ссылкой, и до нажатия кнопки не пересылается ничего. Это не формальность: иначе любой владелец домена мог бы направить catch-all в чужой ящик, и мы доставляли бы туда всё, что он сам себе написал. Смена адреса запрашивает подтверждение заново.

Письмо пересылается ровно в том виде, в каком пришло — исходный From, исходное тело и нетронутая подпись DKIM отправителя. Именно она позволяет пересланному письму пройти DMARC на стороне получателя, поэтому мы его не пересобираем и не подписываем от вашего имени. Добавляем только один заголовок, который останавливает петлю пересылки.

Адресом конверта становится адрес, на который письмо пришло, а не автор письма. Поэтому отказ по пересылке возвращается на ваш же маршрут, где вы его увидите, а не постороннему человеку, который в этот ящик не писал.

Пересланные письма расходуют объём тарифа, как и всё остальное, что уходит с наших серверов, и видны в логах с методом forward.

Формат события

Приходит подписанным POST, как и любое другое событие: в X-Webhook-Event будет inbound.received. Тело крупнее, чем у событий доставки, и статуса отправки в нём нет — это само письмо.

json
{
  "event_id": "6f1d2c30-0004-4c7a-9b21-0c8e5a3d7f44",
  "event": "inbound.received",
  "inbound_id": "b41f9a70-2c55-4d1e-9a7f-1d3e5c9b2a08",
  "domain": "example.ru",
  "route": "support",
  "recipient": "support@inbound.example.ru",
  "mail_from": "client@partner.ru",
  "from": { "email": "client@partner.ru", "name": "Иван Петров" },
  "to": ["support@inbound.example.ru"],
  "cc": [],
  "subject": "Не пришёл счёт по заказу 4417",
  "text": "Добрый день! Заказ оплачен, счёта нет.",
  "html": "<p>Добрый день! Заказ оплачен, счёта нет.</p>",
  "headers": { "Reply-To": ["client@partner.ru"] },
  "message_id": "<CAF9x1@mail.partner.ru>",
  "auth": { "dkim": "pass", "dkim_aligned": true },
  "size": 24188,
  "raw_url": "https://api.fmailer.ru/api/inbound/messages/b41f9a70-.../raw/",
  "attachments": [
    {
      "id": "9c7e1b52-40a8-4f9d-8c31-6b2a4e0d1f77",
      "filename": "чек.pdf",
      "content_type": "application/pdf",
      "size": 18422,
      "inline": false,
      "url": "https://api.fmailer.ru/api/inbound/messages/b41f9a70-.../attachments/9c7e1b52-.../"
    }
  ],
  "timestamp": "2026-06-24T09:41:13.482921+00:00"
}
ПолеЧто это
event_idключ идемпотентности; при повторах не меняется
inbound_idидентификатор сохранённого письма — с ним работают все запросы API ниже
routeшаблон сработавшего маршрута, например ticket-*
recipientадрес, для которого письмо принято
mail_fromотправитель из SMTP-конверта — часто не совпадает с From:, а у отчёта о недоставке пуст
fromразобранный заголовок From:: адрес и отображаемое имя
to, ccадреса из заголовков; в письме могут быть получатели, которых вы не принимали
text, htmlчасти тела, какие были в письме; обрезаются на 500 000 символов
headersфиксированный набор заголовков, каждый — список значений; полный список ниже
message_idMessage-ID отправителя дословно, вместе с угловыми скобками, либо "", если его не было. Мы здесь ничего не подставляем — в отличие от событий доставки, где message_id присвоен нами
authрезультат проверки подлинности, см. ниже
raw_urlссылка на исходный .eml
attachmentsимя файла, тип, размер, признак inline и ссылка на скачивание

headers: полный список

Это не пример, а весь набор целиком: ничего сверх него в headers не бывает. Всё остальное — цепочка Received:, блок ARC-*, пометки антиспама на промежуточных узлах — есть в .eml по raw_url и больше нигде.

ЗаголовокЗачем
Dateвремя по данным клиента отправителя
Reply-Toадрес для ответа, если он отличается от From:
In-Reply-ToMessage-ID родительского письма
Referencesпередаётся — вся цепочка треда, запасной вариант, когда In-Reply-To отсутствует
Return-Pathадрес для отчётов о недоставке, как его записал последний узел
Auto-Submittedпризнак автоматического письма по RFC 3834
Precedenceстарое соглашение bulk / list / junk
List-Idидентификатор рассылки
List-Unsubscribeадрес отписки от рассылки
Content-Typeтип письма верхнего уровня
User-Agent, X-Mailerкаким клиентом отправлено — два написания, встречаются оба
X-Priorityприоритет, заявленный отправителем
  • Значение всегда список, даже если заголовок встретился один раз: headers["References"][0].
  • Если заголовка в письме не было, ключа нет вовсе — читайте через .get(name, []).
  • Значения нормализуются: переносы склеиваются, пробелы схлопываются, и значение обрезается на 2000 символах. Длинный References в старом треде может в это упереться — полный вариант в .eml.

auth: только DKIM — и это осознанно

  • dkim — результат по RFC 8601: pass, fail, none, temperror, permerror. temperror означает «не ответил DNS», а не «письмо поддельное»; если считать иначе, вы будете терять настоящие письма.
  • dkim_aligned — подписавший домен совпадает с доменом в From:. Действительная подпись постороннего домена не делает отправителя тем, за кого он себя выдаёт.
  • Полей spf и dmarc нет намеренно. Мы не вычисляем SPF, а написать "spf": "none" было бы неправдой: по RFC это утверждение, что у домена вовсе нет SPF-записи. Вердикт DMARC — дизъюнкция (выравненный SPF или выравненный DKIM), и вычисленный из одного DKIM он давал бы ложные отказы.

Считайте отправителя проверенным, когда dkim == "pass"и dkim_aligned истинно.

Обработка

inbound.pypython
from flask import Flask, request

app = Flask(__name__)

@app.post("/webhooks/inbound")
def inbound():
    # Подпись проверяется точно так же, как у остальных событий —
    # см. страницу «Вебхуки». Схема и заголовки те же.
    evt = request.get_json()
    if evt["event"] != "inbound.received":
        return "", 200

    # Защита от повторной обработки: при повторах приходит тот же event_id.
    if already_handled(evt["event_id"]):
        return "", 200

    # Отправителю можно доверять, только если DKIM прошёл И совпал с From.
    trusted = evt["auth"]["dkim"] == "pass" and evt["auth"]["dkim_aligned"]

    create_ticket(
        route=evt["route"],                 # "support"
        sender=evt["from"]["email"],
        subject=evt["subject"],
        body=evt["text"] or evt["html"],
        trusted=trusted,
        attachments=[a["url"] for a in evt["attachments"]],
    )
    return "", 200

Авторизация запросов к API

Всё, что под /api/ — маршруты, письма, raw_url и ссылки на вложения — это API аккаунта, и авторизуется оно как вы, владелец аккаунта. Подходят два варианта, и передаются они по-разному:

Что этоГде взятьЗаголовок
Токен аккаунта, 24 символа без префиксаПанель → Профиль, карточка API-токен. Показывается целиком при каждом открытии страницы; кнопка создания нового токена мгновенно гасит старый. Именно его кладут в переменную окружения.Authorization: <токен>
передаётся как есть, без Bearer
JWT access-токенPOST /api/users/auth/ с логином и паролем от панели, в ответе { "access": …, "refresh": … }. Значение access живёт 60 минут. Так работает сама панель; для серверной интеграции удобнее токен аккаунта.Authorization: Bearer <access>
Токен домена (пара логин/пароль для SMTP и для отправки через API) — другая сущность. Он авторизует один домен на отправку, передаётся в теле запроса и работает только на эндпоинтах отправки. На /api/ он даёт 401.

Чтение полученной почты токеном отправки

Всё, что принял маршрут, читается и через публичный read API — тем же токеном домена, которым вы отправляете, по HTTP Basic; токен аккаунта не нужен:

МетодНа какой вопрос отвечает
GET /external/inbound/Что домен получил.
GET /external/inbound/<inbound_id>/Одно письмо — в том же виде, что и в вебхуке.
GET /external/inbound/<inbound_id>/raw/Исходный .eml, редиректом.
GET /external/inbound/<inbound_id>/attachments/<id>/Одно вложение, редиректом.

inbound_id уже есть в теле вебхука, поэтому сервису, обработавшему событие, не нужно хранить второй идентификатор, — а имена полей в ответе те же, что и в вебхуке: вы читаете одну структуру, а не две.

Ссылки внутри вебхука не изменились: они по-прежнему ведут в API аккаунта и требуют токен аккаунта. Это намеренно — тело вебхука сохраняется и повторяется при ретраях до суток, и переписывать этот контракт мы не будем. Если у вас на руках токен отправки, используйте методы выше.

Это поверхность только для чтения: список и получение, без удаления. Удаление принятой почты остаётся действием в панели — это ваша запись о том, что пришло, и утёкший пароль отправки не должен её стирать.

Исходное письмо и вложения

raw_url и ссылки вложений — это адреса нашего API, а не хранилища. Тело события сохраняется и повторяется без изменений в течение суток, а подписанная ссылка на хранилище живёт 5 минут — вшить её означало бы отдавать на повторах мёртвые ссылки. Адреса API постоянны, требуют авторизации и в момент обращения перенаправляют на свежую подписанную ссылку.

python
import os
import requests

# Ссылки в payload — это адреса нашего API, а не хранилища: они постоянны,
# требуют токена аккаунта и отдают 302 на свежую подписанную ссылку в момент
# обращения.
res = requests.get(
    attachment_url,
    # Токен аккаунта передаётся КАК ЕСТЬ — без префикса "Bearer".
    headers={"Authorization": os.environ["FMAILER_API_TOKEN"]},
    allow_redirects=True,
)
open("чек.pdf", "wb").write(res.content)

У обоих адресов есть вариант …/url (/raw_url/, /attachments/<id>/url), который возвращает подписанную ссылку в JSON вместо редиректа — для клиентов, которые не могут последовать за кросс-доменным 302 с заголовком Authorization. Именно им пользуются кнопки скачивания в панели.

В панели

  • Настройка — хост, MX-запись для публикации и её состояние.
  • Маршруты — список маршрутов и их эндпоинтов, с предупреждением о тех, чей эндпоинт не подписан на inbound.received.
  • Письма — всё принятое, с фильтрами по маршруту и результату DKIM. Внутри письма — разобранное тело, заголовки, вердикт DKIM и кнопки скачивания .eml и вложений. Удаление письма удаляет и сохранённые файлы.

Ограничения

ОграничениеЗначение
Размер письма30 МБ
Вложений в письме25, до 25 МБ каждое
Длина сохраняемого тела500 000 символов на часть
Писем на домен500 в час
Маршрутов на доменпо тарифу
Хранениесрок хранения журнала по тарифу; письмо, .eml и вложения удаляются вместе

Что видит отправитель

ОтветКогда
250письмо сохранено, вебхук поставлен в очередь
550ни один включённый маршрут не подходит — жёсткий отказ, отправителю сообщается, что адреса не существует
552превышен размер письма
450превышен часовой лимит домена, сервер отправителя повторит попытку
451временная ошибка на нашей стороне, сервер отправителя повторит попытку

Гарантии доставки

  • Не менее одного раза с обеих сторон: отправитель, не дождавшийся ответа после DATA, пришлёт письмо снова, а наш вебхук повторяется при неудаче. Защищайтесь по event_id.
  • Повторно присланные те же байты для того же получателя распознаются и вебхук второй раз не вызывают. Письмо, адресованное двум вашим маршрутам, — это два письма, а не дубль.
  • Петли ограничены, но не исключены. Письмо со слишком длинной цепочкой Received: отклоняется, а дополнительно защищает часовой лимит. Не стройте автоответчик, который отвечает на адрес маршрута.
  • Автоответы, отчёты о недоставке и рассылки не отбрасываются — это ваша почта, а Auto-Submitted сам по себе не признак петли. Соответствующие заголовки передаются в headers, чтобы ваш код мог отфильтровать их сам.

API

ЗапросЧто делает
GET /api/inbound/hostname/<uuid домена>/хост, адрес для MX и состояние проверки
PUT /api/inbound/hostname/<uuid домена>/{"hostname": "inbound.example.ru"} — создаёт MX-запись и запускает проверку; пустая строка выключает приём
GET/POST /api/inbound/routes/список (?domain=<id>) и создание маршрутов; kind вычисляется по шаблону и только читается
GET/PUT/PATCH/DELETE /api/inbound/routes/<uuid>/один маршрут
GET /api/inbound/messages/принятые письма; фильтры ?domain=<id>, ?route=<uuid>, ?dkim_result=
GET /api/inbound/messages/<uuid>/одно письмо со списком вложений
DELETE /api/inbound/messages/<uuid>/удалить письмо вместе с файлами
GET /api/inbound/messages/<uuid>/raw/302 на исходный .eml
GET /api/inbound/messages/<uuid>/raw_url/та же ссылка в теле ответа
GET /api/inbound/messages/<uuid>/attachments/<id>/302 на вложение
GET /api/inbound/messages/<uuid>/attachments/<id>/url/та же ссылка в теле ответа

Реселлеры управляют маршрутами своих клиентов через /api/wl/v1/inbound-routes/ со скоупами inbound:read и inbound:write; MX-запись для публикации возвращается вместе с остальными записями домена — см. страницу White-label.

Если что-то не работает

СимптомЧто смотреть
Отправителям приходит «адрес не существует»нет включённого маршрута под эту локальную часть либо ещё не подтверждён домен или MX
Письма видны в панели, но в сервис ничего не приходитэндпоинт маршрута не подписан на inbound.received или выключен; проверьте раздел доставок вебхуков
Не приходит вообще ничегоперепроверьте MX-запись на экране настройки: она должна быть единственной на этом хосте и вести на указанный там адрес
Письмо попало не в тот маршрутпомните порядок: точное совпадение, самый длинный префикс, затем *
Ссылка на скачивание отдаёт 403подписанная ссылка использована после истечения срока — запросите новую
Письмо дошло один раз и перестало приходитьэндпоинт ответил не 2xx (или молчал 10 секунд), повторы кончились; само письмо осталось в разделе «Письма» — см. выше
Письмо на support+тег@ попало в catch-allтак и задумано: теги не отрезаются. Заведите префиксный маршрут support*
401 при обращении к raw_url или вложениюне тот токен или не та форма заголовка: токен домена здесь не работает, а токен аккаунта передаётся без Bearer — см. раздел об авторизации