Приём — зеркало отправки. Вы направляете на fmailer MX-запись поддомена, которым владеете, и каждое пришедшее на него письмо возвращается вам вебхуком inbound.received: уже разобранным на поля, подписанным, со ссылками на исходный .eml и каждое вложение. Обзор сценария целиком, включая переход с Mailgun Inbound Routes, SendGrid Inbound Parse и связки Amazon SES + Lambda, — на странице «Приём входящих писем».
inbound.example.ru.POST в эндпоинт, указанный в сработавшем маршруте.inbound.received. В панели откройте домен и перейдите в раздел Приём → Настройка. Поле уже заполнено значением inbound.<ваш домен>; подойдёт любой поддомен.
При смене хоста запись создаётся заново, вместе с ней сбрасывается и подтверждение — новое имя проверяется с нуля.
Экран настройки показывает готовую запись, включая нужный адрес назначения:
inbound.example.ru. IN MX 10 mx.fmailer.ru. Приоритет 10, и другой MX на этом хосте быть не должно. Сохранение хоста сразу запускает проверку, дальше запись перепроверяется ежедневно, а кнопка Проверить запускает проверку вручную. Пока запись не подтверждена, письма от отправителей отклоняются.
inbound.receivedПриём использует тот же механизм вебхуков, что и события доставки: та же схема подписи, те же заголовки, те же повторы — см. страницу «Вебхуки». Важны два отличия:
inbound.received не включено по умолчанию. Его нужно отметить явно, иначе эндпоинт выглядит настроенным и ничего не получает. Редактор маршрута предупреждает об этом и предлагает включить событие на месте. То же, что и для любого другого события: всё, кроме 2xx — а также сетевая ошибка и молчание дольше 10 секунд — считается неудачей, и доставка повторяется по обычному расписанию (60с → 5м → 30м → 2ч → 6ч → 24ч), после чего помечается окончательно неудавшейся. Подробности на странице «Вебхуки».
Особенность приёма: само письмо при этом не теряется. Оно сохраняется до первого вызова вашего эндпоинта, поэтому письмо, у которого кончились повторы, по-прежнему лежит в разделе Письма вместе с телом, .eml и вложениями — до истечения срока хранения. Если сервис лежал полдня, заберите пропущенное оттуда или через GET /api/inbound/messages/, а не просите отправителей слать заново.
Приём → Маршруты. Маршрут — это шаблон локальной части адреса; хост всегда текущий хост приёма домена, поэтому маршрут не может «пережить» смену хоста и указывать в никуда. Тип маршрута определяется по шаблону:
| Шаблон | Тип | Что попадает |
|---|---|---|
support | exact | только support@ |
ticket-* | prefix | ticket-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. Тело крупнее, чем у событий доставки, и статуса отправки в нём нет — это само письмо.
{
"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_id | Message-ID отправителя дословно, вместе с угловыми скобками, либо "", если его не было. Мы здесь ничего не подставляем — в отличие от событий доставки, где message_id присвоен нами |
auth | результат проверки подлинности, см. ниже |
raw_url | ссылка на исходный .eml |
attachments | имя файла, тип, размер, признак inline и ссылка на скачивание |
headers: полный список Это не пример, а весь набор целиком: ничего сверх него в headers не бывает. Всё остальное — цепочка Received:, блок ARC-*, пометки антиспама на промежуточных узлах — есть в .eml по raw_url и больше нигде.
| Заголовок | Зачем |
|---|---|
Date | время по данным клиента отправителя |
Reply-To | адрес для ответа, если он отличается от From: |
In-Reply-To | Message-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, []).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 истинно.
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/ — маршруты, письма, raw_url и ссылки на вложения — это API аккаунта, и авторизуется оно как вы, владелец аккаунта. Подходят два варианта, и передаются они по-разному:
| Что это | Где взять | Заголовок |
|---|---|---|
| Токен аккаунта, 24 символа без префикса | Панель → Профиль, карточка API-токен. Показывается целиком при каждом открытии страницы; кнопка создания нового токена мгновенно гасит старый. Именно его кладут в переменную окружения. | Authorization: <токен>передаётся как есть, без Bearer |
| JWT access-токен | POST /api/users/auth/ с логином и паролем от панели, в ответе { "access": …, "refresh": … }. Значение access живёт 60 минут. Так работает сама панель; для серверной интеграции удобнее токен аккаунта. | Authorization: Bearer <access> |
/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 постоянны, требуют авторизации и в момент обращения перенаправляют на свежую подписанную ссылку.
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. Именно им пользуются кнопки скачивания в панели.
inbound.received..eml и вложений. Удаление письма удаляет и сохранённые файлы.| Ограничение | Значение |
|---|---|
| Размер письма | 30 МБ |
| Вложений в письме | 25, до 25 МБ каждое |
| Длина сохраняемого тела | 500 000 символов на часть |
| Писем на домен | 500 в час |
| Маршрутов на домен | по тарифу |
| Хранение | срок хранения журнала по тарифу; письмо, .eml и вложения удаляются вместе |
| Ответ | Когда |
|---|---|
250 | письмо сохранено, вебхук поставлен в очередь |
550 | ни один включённый маршрут не подходит — жёсткий отказ, отправителю сообщается, что адреса не существует |
552 | превышен размер письма |
450 | превышен часовой лимит домена, сервер отправителя повторит попытку |
451 | временная ошибка на нашей стороне, сервер отправителя повторит попытку |
DATA, пришлёт письмо снова, а наш вебхук повторяется при неудаче. Защищайтесь по event_id.Received: отклоняется, а дополнительно защищает часовой лимит. Не стройте автоответчик, который отвечает на адрес маршрута.Auto-Submitted сам по себе не признак петли. Соответствующие заголовки передаются в headers, чтобы ваш код мог отфильтровать их сам.| Запрос | Что делает |
|---|---|
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 — см. раздел об авторизации |