mailinfra 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ # Файлы сборки и окружения — в репозиторий SDK не коммитятся.
2
+ .venv/
3
+ dist/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .mypy_cache/
8
+ uv.lock
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
4
+ версионирование — [SemVer](https://semver.org/lang/ru/).
5
+
6
+ До `1.0.0` минорные версии могут содержать несовместимые изменения: пока не покрыт
7
+ Management API, публичная поверхность SDK ещё расширяется.
8
+
9
+ ## [0.1.0] — 2026-10-27
10
+
11
+ Первый релиз.
12
+
13
+ ### Добавлено
14
+
15
+ - Клиенты `MailInfra` и `AsyncMailInfra` поверх httpx.
16
+ - Ресурс писем: `send`, `send_batch`, `get`, `list`, `events`.
17
+ - Автоматический `Idempotency-Key` на отправке, переиспользуемый при повторах;
18
+ флаг `replayed` в результате для идемпотентных повторов.
19
+ - Повторы с экспоненциальным backoff и half-jitter, уважение `Retry-After`,
20
+ раздельная политика для идемпотентных и неидемпотентных запросов.
21
+ - Типизированные исключения по HTTP-статусу и по `error.code`; неизвестный код
22
+ деградирует до класса по статусу.
23
+ - Локальная валидация письма по OpenAPI-спеке (лимиты получателей, тегов, темы,
24
+ вложений) до сетевого вызова. Нарушение приходит как `MailInfraError` с кодом
25
+ `invalid_request`, а не как `pydantic.ValidationError`: README учит ловить
26
+ первое, и голый `ValidationError` пролетал бы мимо такого `except`.
27
+ - `Idempotency-Key` принимается только как печатаемый ASCII: httpx кодирует
28
+ значение заголовка в ascii и на кириллице падает `UnicodeEncodeError` —
29
+ голым исключением мимо `MailInfraError`. CR и LF отбиваются заодно.
30
+ - Модели pydantic v2, сгенерированные из <https://docs.mailinfra.ru/openapi/mailinfra-public.json>.
31
+ - `mailinfra.webhooks`: `verify`, `construct_event`, `sign` — проверка HMAC-подписи
32
+ с защитой от replay.
33
+ - Определение окружения по префиксу ключа (`mi_live_` / `mi_test_`) и опция
34
+ `require_environment`.
35
+
36
+ ### Не входит
37
+
38
+ - Management API (домены, шаблоны, список подавления, вебхуки, API-ключи) — у него
39
+ нет машинной аутентификации, см. README.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MailInfra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,307 @@
1
+ Metadata-Version: 2.5
2
+ Name: mailinfra
3
+ Version: 0.1.0
4
+ Summary: Официальный Python SDK для MailInfra — email API для разработчиков
5
+ Project-URL: Homepage, https://mailinfra.ru
6
+ Project-URL: Documentation, https://docs.mailinfra.ru
7
+ Project-URL: Repository, https://github.com/g7AzaZLO/mailinfra-python
8
+ Project-URL: Changelog, https://github.com/g7AzaZLO/mailinfra-python/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/g7AzaZLO/mailinfra-python/issues
10
+ Author: MailInfra
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: api-client,email,mailinfra,smtp,transactional-email
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Communications :: Email
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: pydantic[email]>=2.7
26
+ Description-Content-Type: text/markdown
27
+
28
+ # MailInfra SDK для Python
29
+
30
+ Официальный клиент [MailInfra](https://mailinfra.ru) — email API для разработчиков.
31
+
32
+ ```bash
33
+ pip install mailinfra
34
+ ```
35
+
36
+ ```python
37
+ from mailinfra import MailInfra
38
+
39
+ client = MailInfra() # ключ из MAILINFRA_API_KEY
40
+
41
+ result = client.emails.send(
42
+ from_email="hello@mail.example.ru",
43
+ to="user@example.com",
44
+ subject="Добро пожаловать!",
45
+ html="<p>Привет!</p>",
46
+ )
47
+ print(result.id, result.status)
48
+ ```
49
+
50
+ Требования: Python 3.10+.
51
+
52
+ ## Что клиент делает за вас
53
+
54
+ | Возможность | Подробности |
55
+ |---|---|
56
+ | Идемпотентность | На каждую отправку ставится `Idempotency-Key`, и он **переиспользуется при повторах** — обрыв сети не превращается в два письма |
57
+ | Повторы | `429`, `408/425`, `5xx` и таймауты повторяются с экспоненциальным backoff и half-jitter |
58
+ | Rate limit | `Retry-After` уважается как есть; слишком долгий (>60 с) не блокирует поток, а приходит как `RateLimitError.retry_after` |
59
+ | Типизированные ошибки | `DomainNotVerifiedError`, `RateLimitError`, `UnprocessableError.fields` и ещё ~20 классов |
60
+ | Локальная валидация | Лимиты API (50 получателей, 10 тегов, 998 символов темы, 10 МиБ вложение) проверяются до сетевого вызова |
61
+ | Вебхуки | `mailinfra.webhooks.construct_event()` — проверка HMAC-подписи и разбор события |
62
+ | Типы | Модели pydantic v2, сгенерированные из OpenAPI-спеки MailInfra; пакет типизирован (`py.typed`) |
63
+
64
+ ## Аутентификация
65
+
66
+ ```python
67
+ client = MailInfra(api_key="mi_live_…") # явно
68
+ client = MailInfra() # из MAILINFRA_API_KEY
69
+ client = MailInfra(require_environment="live") # упасть, если подсунули тестовый ключ
70
+ ```
71
+
72
+ `mi_live_…` отправляет письма по-настоящему. `mi_test_…` — sandbox: письмо сохраняется
73
+ со статусом `SIMULATED`, не доставляется и не расходует месячный объём плана. Окружение
74
+ ключа доступно как `client.environment` (`"live"` / `"test"`).
75
+
76
+ Базовый URL меняется параметром `base_url=` или переменной `MAILINFRA_BASE_URL`.
77
+
78
+ ## Отправка
79
+
80
+ ### По шаблону
81
+
82
+ ```python
83
+ client.emails.send(
84
+ from_email="no-reply@mail.example.ru",
85
+ to=["anna@example.com", "boris@example.com"],
86
+ template="welcome", # тема берётся из шаблона
87
+ variables={"user_name": "Анна", "app_name": "MyApp"},
88
+ tags=["onboarding"],
89
+ )
90
+ ```
91
+
92
+ ### С вложением
93
+
94
+ SDK сам закодирует байты в base64:
95
+
96
+ ```python
97
+ from mailinfra import Attachment
98
+
99
+ client.emails.send(
100
+ from_email="billing@mail.example.ru",
101
+ to="user@example.com",
102
+ subject="Счёт за май",
103
+ text="Счёт во вложении.",
104
+ attachments=[
105
+ Attachment(
106
+ filename="invoice.pdf",
107
+ content=open("invoice.pdf", "rb").read(),
108
+ content_type="application/pdf",
109
+ )
110
+ ],
111
+ )
112
+ ```
113
+
114
+ ### Свой ключ идемпотентности
115
+
116
+ Если у вас есть естественный идентификатор операции — передайте его: повторный вызов
117
+ с тем же ключом не создаст второе письмо в течение 24 часов — столько бэкенд хранит
118
+ ключ. Позже тот же ключ — уже новый запрос.
119
+
120
+ ```python
121
+ result = client.emails.send(
122
+ from_email="no-reply@mail.example.ru",
123
+ to="user@example.com",
124
+ template="order_paid",
125
+ variables={"order_id": 4242},
126
+ idempotency_key="order_4242_paid",
127
+ )
128
+ if result.replayed:
129
+ print("Письмо уже было отправлено раньше")
130
+ ```
131
+
132
+ ### Пакетная отправка
133
+
134
+ До 100 писем одним запросом:
135
+
136
+ ```python
137
+ batch = client.emails.send_batch(
138
+ [
139
+ {
140
+ "from_email": "no-reply@mail.example.ru",
141
+ "to": "a@example.com",
142
+ "template": "welcome",
143
+ "variables": {"user_name": "Анна"},
144
+ },
145
+ {
146
+ "from_email": "no-reply@mail.example.ru",
147
+ "to": "b@example.com",
148
+ "template": "welcome",
149
+ "variables": {"user_name": "Борис"},
150
+ },
151
+ ]
152
+ )
153
+
154
+ print(batch.accepted_count, batch.failed_count)
155
+ for item in batch.results:
156
+ if item.error:
157
+ print(item.index, item.error.code, item.error.message)
158
+ ```
159
+
160
+ > **Важно.** Батч возвращает `202`, даже если часть писем отклонена — проверяйте
161
+ > `error` у каждого элемента. `Idempotency-Key` этот эндпоинт не принимает, поэтому
162
+ > SDK **не** повторяет его после `5xx`: повтор мог бы отправить часть писем дважды.
163
+ > Повторяется только `429` — единственный отказ, который случается до приёма
164
+ > писем. Обрыв соединения и таймаут батча **не** повторяются: сервер мог уже
165
+ > закоммитить часть писем, поэтому решение о повторе за вами.
166
+
167
+ ## Чтение
168
+
169
+ ```python
170
+ email = client.emails.get(result.id)
171
+ print(email.status, email.delivered_at, email.final_smtp_code)
172
+
173
+ for event in client.emails.events(result.id):
174
+ print(event.timestamp, event.type, event.data)
175
+
176
+ recent = client.emails.list(status="BOUNCED", tag="onboarding", limit=50)
177
+ ```
178
+
179
+ Фильтры `list()`: `status`, `from_email`, `to_email`, `tag`, `from_date`, `to_date`,
180
+ `limit` (1..100). Enum-поля — подклассы `str`, поэтому `email.status == "DELIVERED"`
181
+ работает наравне с `email.status is EmailStatus.DELIVERED`.
182
+
183
+ ## Ошибки
184
+
185
+ ```python
186
+ from mailinfra import (
187
+ DomainNotVerifiedError,
188
+ RateLimitError,
189
+ UnprocessableError,
190
+ MailInfraError,
191
+ )
192
+
193
+ try:
194
+ client.emails.send(...)
195
+ except DomainNotVerifiedError:
196
+ ... # домен ещё не подтверждён в DNS
197
+ except UnprocessableError as exc:
198
+ for field in exc.fields: # [{"field": "to", "message": "…"}]
199
+ print(field["field"], field["message"])
200
+ except RateLimitError as exc:
201
+ print("повторить через", exc.retry_after)
202
+ except MailInfraError as exc: # базовый класс всех остальных
203
+ print(exc.code, exc.message, exc.request_id)
204
+ ```
205
+
206
+ У любой ошибки есть `.code`, `.message`, `.details`, `.status_code` и `.request_id` —
207
+ последний стоит логировать: с ним поддержка найдёт запрос в своих логах.
208
+
209
+ Конкретный класс всегда наследует класс своего HTTP-статуса: `DomainNotVerifiedError`
210
+ — это и `UnprocessableError` (422), `SandboxTrialExhaustedError` — и
211
+ `PermissionDeniedError` (403). Ловить можно на любом уровне: `MailInfraError`,
212
+ класс статуса или конкретная причина.
213
+
214
+ Неизвестный код ошибки (новый на бэкенде) приходит как класс по HTTP-статусу, а не
215
+ падает — обновление API не ломает старый SDK.
216
+
217
+ ## Настройка повторов
218
+
219
+ ```python
220
+ from mailinfra import MailInfra, RetryPolicy
221
+
222
+ client = MailInfra(
223
+ max_retries=4, # быстрый вариант
224
+ timeout=10.0,
225
+ )
226
+
227
+ client = MailInfra( # полный контроль
228
+ retry_policy=RetryPolicy(
229
+ max_retries=3,
230
+ initial_backoff=0.5,
231
+ backoff_factor=2.0,
232
+ max_backoff=8.0,
233
+ max_retry_after=60.0, # выше этого не ждём, отдаём RateLimitError
234
+ ),
235
+ )
236
+ ```
237
+
238
+ Отключить авто-идемпотентность: `MailInfra(auto_idempotency=False)`.
239
+ Свой HTTP-клиент (прокси, mTLS, пул): `MailInfra(http_client=httpx.Client(...))`.
240
+
241
+ ## Асинхронный клиент
242
+
243
+ ```python
244
+ from mailinfra import AsyncMailInfra
245
+
246
+ async with AsyncMailInfra() as client:
247
+ result = await client.emails.send(
248
+ from_email="hello@mail.example.ru",
249
+ to="user@example.com",
250
+ subject="Привет",
251
+ text="Привет!",
252
+ )
253
+ ```
254
+
255
+ API и поведение совпадают с синхронным клиентом.
256
+
257
+ ## Вебхуки
258
+
259
+ ```python
260
+ from fastapi import FastAPI, Request
261
+ from mailinfra import webhooks
262
+
263
+ app = FastAPI()
264
+
265
+
266
+ @app.post("/mailinfra/events")
267
+ async def handle(request: Request) -> dict[str, str]:
268
+ event = webhooks.construct_event(
269
+ body=await request.body(), # именно сырые байты
270
+ secret=os.environ["MAILINFRA_WEBHOOK_SECRET"],
271
+ signature_header=request.headers["X-MailInfra-Signature"],
272
+ )
273
+
274
+ if event.type == "email.bounced":
275
+ mark_bounced(event.email_id, event.data["event"])
276
+
277
+ return {"status": "ok"}
278
+ ```
279
+
280
+ `construct_event` проверяет HMAC-SHA256 подпись и её возраст (по умолчанию 5 минут,
281
+ как на стороне MailInfra) и только потом разбирает JSON. Нужен просто флаг — есть
282
+ `webhooks.verify(...) -> bool`.
283
+
284
+ Тело обязательно брать **до** разбора JSON: пересериализованный JSON даст другие байты
285
+ и подпись не сойдётся.
286
+
287
+ ## Что пока не покрыто
288
+
289
+ v1 покрывает публичный API писем (`/v1/emails*`) — всё, что доступно по API-ключу
290
+ проекта. Управление доменами, шаблонами, списком подавления, вебхуками и ключами — это
291
+ Management API на сессионном токене пользователя; для машинной интеграции у него пока
292
+ нет подходящего credential, поэтому в SDK этих ресурсов нет. Следите за
293
+ [CHANGELOG](CHANGELOG.md).
294
+
295
+ ## Ссылки
296
+
297
+ - Документация: <https://docs.mailinfra.ru>
298
+ - Формат ошибок: <https://docs.mailinfra.ru/errors>
299
+ - Вебхуки: <https://docs.mailinfra.ru/webhooks/signature>
300
+ - OpenAPI-спека: <https://docs.mailinfra.ru/openapi/mailinfra-public.json>
301
+ - Исходный код и issues: <https://github.com/g7AzaZLO/mailinfra-python>. Репозиторий
302
+ обновляется при каждом релизе SDK; ошибки и предложения — в issues, pull request'ы
303
+ не принимаются: SDK разрабатывается вместе с API MailInfra.
304
+
305
+ ## Лицензия
306
+
307
+ MIT
@@ -0,0 +1,280 @@
1
+ # MailInfra SDK для Python
2
+
3
+ Официальный клиент [MailInfra](https://mailinfra.ru) — email API для разработчиков.
4
+
5
+ ```bash
6
+ pip install mailinfra
7
+ ```
8
+
9
+ ```python
10
+ from mailinfra import MailInfra
11
+
12
+ client = MailInfra() # ключ из MAILINFRA_API_KEY
13
+
14
+ result = client.emails.send(
15
+ from_email="hello@mail.example.ru",
16
+ to="user@example.com",
17
+ subject="Добро пожаловать!",
18
+ html="<p>Привет!</p>",
19
+ )
20
+ print(result.id, result.status)
21
+ ```
22
+
23
+ Требования: Python 3.10+.
24
+
25
+ ## Что клиент делает за вас
26
+
27
+ | Возможность | Подробности |
28
+ |---|---|
29
+ | Идемпотентность | На каждую отправку ставится `Idempotency-Key`, и он **переиспользуется при повторах** — обрыв сети не превращается в два письма |
30
+ | Повторы | `429`, `408/425`, `5xx` и таймауты повторяются с экспоненциальным backoff и half-jitter |
31
+ | Rate limit | `Retry-After` уважается как есть; слишком долгий (>60 с) не блокирует поток, а приходит как `RateLimitError.retry_after` |
32
+ | Типизированные ошибки | `DomainNotVerifiedError`, `RateLimitError`, `UnprocessableError.fields` и ещё ~20 классов |
33
+ | Локальная валидация | Лимиты API (50 получателей, 10 тегов, 998 символов темы, 10 МиБ вложение) проверяются до сетевого вызова |
34
+ | Вебхуки | `mailinfra.webhooks.construct_event()` — проверка HMAC-подписи и разбор события |
35
+ | Типы | Модели pydantic v2, сгенерированные из OpenAPI-спеки MailInfra; пакет типизирован (`py.typed`) |
36
+
37
+ ## Аутентификация
38
+
39
+ ```python
40
+ client = MailInfra(api_key="mi_live_…") # явно
41
+ client = MailInfra() # из MAILINFRA_API_KEY
42
+ client = MailInfra(require_environment="live") # упасть, если подсунули тестовый ключ
43
+ ```
44
+
45
+ `mi_live_…` отправляет письма по-настоящему. `mi_test_…` — sandbox: письмо сохраняется
46
+ со статусом `SIMULATED`, не доставляется и не расходует месячный объём плана. Окружение
47
+ ключа доступно как `client.environment` (`"live"` / `"test"`).
48
+
49
+ Базовый URL меняется параметром `base_url=` или переменной `MAILINFRA_BASE_URL`.
50
+
51
+ ## Отправка
52
+
53
+ ### По шаблону
54
+
55
+ ```python
56
+ client.emails.send(
57
+ from_email="no-reply@mail.example.ru",
58
+ to=["anna@example.com", "boris@example.com"],
59
+ template="welcome", # тема берётся из шаблона
60
+ variables={"user_name": "Анна", "app_name": "MyApp"},
61
+ tags=["onboarding"],
62
+ )
63
+ ```
64
+
65
+ ### С вложением
66
+
67
+ SDK сам закодирует байты в base64:
68
+
69
+ ```python
70
+ from mailinfra import Attachment
71
+
72
+ client.emails.send(
73
+ from_email="billing@mail.example.ru",
74
+ to="user@example.com",
75
+ subject="Счёт за май",
76
+ text="Счёт во вложении.",
77
+ attachments=[
78
+ Attachment(
79
+ filename="invoice.pdf",
80
+ content=open("invoice.pdf", "rb").read(),
81
+ content_type="application/pdf",
82
+ )
83
+ ],
84
+ )
85
+ ```
86
+
87
+ ### Свой ключ идемпотентности
88
+
89
+ Если у вас есть естественный идентификатор операции — передайте его: повторный вызов
90
+ с тем же ключом не создаст второе письмо в течение 24 часов — столько бэкенд хранит
91
+ ключ. Позже тот же ключ — уже новый запрос.
92
+
93
+ ```python
94
+ result = client.emails.send(
95
+ from_email="no-reply@mail.example.ru",
96
+ to="user@example.com",
97
+ template="order_paid",
98
+ variables={"order_id": 4242},
99
+ idempotency_key="order_4242_paid",
100
+ )
101
+ if result.replayed:
102
+ print("Письмо уже было отправлено раньше")
103
+ ```
104
+
105
+ ### Пакетная отправка
106
+
107
+ До 100 писем одним запросом:
108
+
109
+ ```python
110
+ batch = client.emails.send_batch(
111
+ [
112
+ {
113
+ "from_email": "no-reply@mail.example.ru",
114
+ "to": "a@example.com",
115
+ "template": "welcome",
116
+ "variables": {"user_name": "Анна"},
117
+ },
118
+ {
119
+ "from_email": "no-reply@mail.example.ru",
120
+ "to": "b@example.com",
121
+ "template": "welcome",
122
+ "variables": {"user_name": "Борис"},
123
+ },
124
+ ]
125
+ )
126
+
127
+ print(batch.accepted_count, batch.failed_count)
128
+ for item in batch.results:
129
+ if item.error:
130
+ print(item.index, item.error.code, item.error.message)
131
+ ```
132
+
133
+ > **Важно.** Батч возвращает `202`, даже если часть писем отклонена — проверяйте
134
+ > `error` у каждого элемента. `Idempotency-Key` этот эндпоинт не принимает, поэтому
135
+ > SDK **не** повторяет его после `5xx`: повтор мог бы отправить часть писем дважды.
136
+ > Повторяется только `429` — единственный отказ, который случается до приёма
137
+ > писем. Обрыв соединения и таймаут батча **не** повторяются: сервер мог уже
138
+ > закоммитить часть писем, поэтому решение о повторе за вами.
139
+
140
+ ## Чтение
141
+
142
+ ```python
143
+ email = client.emails.get(result.id)
144
+ print(email.status, email.delivered_at, email.final_smtp_code)
145
+
146
+ for event in client.emails.events(result.id):
147
+ print(event.timestamp, event.type, event.data)
148
+
149
+ recent = client.emails.list(status="BOUNCED", tag="onboarding", limit=50)
150
+ ```
151
+
152
+ Фильтры `list()`: `status`, `from_email`, `to_email`, `tag`, `from_date`, `to_date`,
153
+ `limit` (1..100). Enum-поля — подклассы `str`, поэтому `email.status == "DELIVERED"`
154
+ работает наравне с `email.status is EmailStatus.DELIVERED`.
155
+
156
+ ## Ошибки
157
+
158
+ ```python
159
+ from mailinfra import (
160
+ DomainNotVerifiedError,
161
+ RateLimitError,
162
+ UnprocessableError,
163
+ MailInfraError,
164
+ )
165
+
166
+ try:
167
+ client.emails.send(...)
168
+ except DomainNotVerifiedError:
169
+ ... # домен ещё не подтверждён в DNS
170
+ except UnprocessableError as exc:
171
+ for field in exc.fields: # [{"field": "to", "message": "…"}]
172
+ print(field["field"], field["message"])
173
+ except RateLimitError as exc:
174
+ print("повторить через", exc.retry_after)
175
+ except MailInfraError as exc: # базовый класс всех остальных
176
+ print(exc.code, exc.message, exc.request_id)
177
+ ```
178
+
179
+ У любой ошибки есть `.code`, `.message`, `.details`, `.status_code` и `.request_id` —
180
+ последний стоит логировать: с ним поддержка найдёт запрос в своих логах.
181
+
182
+ Конкретный класс всегда наследует класс своего HTTP-статуса: `DomainNotVerifiedError`
183
+ — это и `UnprocessableError` (422), `SandboxTrialExhaustedError` — и
184
+ `PermissionDeniedError` (403). Ловить можно на любом уровне: `MailInfraError`,
185
+ класс статуса или конкретная причина.
186
+
187
+ Неизвестный код ошибки (новый на бэкенде) приходит как класс по HTTP-статусу, а не
188
+ падает — обновление API не ломает старый SDK.
189
+
190
+ ## Настройка повторов
191
+
192
+ ```python
193
+ from mailinfra import MailInfra, RetryPolicy
194
+
195
+ client = MailInfra(
196
+ max_retries=4, # быстрый вариант
197
+ timeout=10.0,
198
+ )
199
+
200
+ client = MailInfra( # полный контроль
201
+ retry_policy=RetryPolicy(
202
+ max_retries=3,
203
+ initial_backoff=0.5,
204
+ backoff_factor=2.0,
205
+ max_backoff=8.0,
206
+ max_retry_after=60.0, # выше этого не ждём, отдаём RateLimitError
207
+ ),
208
+ )
209
+ ```
210
+
211
+ Отключить авто-идемпотентность: `MailInfra(auto_idempotency=False)`.
212
+ Свой HTTP-клиент (прокси, mTLS, пул): `MailInfra(http_client=httpx.Client(...))`.
213
+
214
+ ## Асинхронный клиент
215
+
216
+ ```python
217
+ from mailinfra import AsyncMailInfra
218
+
219
+ async with AsyncMailInfra() as client:
220
+ result = await client.emails.send(
221
+ from_email="hello@mail.example.ru",
222
+ to="user@example.com",
223
+ subject="Привет",
224
+ text="Привет!",
225
+ )
226
+ ```
227
+
228
+ API и поведение совпадают с синхронным клиентом.
229
+
230
+ ## Вебхуки
231
+
232
+ ```python
233
+ from fastapi import FastAPI, Request
234
+ from mailinfra import webhooks
235
+
236
+ app = FastAPI()
237
+
238
+
239
+ @app.post("/mailinfra/events")
240
+ async def handle(request: Request) -> dict[str, str]:
241
+ event = webhooks.construct_event(
242
+ body=await request.body(), # именно сырые байты
243
+ secret=os.environ["MAILINFRA_WEBHOOK_SECRET"],
244
+ signature_header=request.headers["X-MailInfra-Signature"],
245
+ )
246
+
247
+ if event.type == "email.bounced":
248
+ mark_bounced(event.email_id, event.data["event"])
249
+
250
+ return {"status": "ok"}
251
+ ```
252
+
253
+ `construct_event` проверяет HMAC-SHA256 подпись и её возраст (по умолчанию 5 минут,
254
+ как на стороне MailInfra) и только потом разбирает JSON. Нужен просто флаг — есть
255
+ `webhooks.verify(...) -> bool`.
256
+
257
+ Тело обязательно брать **до** разбора JSON: пересериализованный JSON даст другие байты
258
+ и подпись не сойдётся.
259
+
260
+ ## Что пока не покрыто
261
+
262
+ v1 покрывает публичный API писем (`/v1/emails*`) — всё, что доступно по API-ключу
263
+ проекта. Управление доменами, шаблонами, списком подавления, вебхуками и ключами — это
264
+ Management API на сессионном токене пользователя; для машинной интеграции у него пока
265
+ нет подходящего credential, поэтому в SDK этих ресурсов нет. Следите за
266
+ [CHANGELOG](CHANGELOG.md).
267
+
268
+ ## Ссылки
269
+
270
+ - Документация: <https://docs.mailinfra.ru>
271
+ - Формат ошибок: <https://docs.mailinfra.ru/errors>
272
+ - Вебхуки: <https://docs.mailinfra.ru/webhooks/signature>
273
+ - OpenAPI-спека: <https://docs.mailinfra.ru/openapi/mailinfra-public.json>
274
+ - Исходный код и issues: <https://github.com/g7AzaZLO/mailinfra-python>. Репозиторий
275
+ обновляется при каждом релизе SDK; ошибки и предложения — в issues, pull request'ы
276
+ не принимаются: SDK разрабатывается вместе с API MailInfra.
277
+
278
+ ## Лицензия
279
+
280
+ MIT