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.
- mailinfra-0.1.0/.gitignore +8 -0
- mailinfra-0.1.0/CHANGELOG.md +39 -0
- mailinfra-0.1.0/LICENSE +21 -0
- mailinfra-0.1.0/PKG-INFO +307 -0
- mailinfra-0.1.0/README.md +280 -0
- mailinfra-0.1.0/pyproject.toml +113 -0
- mailinfra-0.1.0/src/mailinfra/__init__.py +124 -0
- mailinfra-0.1.0/src/mailinfra/_errors.py +343 -0
- mailinfra-0.1.0/src/mailinfra/_generated/__init__.py +1 -0
- mailinfra-0.1.0/src/mailinfra/_generated/models.py +288 -0
- mailinfra-0.1.0/src/mailinfra/_transport.py +194 -0
- mailinfra-0.1.0/src/mailinfra/_version.py +11 -0
- mailinfra-0.1.0/src/mailinfra/client.py +657 -0
- mailinfra-0.1.0/src/mailinfra/models.py +146 -0
- mailinfra-0.1.0/src/mailinfra/py.typed +0 -0
- mailinfra-0.1.0/src/mailinfra/resources/__init__.py +13 -0
- mailinfra-0.1.0/src/mailinfra/resources/emails.py +882 -0
- mailinfra-0.1.0/src/mailinfra/webhooks.py +307 -0
- mailinfra-0.1.0/tests/__init__.py +1 -0
- mailinfra-0.1.0/tests/conftest.py +127 -0
- mailinfra-0.1.0/tests/test_emails.py +619 -0
- mailinfra-0.1.0/tests/test_errors.py +250 -0
- mailinfra-0.1.0/tests/test_operation_coverage.py +166 -0
- mailinfra-0.1.0/tests/test_retries.py +434 -0
- mailinfra-0.1.0/tests/test_spec_parity.py +260 -0
- mailinfra-0.1.0/tests/test_transport.py +130 -0
- mailinfra-0.1.0/tests/test_webhooks.py +226 -0
|
@@ -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.
|
mailinfra-0.1.0/LICENSE
ADDED
|
@@ -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.
|
mailinfra-0.1.0/PKG-INFO
ADDED
|
@@ -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
|