mailganer 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.
- mailganer-0.1.0/LICENSE +21 -0
- mailganer-0.1.0/PKG-INFO +146 -0
- mailganer-0.1.0/README.md +127 -0
- mailganer-0.1.0/pyproject.toml +35 -0
- mailganer-0.1.0/setup.cfg +4 -0
- mailganer-0.1.0/src/mailganer/__init__.py +28 -0
- mailganer-0.1.0/src/mailganer/_http.py +186 -0
- mailganer-0.1.0/src/mailganer/_version.py +1 -0
- mailganer-0.1.0/src/mailganer/client.py +50 -0
- mailganer-0.1.0/src/mailganer/errors.py +70 -0
- mailganer-0.1.0/src/mailganer/py.typed +0 -0
- mailganer-0.1.0/src/mailganer/resources.py +441 -0
- mailganer-0.1.0/src/mailganer/webhooks.py +68 -0
- mailganer-0.1.0/src/mailganer.egg-info/PKG-INFO +146 -0
- mailganer-0.1.0/src/mailganer.egg-info/SOURCES.txt +16 -0
- mailganer-0.1.0/src/mailganer.egg-info/dependency_links.txt +1 -0
- mailganer-0.1.0/src/mailganer.egg-info/top_level.txt +1 -0
- mailganer-0.1.0/tests/test_client.py +283 -0
mailganer-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mailganer
|
|
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.
|
mailganer-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mailganer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Официальный клиент REST API Mailganer: подписчики, списки, рассылки, триггеры, отчёты
|
|
5
|
+
Author-email: Mailganer <api@mailganer.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Documentation, https://documentation.mailganer.com/
|
|
8
|
+
Project-URL: API Reference, https://documentation.mailganer.com/reference/
|
|
9
|
+
Project-URL: Homepage, https://mailganer.com
|
|
10
|
+
Keywords: mailganer,email,рассылки,api,email-marketing
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Natural Language :: Russian
|
|
14
|
+
Classifier: Topic :: Communications :: Email
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# mailganer
|
|
21
|
+
|
|
22
|
+
Официальный клиент [REST API Mailganer](https://documentation.mailganer.com/) для Python.
|
|
23
|
+
Версия **0.1.0**, лицензия MIT, Python 3.8+, без внешних зависимостей.
|
|
24
|
+
|
|
25
|
+
## Установка
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install mailganer
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Ключ API
|
|
32
|
+
|
|
33
|
+
Ключ лежит в личном кабинете, раздел [Ключ API](https://mailganer.com/app/profile/settings/api/).
|
|
34
|
+
Передайте его в клиент или положите в переменную окружения `MAILGANER_API_KEY`.
|
|
35
|
+
Вставляйте только сам ключ: заголовок `Authorization: CodeRequest ...` клиент соберёт сам,
|
|
36
|
+
а в v1-методах подставит ключ в тело запроса.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from mailganer import Mailganer
|
|
40
|
+
|
|
41
|
+
mg = Mailganer(api_key="ваш_ключ") # или Mailganer() с MAILGANER_API_KEY
|
|
42
|
+
print(mg.users.balance()) # {'balance': 954.9}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Подписчики
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
# добавить одного
|
|
49
|
+
mg.subscribers.add("user@example.com", source=49767, name="Иван", user_vars={"city": "Москва"})
|
|
50
|
+
|
|
51
|
+
# до 50 за запрос; если одна запись с ошибкой, не добавится ни одна
|
|
52
|
+
mg.subscribers.add_many([
|
|
53
|
+
{"email": "a@example.com", "source": 49767, "name": "Аня"},
|
|
54
|
+
{"email": "b@example.com", "source": 49767, "name": "Боря"},
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
# v3: подробный ответ по каждой записи, частичный успех не ошибка
|
|
58
|
+
result = mg.subscribers.add_many_v3(rows)
|
|
59
|
+
for row in result["failed"]:
|
|
60
|
+
print(row["email"], row["errors"])
|
|
61
|
+
|
|
62
|
+
# найти и обновить
|
|
63
|
+
page = mg.subscribers.list(email="user@example.com") # одна страница, 25 записей
|
|
64
|
+
for sub in mg.subscribers.list_all(source=49767): # все страницы подряд
|
|
65
|
+
...
|
|
66
|
+
mg.subscribers.update(mg_hash, name="Пётр") # по mg_hash
|
|
67
|
+
mg.subscribers.update_by_email("user@example.com", 49767, city="Казань")
|
|
68
|
+
mg.subscribers.unsubscribe(mg_hash)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Рассылки и триггеры
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
draft = mg.mailings.create(name="Акция", theme="Скидка 20%", segment_id=123456,
|
|
75
|
+
from_email="news@example.com", from_name="Магазин",
|
|
76
|
+
html_content="<html>...</html>")
|
|
77
|
+
mg.mailings.send(draft["public_id"], datetime_send="01.10.2026 10:00")
|
|
78
|
+
mg.mailings.stats(draft["public_id"], start_date="2026-10-01", end_date="2026-10-07")
|
|
79
|
+
|
|
80
|
+
mg.triggers.send(113292, "user@example.com", variables={"order_id": 555})
|
|
81
|
+
mg.segments.send_trigger(245611, trigger_id=145674, ignore_trigger_conditions=True)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Отчёты
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
task = mg.reports.create("csv", site_id=49767)
|
|
88
|
+
mg.reports.wait(task["id"]) # ждёт state=COMPLETED
|
|
89
|
+
open("list.csv", "wb").write(mg.reports.download(task["id"]))
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Вебхуки
|
|
93
|
+
|
|
94
|
+
Mailganer шлёт события на URL, заданный `mg.lists.set_webhook(list_id, url)`.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from mailganer import parse_webhook
|
|
98
|
+
|
|
99
|
+
@app.post("/mailganer-hook")
|
|
100
|
+
def hook(request):
|
|
101
|
+
event = parse_webhook(request.body)
|
|
102
|
+
if event["event"] == "mail_interaction" and event["action_name"] == "clicked":
|
|
103
|
+
...
|
|
104
|
+
return "", 200
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`event`: `subscriber_status`, `subscriber_data`, `mail_interaction` (с `action_name`: delivered, opened,
|
|
108
|
+
clicked, unsubscribed, spam_complaint, not_delivered), `trigger_event` (с `user_vars`).
|
|
109
|
+
|
|
110
|
+
## Постбек
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from mailganer import postback_url
|
|
114
|
+
|
|
115
|
+
# ссылка для письма: {{public_id}} и {{email_hash}} подставит Mailganer при отправке
|
|
116
|
+
link = postback_url("https://shop.ru/order/done", nps=10)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Ошибки и лимиты
|
|
120
|
+
|
|
121
|
+
Все ошибки наследуют `MailganerError`: `AuthenticationError` (401), `PermissionDeniedError` (403),
|
|
122
|
+
`NotFoundError` (404), `ValidationError` (400, подробности по полям в `.errors`), `RateLimitError`,
|
|
123
|
+
`ServerError`. v1-методы, которые сообщают об ошибке в теле с HTTP 200, тоже дают исключение.
|
|
124
|
+
|
|
125
|
+
При 429, 503 и `throttling` клиент сам ждёт и повторяет запрос (по умолчанию 2 раза, `max_retries`).
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from mailganer import ValidationError
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
mg.templates.create(name="Письмо", html="")
|
|
132
|
+
except ValidationError as e:
|
|
133
|
+
print(e.errors) # {'html': ['Это поле обязательно.']}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Все разделы
|
|
137
|
+
|
|
138
|
+
`subscribers`, `lists`, `segments`, `variables`, `mailings`, `triggers`, `recommendations`, `users`, `tags`,
|
|
139
|
+
`templates`, `reports`, `postback`. Имена полей совпадают с [справочником API](https://documentation.mailganer.com/reference/).
|
|
140
|
+
Метода нет в библиотеке — `mg.request("GET", "/api/v2/...")`.
|
|
141
|
+
|
|
142
|
+
## Разработка
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
python -m unittest discover -s tests # тесты без обращения к API
|
|
146
|
+
```
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# mailganer
|
|
2
|
+
|
|
3
|
+
Официальный клиент [REST API Mailganer](https://documentation.mailganer.com/) для Python.
|
|
4
|
+
Версия **0.1.0**, лицензия MIT, Python 3.8+, без внешних зависимостей.
|
|
5
|
+
|
|
6
|
+
## Установка
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install mailganer
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Ключ API
|
|
13
|
+
|
|
14
|
+
Ключ лежит в личном кабинете, раздел [Ключ API](https://mailganer.com/app/profile/settings/api/).
|
|
15
|
+
Передайте его в клиент или положите в переменную окружения `MAILGANER_API_KEY`.
|
|
16
|
+
Вставляйте только сам ключ: заголовок `Authorization: CodeRequest ...` клиент соберёт сам,
|
|
17
|
+
а в v1-методах подставит ключ в тело запроса.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from mailganer import Mailganer
|
|
21
|
+
|
|
22
|
+
mg = Mailganer(api_key="ваш_ключ") # или Mailganer() с MAILGANER_API_KEY
|
|
23
|
+
print(mg.users.balance()) # {'balance': 954.9}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Подписчики
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
# добавить одного
|
|
30
|
+
mg.subscribers.add("user@example.com", source=49767, name="Иван", user_vars={"city": "Москва"})
|
|
31
|
+
|
|
32
|
+
# до 50 за запрос; если одна запись с ошибкой, не добавится ни одна
|
|
33
|
+
mg.subscribers.add_many([
|
|
34
|
+
{"email": "a@example.com", "source": 49767, "name": "Аня"},
|
|
35
|
+
{"email": "b@example.com", "source": 49767, "name": "Боря"},
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
# v3: подробный ответ по каждой записи, частичный успех не ошибка
|
|
39
|
+
result = mg.subscribers.add_many_v3(rows)
|
|
40
|
+
for row in result["failed"]:
|
|
41
|
+
print(row["email"], row["errors"])
|
|
42
|
+
|
|
43
|
+
# найти и обновить
|
|
44
|
+
page = mg.subscribers.list(email="user@example.com") # одна страница, 25 записей
|
|
45
|
+
for sub in mg.subscribers.list_all(source=49767): # все страницы подряд
|
|
46
|
+
...
|
|
47
|
+
mg.subscribers.update(mg_hash, name="Пётр") # по mg_hash
|
|
48
|
+
mg.subscribers.update_by_email("user@example.com", 49767, city="Казань")
|
|
49
|
+
mg.subscribers.unsubscribe(mg_hash)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Рассылки и триггеры
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
draft = mg.mailings.create(name="Акция", theme="Скидка 20%", segment_id=123456,
|
|
56
|
+
from_email="news@example.com", from_name="Магазин",
|
|
57
|
+
html_content="<html>...</html>")
|
|
58
|
+
mg.mailings.send(draft["public_id"], datetime_send="01.10.2026 10:00")
|
|
59
|
+
mg.mailings.stats(draft["public_id"], start_date="2026-10-01", end_date="2026-10-07")
|
|
60
|
+
|
|
61
|
+
mg.triggers.send(113292, "user@example.com", variables={"order_id": 555})
|
|
62
|
+
mg.segments.send_trigger(245611, trigger_id=145674, ignore_trigger_conditions=True)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Отчёты
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
task = mg.reports.create("csv", site_id=49767)
|
|
69
|
+
mg.reports.wait(task["id"]) # ждёт state=COMPLETED
|
|
70
|
+
open("list.csv", "wb").write(mg.reports.download(task["id"]))
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Вебхуки
|
|
74
|
+
|
|
75
|
+
Mailganer шлёт события на URL, заданный `mg.lists.set_webhook(list_id, url)`.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from mailganer import parse_webhook
|
|
79
|
+
|
|
80
|
+
@app.post("/mailganer-hook")
|
|
81
|
+
def hook(request):
|
|
82
|
+
event = parse_webhook(request.body)
|
|
83
|
+
if event["event"] == "mail_interaction" and event["action_name"] == "clicked":
|
|
84
|
+
...
|
|
85
|
+
return "", 200
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`event`: `subscriber_status`, `subscriber_data`, `mail_interaction` (с `action_name`: delivered, opened,
|
|
89
|
+
clicked, unsubscribed, spam_complaint, not_delivered), `trigger_event` (с `user_vars`).
|
|
90
|
+
|
|
91
|
+
## Постбек
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from mailganer import postback_url
|
|
95
|
+
|
|
96
|
+
# ссылка для письма: {{public_id}} и {{email_hash}} подставит Mailganer при отправке
|
|
97
|
+
link = postback_url("https://shop.ru/order/done", nps=10)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Ошибки и лимиты
|
|
101
|
+
|
|
102
|
+
Все ошибки наследуют `MailganerError`: `AuthenticationError` (401), `PermissionDeniedError` (403),
|
|
103
|
+
`NotFoundError` (404), `ValidationError` (400, подробности по полям в `.errors`), `RateLimitError`,
|
|
104
|
+
`ServerError`. v1-методы, которые сообщают об ошибке в теле с HTTP 200, тоже дают исключение.
|
|
105
|
+
|
|
106
|
+
При 429, 503 и `throttling` клиент сам ждёт и повторяет запрос (по умолчанию 2 раза, `max_retries`).
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from mailganer import ValidationError
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
mg.templates.create(name="Письмо", html="")
|
|
113
|
+
except ValidationError as e:
|
|
114
|
+
print(e.errors) # {'html': ['Это поле обязательно.']}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Все разделы
|
|
118
|
+
|
|
119
|
+
`subscribers`, `lists`, `segments`, `variables`, `mailings`, `triggers`, `recommendations`, `users`, `tags`,
|
|
120
|
+
`templates`, `reports`, `postback`. Имена полей совпадают с [справочником API](https://documentation.mailganer.com/reference/).
|
|
121
|
+
Метода нет в библиотеке — `mg.request("GET", "/api/v2/...")`.
|
|
122
|
+
|
|
123
|
+
## Разработка
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
python -m unittest discover -s tests # тесты без обращения к API
|
|
127
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mailganer"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Официальный клиент REST API Mailganer: подписчики, списки, рассылки, триггеры, отчёты"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Mailganer", email = "api@mailganer.com" }]
|
|
13
|
+
requires-python = ">=3.8"
|
|
14
|
+
dependencies = []
|
|
15
|
+
keywords = ["mailganer", "email", "рассылки", "api", "email-marketing"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Natural Language :: Russian",
|
|
20
|
+
"Topic :: Communications :: Email",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Documentation = "https://documentation.mailganer.com/"
|
|
25
|
+
"API Reference" = "https://documentation.mailganer.com/reference/"
|
|
26
|
+
Homepage = "https://mailganer.com"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.dynamic]
|
|
29
|
+
version = { attr = "mailganer._version.__version__" }
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.package-data]
|
|
35
|
+
mailganer = ["py.typed"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Официальный клиент REST API Mailganer для Python.
|
|
2
|
+
|
|
3
|
+
Документация API: https://documentation.mailganer.com/
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ._version import __version__
|
|
7
|
+
from .client import Mailganer
|
|
8
|
+
from .errors import (
|
|
9
|
+
AuthenticationError, MailganerError, NotFoundError, PermissionDeniedError, RateLimitError, ServerError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
)
|
|
12
|
+
from .resources import Variables, postback_url
|
|
13
|
+
from .webhooks import parse_webhook
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"__version__",
|
|
17
|
+
"Mailganer",
|
|
18
|
+
"MailganerError",
|
|
19
|
+
"AuthenticationError",
|
|
20
|
+
"PermissionDeniedError",
|
|
21
|
+
"NotFoundError",
|
|
22
|
+
"ValidationError",
|
|
23
|
+
"RateLimitError",
|
|
24
|
+
"ServerError",
|
|
25
|
+
"Variables",
|
|
26
|
+
"parse_webhook",
|
|
27
|
+
"postback_url",
|
|
28
|
+
]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""HTTP-слой: запрос, разбор ответа, ошибки и повторы. Только стандартная библиотека Python."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from .errors import MailganerError, RateLimitError, error_for_status
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://mailganer.com"
|
|
14
|
+
USER_AGENT = "mailganer-python/{}".format(__version__)
|
|
15
|
+
|
|
16
|
+
# v1-методы и часть v2 сообщают об ошибке в теле, иногда с HTTP 200.
|
|
17
|
+
_BODY_ERROR_STATUSES = ("error", "invalid", -1, "-1")
|
|
18
|
+
_BODY_ERROR_TEXTS = ("error", "invalid", "throttling")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def urllib_transport(method, url, headers, body, timeout):
|
|
22
|
+
"""Транспорт по умолчанию. Возвращает (status, headers, raw_bytes).
|
|
23
|
+
|
|
24
|
+
Тело передаётся и в GET: так работает, например, проверка верификации домена.
|
|
25
|
+
"""
|
|
26
|
+
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
27
|
+
try:
|
|
28
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
29
|
+
return resp.status, dict(resp.headers), resp.read()
|
|
30
|
+
except urllib.error.HTTPError as e:
|
|
31
|
+
return e.code, dict(e.headers or {}), e.read() or b""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def encode_multipart(fields):
|
|
35
|
+
"""form-data для v1-метода создания рассылки. fields: dict строк."""
|
|
36
|
+
boundary = "mailganer-" + uuid.uuid4().hex
|
|
37
|
+
parts = []
|
|
38
|
+
for name, value in fields.items():
|
|
39
|
+
if value is None:
|
|
40
|
+
continue
|
|
41
|
+
if isinstance(value, bool):
|
|
42
|
+
value = "true" if value else "false"
|
|
43
|
+
elif isinstance(value, (dict, list)):
|
|
44
|
+
value = json.dumps(value, ensure_ascii=False)
|
|
45
|
+
parts.append(
|
|
46
|
+
"--{b}\r\nContent-Disposition: form-data; name=\"{n}\"\r\n\r\n{v}\r\n".format(b=boundary, n=name, v=value)
|
|
47
|
+
)
|
|
48
|
+
parts.append("--{}--\r\n".format(boundary))
|
|
49
|
+
return "".join(parts).encode("utf-8"), "multipart/form-data; boundary=" + boundary
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse(raw, content_type):
|
|
53
|
+
if raw is None:
|
|
54
|
+
return None
|
|
55
|
+
if "application/json" in (content_type or "") or raw[:1] in (b"{", b"["):
|
|
56
|
+
try:
|
|
57
|
+
return json.loads(raw.decode("utf-8"))
|
|
58
|
+
except ValueError:
|
|
59
|
+
pass
|
|
60
|
+
try:
|
|
61
|
+
return raw.decode("utf-8")
|
|
62
|
+
except UnicodeDecodeError:
|
|
63
|
+
return raw
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _body_error(data):
|
|
67
|
+
if not isinstance(data, dict):
|
|
68
|
+
return False
|
|
69
|
+
return data.get("status") in _BODY_ERROR_STATUSES or data.get("status_text") in _BODY_ERROR_TEXTS
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _message(data, status):
|
|
73
|
+
if isinstance(data, dict):
|
|
74
|
+
for key in ("error", "detail", "msg", "message", "status_text"):
|
|
75
|
+
val = data.get(key)
|
|
76
|
+
if isinstance(val, str) and val:
|
|
77
|
+
return val
|
|
78
|
+
err = data.get("error")
|
|
79
|
+
if isinstance(err, dict) and err:
|
|
80
|
+
return "; ".join("{}: {}".format(k, " ".join(map(str, v)) if isinstance(v, list) else v) for k, v in err.items())
|
|
81
|
+
if isinstance(err, (dict, list)):
|
|
82
|
+
return "Ошибка в полях запроса"
|
|
83
|
+
if isinstance(data, str) and data.strip() and len(data) < 300:
|
|
84
|
+
return data.strip()
|
|
85
|
+
if status == 401:
|
|
86
|
+
return "Ключ API не передан или неверен"
|
|
87
|
+
return "Ошибка API Mailganer"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _header(headers, name):
|
|
91
|
+
for k, v in (headers or {}).items():
|
|
92
|
+
if k.lower() == name.lower():
|
|
93
|
+
return v
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class HttpClient:
|
|
98
|
+
def __init__(self, api_key, base_url=DEFAULT_BASE_URL, timeout=30, max_retries=2, transport=None, sleep=time.sleep):
|
|
99
|
+
self.api_key = (api_key or "").strip()
|
|
100
|
+
if self.api_key.lower().startswith("coderequest "):
|
|
101
|
+
self.api_key = self.api_key[len("coderequest "):].strip()
|
|
102
|
+
self.base_url = base_url.rstrip("/")
|
|
103
|
+
self.timeout = timeout
|
|
104
|
+
self.max_retries = max_retries
|
|
105
|
+
self.transport = transport or urllib_transport
|
|
106
|
+
self._sleep = sleep
|
|
107
|
+
|
|
108
|
+
def request(self, method, path, query=None, json_body=None, form=None, v1_key=False, auth=True, raw=False, base_url=None):
|
|
109
|
+
"""Выполнить запрос и вернуть разобранный ответ.
|
|
110
|
+
|
|
111
|
+
path — путь (/api/v2/...) или полный URL (ссылки next в постраничных ответах).
|
|
112
|
+
v1_key — подставить api_key в тело (v1-методы). raw — вернуть байты (скачивание файла).
|
|
113
|
+
"""
|
|
114
|
+
if auth and not self.api_key:
|
|
115
|
+
raise MailganerError("Нужен api_key: Mailganer(api_key=...). Ключ лежит в личном кабинете, раздел «Ключ API».")
|
|
116
|
+
url = path if path.startswith("http") else (base_url or self.base_url) + path
|
|
117
|
+
if query:
|
|
118
|
+
q = {k: _query_value(v) for k, v in query.items() if v is not None}
|
|
119
|
+
if q:
|
|
120
|
+
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q)
|
|
121
|
+
|
|
122
|
+
headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
|
|
123
|
+
if auth and not v1_key:
|
|
124
|
+
headers["Authorization"] = "CodeRequest " + self.api_key
|
|
125
|
+
body = None
|
|
126
|
+
if form is not None:
|
|
127
|
+
fields = dict(form)
|
|
128
|
+
if v1_key:
|
|
129
|
+
fields["api_key"] = self.api_key
|
|
130
|
+
body, headers["Content-Type"] = encode_multipart(fields)
|
|
131
|
+
elif json_body is not None:
|
|
132
|
+
payload = json_body
|
|
133
|
+
if v1_key and isinstance(payload, dict):
|
|
134
|
+
payload = dict(payload, api_key=self.api_key)
|
|
135
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
136
|
+
headers["Content-Type"] = "application/json"
|
|
137
|
+
|
|
138
|
+
attempt = 0
|
|
139
|
+
while True:
|
|
140
|
+
status, resp_headers, raw_body = self.transport(method, url, headers, body, self.timeout)
|
|
141
|
+
if raw and 200 <= status < 300:
|
|
142
|
+
return raw_body
|
|
143
|
+
data = _parse(raw_body, _header(resp_headers, "Content-Type"))
|
|
144
|
+
failed = status >= 400 or _body_error(data)
|
|
145
|
+
if not failed:
|
|
146
|
+
return data
|
|
147
|
+
throttled = status in (429, 503) or (isinstance(data, dict) and data.get("status_text") == "throttling")
|
|
148
|
+
if throttled and attempt < self.max_retries:
|
|
149
|
+
self._sleep(_retry_delay(resp_headers, attempt))
|
|
150
|
+
attempt += 1
|
|
151
|
+
continue
|
|
152
|
+
cls = RateLimitError if throttled and status < 500 else error_for_status(status if status >= 400 else 400)
|
|
153
|
+
raise cls(_message(data, status), status=status, body=data)
|
|
154
|
+
|
|
155
|
+
def paginate(self, path, query=None):
|
|
156
|
+
"""Пройти все страницы постраничного ответа (count / next / results) и отдать элементы по одному."""
|
|
157
|
+
page = self.request("GET", path, query=query)
|
|
158
|
+
while True:
|
|
159
|
+
if isinstance(page, dict) and isinstance(page.get("results"), list):
|
|
160
|
+
for item in page["results"]:
|
|
161
|
+
yield item
|
|
162
|
+
nxt = page.get("next")
|
|
163
|
+
if not nxt:
|
|
164
|
+
return
|
|
165
|
+
page = self.request("GET", nxt)
|
|
166
|
+
else:
|
|
167
|
+
if isinstance(page, list):
|
|
168
|
+
for item in page:
|
|
169
|
+
yield item
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _query_value(v):
|
|
174
|
+
if isinstance(v, bool):
|
|
175
|
+
return "true" if v else "false"
|
|
176
|
+
return v
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _retry_delay(headers, attempt):
|
|
180
|
+
after = _header(headers, "Retry-After")
|
|
181
|
+
try:
|
|
182
|
+
if after is not None:
|
|
183
|
+
return min(float(after), 60.0)
|
|
184
|
+
except ValueError:
|
|
185
|
+
pass
|
|
186
|
+
return min(2 ** attempt, 30)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Клиент REST API Mailganer."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from ._http import DEFAULT_BASE_URL, HttpClient
|
|
6
|
+
from .resources import (
|
|
7
|
+
Lists, Mailings, Postback, Recommendations, Reports, Segments, Subscribers, Tags, Templates, Triggers, Users,
|
|
8
|
+
Variables,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Mailganer:
|
|
13
|
+
"""Клиент REST API Mailganer.
|
|
14
|
+
|
|
15
|
+
from mailganer import Mailganer
|
|
16
|
+
mg = Mailganer(api_key="ваш_ключ") # или переменная окружения MAILGANER_API_KEY
|
|
17
|
+
mg.subscribers.add("user@example.com", source=49767, name="Иван")
|
|
18
|
+
|
|
19
|
+
Ключ API лежит в личном кабинете, раздел «Ключ API»: https://mailganer.com/app/profile/settings/api/
|
|
20
|
+
Вставляйте только сам ключ: заголовок Authorization: CodeRequest <ключ> клиент соберёт сам,
|
|
21
|
+
а в v1-методах подставит ключ в тело запроса.
|
|
22
|
+
|
|
23
|
+
max_retries — сколько раз повторить запрос при 429/503 и throttling (с паузой);
|
|
24
|
+
transport — свой транспорт для тестов: callable(method, url, headers, body, timeout) -> (status, headers, bytes).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, api_key=None, base_url=DEFAULT_BASE_URL, timeout=30, max_retries=2, transport=None):
|
|
28
|
+
self._http = HttpClient(
|
|
29
|
+
api_key if api_key is not None else os.environ.get("MAILGANER_API_KEY", ""),
|
|
30
|
+
base_url=base_url, timeout=timeout, max_retries=max_retries, transport=transport,
|
|
31
|
+
)
|
|
32
|
+
self.subscribers = Subscribers(self._http)
|
|
33
|
+
self.lists = Lists(self._http)
|
|
34
|
+
self.segments = Segments(self._http)
|
|
35
|
+
self.variables = Variables(self._http)
|
|
36
|
+
self.mailings = Mailings(self._http)
|
|
37
|
+
self.triggers = Triggers(self._http)
|
|
38
|
+
self.recommendations = Recommendations(self._http)
|
|
39
|
+
self.users = Users(self._http)
|
|
40
|
+
self.tags = Tags(self._http)
|
|
41
|
+
self.templates = Templates(self._http)
|
|
42
|
+
self.reports = Reports(self._http)
|
|
43
|
+
self.postback = Postback(self._http)
|
|
44
|
+
|
|
45
|
+
def request(self, method, path, **kwargs):
|
|
46
|
+
"""Произвольный запрос к API, если метода ещё нет в библиотеке: mg.request("GET", "/api/v2/tags/")."""
|
|
47
|
+
return self._http.request(method, path, **kwargs)
|
|
48
|
+
|
|
49
|
+
def __repr__(self):
|
|
50
|
+
return "<Mailganer {}>".format(self._http.base_url)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Ошибки клиента Mailganer."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MailganerError(Exception):
|
|
5
|
+
"""Общая ошибка API Mailganer.
|
|
6
|
+
|
|
7
|
+
status — HTTP-код ответа (у v1-методов ошибка может прийти и с кодом 200),
|
|
8
|
+
body — разобранное тело ответа (dict, list или строка),
|
|
9
|
+
errors — ошибки по полям, если API их вернул: {"email": ["Это поле обязательно."]}.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message, status=None, body=None):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.message = message
|
|
15
|
+
self.status = status
|
|
16
|
+
self.body = body
|
|
17
|
+
self.errors = _field_errors(body)
|
|
18
|
+
|
|
19
|
+
def __str__(self):
|
|
20
|
+
return "{} (HTTP {})".format(self.message, self.status) if self.status else self.message
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthenticationError(MailganerError):
|
|
24
|
+
"""401: ключ API не передан или неверен."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PermissionDeniedError(MailganerError):
|
|
28
|
+
"""403: метод не включён для аккаунта или нет доступа к объекту."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NotFoundError(MailganerError):
|
|
32
|
+
"""404: объект не найден."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ValidationError(MailganerError):
|
|
36
|
+
"""400: не прошли проверку поля запроса. Подробности по полям — в .errors."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RateLimitError(MailganerError):
|
|
40
|
+
"""429 или throttling: превышен лимит запросов. Клиент сам повторяет запрос, эта ошибка — если повторы кончились."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ServerError(MailganerError):
|
|
44
|
+
"""5xx: сервер временно не смог обработать запрос."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _field_errors(body):
|
|
48
|
+
if isinstance(body, dict):
|
|
49
|
+
err = body.get("error")
|
|
50
|
+
if isinstance(err, dict):
|
|
51
|
+
return err
|
|
52
|
+
if isinstance(err, list): # массовые методы: ошибка по каждой записи
|
|
53
|
+
return err
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def error_for_status(status):
|
|
58
|
+
if status == 400:
|
|
59
|
+
return ValidationError
|
|
60
|
+
if status == 401:
|
|
61
|
+
return AuthenticationError
|
|
62
|
+
if status == 403:
|
|
63
|
+
return PermissionDeniedError
|
|
64
|
+
if status == 404:
|
|
65
|
+
return NotFoundError
|
|
66
|
+
if status == 429:
|
|
67
|
+
return RateLimitError
|
|
68
|
+
if status and status >= 500:
|
|
69
|
+
return ServerError
|
|
70
|
+
return MailganerError
|
|
File without changes
|