MailInfra SDK для Python
Официальный клиент MailInfra — email API для разработчиков.
pip install mailinfra
from mailinfra import MailInfra
client = MailInfra() # ключ из MAILINFRA_API_KEY
result = client.emails.send(
from_email="hello@mail.example.ru",
to="user@example.com",
subject="Добро пожаловать!",
html="<p>Привет!</p>",
)
print(result.id, result.status)
Требования: Python 3.10+.
Что клиент делает за вас
| Возможность | Подробности |
|---|---|
| Идемпотентность | На каждую отправку — и письма, и батча — ставится Idempotency-Key, и он переиспользуется при повторах — обрыв сети не превращается в два письма |
| Повторы | 429, 408/425, 5xx и таймауты повторяются с экспоненциальным backoff и half-jitter |
| Rate limit | Retry-After уважается как есть; слишком долгий (>60 с) не блокирует поток, а приходит как RateLimitError.retry_after |
| Типизированные ошибки | DomainNotVerifiedError, RateLimitError, UnprocessableError.fields и ещё ~20 классов |
| Локальная валидация | Лимиты API (50 получателей, 10 тегов, 998 символов темы, 10 МиБ вложение, лимиты metadata, диапазон ttl, часовой пояс у send_at) проверяются до сетевого вызова |
| Пагинация | list_all() сам идёт по курсорам и отдаёт письма по одному, запрашивая страницы по мере чтения |
| Вебхуки | mailinfra.webhooks.construct_event() — проверка HMAC-подписи (в том числе двух подписей в окне ротации секрета) и разбор события |
| Типы | Модели pydantic v2, сгенерированные из OpenAPI-спеки MailInfra; пакет типизирован (py.typed) |
Аутентификация
client = MailInfra(api_key="mi_live_…") # явно
client = MailInfra() # из MAILINFRA_API_KEY
client = MailInfra(require_environment="live") # упасть, если подсунули тестовый ключ
mi_live_… отправляет письма по-настоящему. mi_test_… — sandbox: письмо сохраняется
со статусом SIMULATED, не доставляется и не расходует месячный объём плана. Окружение
ключа доступно как client.environment ("live" / "test").
Базовый URL меняется параметром base_url= или переменной MAILINFRA_BASE_URL.
Отправка
По шаблону
client.emails.send(
from_email="no-reply@mail.example.ru",
to=["anna@example.com", "boris@example.com"],
template="welcome", # тема берётся из шаблона
variables={"user_name": "Анна", "app_name": "MyApp"},
tags=["onboarding"],
)
С вложением
SDK сам закодирует байты в base64:
from mailinfra import Attachment
client.emails.send(
from_email="billing@mail.example.ru",
to="user@example.com",
subject="Счёт за май",
text="Счёт во вложении.",
attachments=[
Attachment(
filename="invoice.pdf",
content=open("invoice.pdf", "rb").read(),
content_type="application/pdf",
)
],
)
Inline-картинки
Вложение с content_id встраивается в тело письма, а не прикладывается файлом; в HTML
на него ссылаются как cid:<content_id>:
client.emails.send(
from_email="hello@mail.example.ru",
to="user@example.com",
subject="Добро пожаловать!",
html='<p><img src="cid:logo" alt="MyApp"></p><p>Привет!</p>',
attachments=[
Attachment(
filename="logo.png",
content=open("logo.png", "rb").read(),
content_type="image/png",
content_id="logo",
)
],
)
Свои метаданные
metadata — ваши пары «ключ — значение» (номер заказа, id пользователя). На доставку
они не влияют, а возвращаются в get(), list() и в каждом вебхуке письма — по ним
удобно связать письмо и события со своей системой:
result = client.emails.send(
from_email="billing@mail.example.ru",
to="user@example.com",
template="order_paid",
metadata={"order_id": "4242", "user_id": "u-17"},
)
client.emails.get(result.id).metadata # {"order_id": "4242", "user_id": "u-17"}
Лимиты проверяются до сетевого вызова: не больше 20 ключей; ключ — 1–40 символов
[A-Za-z0-9_.-]; значение — строка до 500 символов без NUL; весь объект — до 4096 байт
JSON в UTF-8.
Отложенная отправка
send_at — когда отправить письмо: datetime с часовым поясом или ISO 8601 строка
со смещением, не дальше 7 дней вперёд. Письмо получает статус SCHEDULED; время в
прошлом — отправить сразу. Время без часового пояса SDK отклоняет до сетевого вызова
(MailInfraError с кодом invalid_request): у него нет однозначного момента.
from datetime import datetime, timedelta, timezone
reminder = client.emails.send(
from_email="billing@mail.example.ru",
to="user@example.com",
template="payment_reminder",
send_at=datetime.now(timezone.utc) + timedelta(days=3),
)
print(reminder.status) # SCHEDULED
# Пользователь оплатил — напоминание больше не нужно; резерв квоты вернётся.
client.emails.cancel(reminder.id)
# …или перенести на другое время.
client.emails.reschedule(reminder.id, send_at="2026-11-01T09:00:00+03:00")
cancel() и reschedule() возвращают EmailScheduleData (id, status,
scheduled_at) и требуют права на отправку. Горизонт переноса — 7 дней от приёма
письма, а не от переноса: дальше — SendAtTooFarError. Письмо, которое уже ушло в
очередь, отправлено или отменено, — EmailNotScheduledError (текущий статус в
details["status"]). Оба вызова SDK повторяет после 5xx и таймаута, и если
EmailNotScheduledError пришёл на повторе, первая попытка, скорее всего, уже
выполнила операцию.
Срок жизни письма
ttl — сколько секунд письмо остаётся актуальным (60..86400), считая от send_at или от
приёма. Не доставленное за это время письмо больше не отправляется и не повторяется:
статус FAILED с причиной expired или soft bounce с категорией delivery-expired.
Подходит для кодов входа и всего, что поздно уже не нужно:
client.emails.send(
from_email="auth@mail.example.ru",
to="user@example.com",
subject="Код входа: 271828",
text="Код действует 10 минут.",
ttl=600,
)
Свой ключ идемпотентности
Если у вас есть естественный идентификатор операции — передайте его: повторный вызов с тем же ключом не создаст второе письмо в течение 24 часов — столько бэкенд хранит ключ. Позже тот же ключ — уже новый запрос.
result = client.emails.send(
from_email="no-reply@mail.example.ru",
to="user@example.com",
template="order_paid",
variables={"order_id": 4242},
idempotency_key="order_4242_paid",
)
if result.replayed:
print("Письмо уже было отправлено раньше")
Пакетная отправка
До 100 писем одним запросом:
batch = client.emails.send_batch(
[
{
"from_email": "no-reply@mail.example.ru",
"to": "a@example.com",
"template": "welcome",
"variables": {"user_name": "Анна"},
},
{
"from_email": "no-reply@mail.example.ru",
"to": "b@example.com",
"template": "welcome",
"variables": {"user_name": "Борис"},
},
]
)
print(batch.accepted_count, batch.failed_count)
for item in batch.results:
if item.error:
print(item.index, item.error.code, item.error.message)
Важно. Батч возвращает
202, даже если часть писем отклонена — проверяйтеerrorу каждого элемента.
Элементы батча принимают те же параметры, что send() (metadata, send_at, ttl,
вложения), кроме idempotency_key: у батча ключ один на весь запрос. SDK ставит его сам
и переиспользует на повторах, а бэкенд выводит из него ключ каждого письма. Поэтому
повтор после 5xx, таймаута или обрыва соединения не дублирует уже принятые письма, а
отклонённые пробует принять заново. Свой ключ (до 252 символов) — когда батч может
быть отправлен повторно уже вашим кодом, например после перезапуска задачи:
batch = client.emails.send_batch(emails, idempotency_key="newsletter_2026_11")
if batch.replayed:
print("Все письма этого батча уже были приняты раньше")
replayed — True, если ни одно письмо не принято заново (ответ 200 с
Idempotent-Replayed: true). С auto_idempotency=False и без своего ключа батч
неидемпотентен, и SDK повторяет его только на 429 — единственном отказе, который
случается до приёма писем.
Чтение
email = client.emails.get(result.id)
print(email.status, email.delivered_at, email.final_smtp_code)
for event in client.emails.events(result.id):
print(event.timestamp, event.type, event.data)
Список писем и пагинация
Письма отдаются страницами, новые сначала. list() возвращает одну страницу —
EmailListResponse с письмами в data, признаком has_more и курсором next_cursor:
page = client.emails.list(status="BOUNCED", tag="onboarding", limit=50)
for email in page.data:
print(email.id, email.status, email.metadata)
if page.has_more:
page = client.emails.list(status="BOUNCED", tag="onboarding", limit=50, cursor=page.next_cursor)
Курсор непрозрачный: передавайте его как есть и с теми же фильтрами. Обойти все страницы
проще через list_all() — он сам идёт по next_cursor, пока has_more не станет
False, и запрашивает следующую страницу, только когда прочитана предыдущая:
for email in client.emails.list_all(status="BOUNCED", limit=100):
mark_bounced(email.id, email.metadata.get("user_id"))
У list_all() те же фильтры, что у list(); limit — размер одной страницы, а не общее
число писем, cursor — с какой страницы начать (например, сохранённый next_cursor).
В асинхронном клиенте это async for email in client.emails.list_all(...).
Фильтры: status, from_email, to_email, tag, from_date, to_date, limit
(1..100), cursor. Enum-поля — подклассы str, поэтому email.status == "DELIVERED"
работает наравне с email.status is EmailStatus.DELIVERED.
Ошибки
from mailinfra import (
DomainNotVerifiedError,
RateLimitError,
UnprocessableError,
MailInfraError,
)
try:
client.emails.send(...)
except DomainNotVerifiedError:
... # домен ещё не подтверждён в DNS
except UnprocessableError as exc:
for field in exc.fields: # [{"field": "to", "message": "…"}]
print(field["field"], field["message"])
except RateLimitError as exc:
print("повторить через", exc.retry_after)
except MailInfraError as exc: # базовый класс всех остальных
print(exc.code, exc.message, exc.request_id)
У любой ошибки есть .code, .message, .details, .status_code и .request_id —
последний стоит логировать: с ним поддержка найдёт запрос в своих логах.
Конкретный класс всегда наследует класс своего HTTP-статуса: DomainNotVerifiedError
— это и UnprocessableError (422), SandboxTrialExhaustedError — и
PermissionDeniedError (403). Ловить можно на любом уровне: MailInfraError,
класс статуса или конкретная причина.
Коды отложенной отправки и доступа: EmailNotScheduledError (409, email_not_scheduled)
— отменить или перенести можно только письмо в статусе SCHEDULED;
SendAtTooFarError и SendAtInvalidError (400, send_at_too_far и send_at_invalid)
— время дальше 7 дней или без часового пояса; IPNotAllowedError (403,
ip_not_allowed) — ключ использован с IP-адреса вне его белого списка.
Неизвестный код ошибки (новый на бэкенде) приходит как класс по HTTP-статусу, а не падает — обновление API не ломает старый SDK.
Настройка повторов
from mailinfra import MailInfra, RetryPolicy
client = MailInfra(
max_retries=4, # быстрый вариант
timeout=10.0,
)
client = MailInfra( # полный контроль
retry_policy=RetryPolicy(
max_retries=3,
initial_backoff=0.5,
backoff_factor=2.0,
max_backoff=8.0,
max_retry_after=60.0, # выше этого не ждём, отдаём RateLimitError
),
)
Отключить авто-идемпотентность: MailInfra(auto_idempotency=False).
Свой HTTP-клиент (прокси, mTLS, пул): MailInfra(http_client=httpx.Client(...)).
Асинхронный клиент
from mailinfra import AsyncMailInfra
async with AsyncMailInfra() as client:
result = await client.emails.send(
from_email="hello@mail.example.ru",
to="user@example.com",
subject="Привет",
text="Привет!",
)
API и поведение совпадают с синхронным клиентом.
Вебхуки
from fastapi import FastAPI, Request
from mailinfra import webhooks
app = FastAPI()
@app.post("/mailinfra/events")
async def handle(request: Request) -> dict[str, str]:
event = webhooks.construct_event(
body=await request.body(), # именно сырые байты
secret=os.environ["MAILINFRA_WEBHOOK_SECRET"],
signature_header=request.headers["X-MailInfra-Signature"],
)
if event.type == "email.bounced":
mark_bounced(event.email_id, event.metadata.get("user_id"), event.data["event"])
return {"status": "ok"}
construct_event проверяет HMAC-SHA256 подпись и её возраст (по умолчанию 5 минут,
как на стороне MailInfra) и только потом разбирает JSON. Нужен просто флаг — есть
webhooks.verify(...) -> bool.
event.metadata — metadata, переданный при отправке письма (пустой словарь у событий
без письма). У отложенных писем есть события email.scheduled и email.canceled.
Ротация секрета не требует одновременного деплоя: пока прежний секрет действует,
MailInfra подписывает событие обоими, и в заголовке две подписи —
t=…,v1=<новым секретом>,v1=<прежним>. SDK принимает событие, если совпала любая
v1, поэтому обработчик со старым секретом продолжает работать, пока вы не выкатите
новый.
Тело обязательно брать до разбора JSON: пересериализованный JSON даст другие байты и подпись не сойдётся.
Что пока не покрыто
v1 покрывает публичный API писем (/v1/emails*) — всё, что доступно по API-ключу
проекта. Управление доменами, шаблонами, списком подавления, вебхуками и ключами — это
Management API на сессионном токене пользователя; для машинной интеграции у него пока
нет подходящего credential, поэтому в SDK этих ресурсов нет. Следите за
CHANGELOG.
Ссылки
- Документация: https://docs.mailinfra.ru
- Формат ошибок: https://docs.mailinfra.ru/errors
- Вебхуки: https://docs.mailinfra.ru/webhooks/signature
- OpenAPI-спека: https://docs.mailinfra.ru/openapi/mailinfra-public.json
- Исходный код и issues: https://github.com/g7AzaZLO/mailinfra-python. Репозиторий обновляется при каждом релизе SDK; ошибки и предложения — в issues, pull request'ы не принимаются: SDK разрабатывается вместе с API MailInfra.
Лицензия
MIT
Metadata
Release files for mailinfra 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| mailinfra-0.2.0.tar.gz | 77.3 kB | With provenance | Details for mailinfra-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| mailinfra-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for mailinfra-0.2.0-py3-none-any.whl |
Total release size: 132.1 kB
Release files / mailinfra-0.2.0.tar.gz
| Download URL | mailinfra-0.2.0.tar.gz |
|---|---|
| Size | 77.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
eb14a1747fc8a2ae86f7931859377b1459089a764b8cac98036954309d31e9a0
|
|
BLAKE2b-256 checksum How to use checksums |
8c5ff2bb131ee10618f2c19237a6b727c31eafdc468fe7ae844aa98edfdc8eb2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.
Transparency logRelease files / mailinfra-0.2.0-py3-none-any.whl
| Download URL | mailinfra-0.2.0-py3-none-any.whl |
|---|---|
| Size | 54.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ba10797b3d4c8190eb1f4cdf925304c10b9a0f55ce6cb53067ee97c4ff8595ed
|
|
BLAKE2b-256 checksum How to use checksums |
a21e6f0192d331cb6dda6483e4bd4b910036bd7335db6958a62eb4cfd262ed1d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.
Transparency logSecurity for mailinfra 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for mailinfra-0.2.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.