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.
@@ -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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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