mailganer 0.1.0__py3-none-any.whl
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/__init__.py +28 -0
- mailganer/_http.py +186 -0
- mailganer/_version.py +1 -0
- mailganer/client.py +50 -0
- mailganer/errors.py +70 -0
- mailganer/py.typed +0 -0
- mailganer/resources.py +441 -0
- mailganer/webhooks.py +68 -0
- mailganer-0.1.0.dist-info/METADATA +146 -0
- mailganer-0.1.0.dist-info/RECORD +13 -0
- mailganer-0.1.0.dist-info/WHEEL +5 -0
- mailganer-0.1.0.dist-info/licenses/LICENSE +21 -0
- mailganer-0.1.0.dist-info/top_level.txt +1 -0
mailganer/__init__.py
ADDED
|
@@ -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
|
+
]
|
mailganer/_http.py
ADDED
|
@@ -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)
|
mailganer/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
mailganer/client.py
ADDED
|
@@ -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)
|
mailganer/errors.py
ADDED
|
@@ -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
|
mailganer/py.typed
ADDED
|
File without changes
|
mailganer/resources.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Методы API по разделам документации. Имена полей — как в API, чтобы код совпадал со справочником:
|
|
2
|
+
https://documentation.mailganer.com/reference/
|
|
3
|
+
|
|
4
|
+
Необязательные поля передаются именованными аргументами (**fields) и уходят в запрос как есть.
|
|
5
|
+
Поля с дефисом (html-content и т.п.) можно передать через подчёркивание: html_content.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
POSTBACK_BASE_URL = "https://postback.mailganer.com"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _clean(d):
|
|
12
|
+
"""Убрать None, чтобы не затирать сохранённые значения пустыми."""
|
|
13
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _dash(fields, names):
|
|
17
|
+
"""html_content -> html-content для полей, которые в API пишутся через дефис."""
|
|
18
|
+
out = {}
|
|
19
|
+
for k, v in fields.items():
|
|
20
|
+
key = k.replace("_", "-") if k in names else k
|
|
21
|
+
out[key] = v
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _require(**kwargs):
|
|
26
|
+
for name, value in kwargs.items():
|
|
27
|
+
if value is None or value == "" or value == []:
|
|
28
|
+
raise ValueError("Нужен параметр {}".format(name))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Resource:
|
|
32
|
+
def __init__(self, http):
|
|
33
|
+
self._http = http
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Subscribers(_Resource):
|
|
37
|
+
"""Подписчики."""
|
|
38
|
+
|
|
39
|
+
def add(self, email, source, **fields):
|
|
40
|
+
"""Добавить подписчика. source — ID списка, список ID или "all".
|
|
41
|
+
|
|
42
|
+
Поля: phone, name, surname, patronymic, gender, birthday, city, add_tag, delete_tag,
|
|
43
|
+
user_vars, not_doi, need_activate, add_category, delete_category.
|
|
44
|
+
"""
|
|
45
|
+
_require(email=email, source=source)
|
|
46
|
+
return self._http.request("POST", "/api/v2/emails/", json_body=_clean(dict(fields, email=email, source=source)))
|
|
47
|
+
|
|
48
|
+
def add_many(self, subscribers):
|
|
49
|
+
"""Массовое добавление, до 50 подписчиков за запрос. Если хотя бы одна запись с ошибкой, не добавится ни одна.
|
|
50
|
+
|
|
51
|
+
subscribers: список словарей с теми же полями, что у add().
|
|
52
|
+
"""
|
|
53
|
+
_require(subscribers=subscribers)
|
|
54
|
+
if len(subscribers) > 50:
|
|
55
|
+
raise ValueError("Не больше 50 подписчиков за запрос, для больших пачек — add_many_v3()")
|
|
56
|
+
return self._http.request("POST", "/api/v2/emails/", json_body=list(subscribers))
|
|
57
|
+
|
|
58
|
+
def add_many_v3(self, subscribers):
|
|
59
|
+
"""Массовое добавление v3 с подробным ответом по каждой записи.
|
|
60
|
+
|
|
61
|
+
Возвращает {"successful": [...], "failed": [...], "warnings": [...]}. Частичный успех (HTTP 207)
|
|
62
|
+
ошибкой не считается: смотрите failed и warnings. Лимит 2000 запросов в минуту.
|
|
63
|
+
"""
|
|
64
|
+
_require(subscribers=subscribers)
|
|
65
|
+
return self._http.request("POST", "/api/v3/emails/", json_body=list(subscribers))
|
|
66
|
+
|
|
67
|
+
def list(self, email=None, source=None, start_date=None, end_date=None, updated_at_start=None,
|
|
68
|
+
updated_at_end=None, last_activity_days=None):
|
|
69
|
+
"""Подписчики с фильтрами, одна страница (25 записей): {"count", "next", "previous", "results"}. Даты — YYYY-MM-DD."""
|
|
70
|
+
return self._http.request("GET", "/api/v2/emails/", query=locals_query(locals()))
|
|
71
|
+
|
|
72
|
+
def list_all(self, **filters):
|
|
73
|
+
"""То же, что list(), но проходит все страницы и отдаёт подписчиков по одному."""
|
|
74
|
+
return self._http.paginate("/api/v2/emails/", query=_clean(filters))
|
|
75
|
+
|
|
76
|
+
def get(self, mg_hash):
|
|
77
|
+
"""Подписчик по mg_hash."""
|
|
78
|
+
_require(mg_hash=mg_hash)
|
|
79
|
+
return self._http.request("GET", "/api/v2/emails/{}/".format(mg_hash))
|
|
80
|
+
|
|
81
|
+
def update(self, mg_hash, **fields):
|
|
82
|
+
"""Обновить подписчика по mg_hash. Чтобы удалить переменную, передайте её в user_vars со значением None."""
|
|
83
|
+
_require(mg_hash=mg_hash)
|
|
84
|
+
return self._http.request("PATCH", "/api/v2/emails/{}/".format(mg_hash), json_body=dict(fields))
|
|
85
|
+
|
|
86
|
+
def update_by_email(self, email, source, **fields):
|
|
87
|
+
"""Обновить подписчика по имейлу и списку."""
|
|
88
|
+
_require(email=email, source=source)
|
|
89
|
+
return self._http.request("PATCH", "/api/v2/emails/update/", json_body=dict(fields, email=email, source=source))
|
|
90
|
+
|
|
91
|
+
def update_many(self, subscribers):
|
|
92
|
+
"""Массовое обновление: список словарей, в каждом email и source плюс меняемые поля."""
|
|
93
|
+
_require(subscribers=subscribers)
|
|
94
|
+
return self._http.request("PATCH", "/api/v2/emails/mass/", json_body=list(subscribers))
|
|
95
|
+
|
|
96
|
+
def unsubscribe(self, mg_hash):
|
|
97
|
+
"""Отписать подписчика по mg_hash."""
|
|
98
|
+
_require(mg_hash=mg_hash)
|
|
99
|
+
return self._http.request("POST", "/api/v2/emails/{}/unsubscribe/".format(mg_hash))
|
|
100
|
+
|
|
101
|
+
def unsubscribe_by_email(self, email, source):
|
|
102
|
+
"""Отписать подписчика по имейлу и списку."""
|
|
103
|
+
_require(email=email, source=source)
|
|
104
|
+
return self._http.request("POST", "/api/v2/emails/unsubscribe/", json_body={"email": email, "source": source})
|
|
105
|
+
|
|
106
|
+
def validate(self, email, source):
|
|
107
|
+
"""Проверить имейл: в ответе is_valid и corrected_email."""
|
|
108
|
+
_require(email=email, source=source)
|
|
109
|
+
return self._http.request("POST", "/api/v2/emails/validate/", json_body={"email": email, "source": source})
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Lists(_Resource):
|
|
113
|
+
"""Списки."""
|
|
114
|
+
|
|
115
|
+
def create(self, domain):
|
|
116
|
+
"""Создать список. domain — название списка."""
|
|
117
|
+
_require(domain=domain)
|
|
118
|
+
return self._http.request("POST", "/api/v2/sites/", json_body={"domain": domain})
|
|
119
|
+
|
|
120
|
+
def list(self, simple=None):
|
|
121
|
+
"""Списки, одна страница. simple=True — только id и domain."""
|
|
122
|
+
return self._http.request("GET", "/api/v2/sites/", query=_clean({"simple": simple}))
|
|
123
|
+
|
|
124
|
+
def list_all(self, simple=None):
|
|
125
|
+
"""Все списки, по одному."""
|
|
126
|
+
return self._http.paginate("/api/v2/sites/", query=_clean({"simple": simple}))
|
|
127
|
+
|
|
128
|
+
def verify_domain(self, domain):
|
|
129
|
+
"""Статус верификации домена отправителя: verification_status, spf_status, dmarc_status, dkim_status.
|
|
130
|
+
|
|
131
|
+
API читает domain из тела GET-запроса, клиент передаёт его именно так.
|
|
132
|
+
"""
|
|
133
|
+
_require(domain=domain)
|
|
134
|
+
return self._http.request("GET", "/api/v2/sites/verify_domain/", json_body={"domain": domain})
|
|
135
|
+
|
|
136
|
+
def set_webhook(self, list_id, url, is_active=None, is_use_auth_key=None, is_enable_retry=None):
|
|
137
|
+
"""Задать URL вебхука для списка. События приходят на url, разобрать их поможет mailganer.parse_webhook()."""
|
|
138
|
+
_require(list_id=list_id, url=url)
|
|
139
|
+
body = _clean({"id": list_id, "url": url, "is_active": is_active,
|
|
140
|
+
"is_use_auth_key": is_use_auth_key, "is_enable_retry": is_enable_retry})
|
|
141
|
+
return self._http.request("POST", "/api/v2/sites/set_webhook", json_body=body)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Segments(_Resource):
|
|
145
|
+
"""Сегменты."""
|
|
146
|
+
|
|
147
|
+
def create(self, title, conditions):
|
|
148
|
+
"""Создать сегмент. conditions: [{"condition": 6, "type_value": 4, "value": ["Тег"]}, ...]."""
|
|
149
|
+
_require(title=title, conditions=conditions)
|
|
150
|
+
return self._http.request("POST", "/api/v2/segments/", json_body={"title": title, "conditions": conditions})
|
|
151
|
+
|
|
152
|
+
def list(self):
|
|
153
|
+
"""Сегменты, одна страница."""
|
|
154
|
+
return self._http.request("GET", "/api/v2/segments/")
|
|
155
|
+
|
|
156
|
+
def list_all(self):
|
|
157
|
+
"""Все сегменты, по одному."""
|
|
158
|
+
return self._http.paginate("/api/v2/segments/")
|
|
159
|
+
|
|
160
|
+
def get(self, segment_id):
|
|
161
|
+
"""Сегмент по ID."""
|
|
162
|
+
_require(segment_id=segment_id)
|
|
163
|
+
return self._http.request("GET", "/api/v2/segments/{}".format(segment_id))
|
|
164
|
+
|
|
165
|
+
def send_trigger(self, segment_id, trigger_id, ignore_trigger_conditions=None):
|
|
166
|
+
"""Запустить триггер на сегмент. ignore_trigger_conditions=True — отправить сразу, без условий триггера."""
|
|
167
|
+
_require(segment_id=segment_id, trigger_id=trigger_id)
|
|
168
|
+
body = _clean({"trigger_id": trigger_id, "ignore_trigger_conditions": ignore_trigger_conditions})
|
|
169
|
+
return self._http.request("POST", "/api/v2/segments/{}/send_trigger/".format(segment_id), json_body=body)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Variables(_Resource):
|
|
173
|
+
"""Пользовательские переменные."""
|
|
174
|
+
|
|
175
|
+
STRING, INTEGER, FLOAT, DATE, DATETIME, BOOLEAN, JSON = 0, 1, 2, 3, 4, 6, 8
|
|
176
|
+
|
|
177
|
+
def create(self, source, title, type, name=None):
|
|
178
|
+
"""Создать переменную в списке source. type — Variables.STRING / INTEGER / FLOAT / DATE / DATETIME / BOOLEAN / JSON."""
|
|
179
|
+
_require(source=source, title=title, type=type)
|
|
180
|
+
return self._http.request("POST", "/api/v2/user_vars/",
|
|
181
|
+
json_body=_clean({"source": source, "title": title, "type": type, "name": name}))
|
|
182
|
+
|
|
183
|
+
def list(self, source=None):
|
|
184
|
+
"""Переменные, одна страница. source — ID списка."""
|
|
185
|
+
return self._http.request("GET", "/api/v2/user_vars/", query=_clean({"source": source}))
|
|
186
|
+
|
|
187
|
+
def list_all(self, source=None):
|
|
188
|
+
"""Все переменные, по одной."""
|
|
189
|
+
return self._http.paginate("/api/v2/user_vars/", query=_clean({"source": source}))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class Mailings(_Resource):
|
|
193
|
+
"""Рассылки."""
|
|
194
|
+
|
|
195
|
+
_DASHED = ("html_content", "amp_html_content", "zip_content", "amp_zip_content")
|
|
196
|
+
|
|
197
|
+
def create(self, **fields):
|
|
198
|
+
"""Создать черновик рассылки (v1). Поля: name, theme, from_name, from_email, segment_id, html_content,
|
|
199
|
+
amp_html_content, template_id, zip_content, amp_zip_content, utm_campaign, utm_source, utm_medium,
|
|
200
|
+
user_utms (dict), variables (dict). Ключ API подставляется сам.
|
|
201
|
+
"""
|
|
202
|
+
return self._http.request("POST", "/api/v1/mailing/create/", form=_dash(_clean(fields), self._DASHED), v1_key=True)
|
|
203
|
+
|
|
204
|
+
def send(self, public_id, datetime_send=None, is_chunked=None, chunk_size=None, chunk_timeout=None):
|
|
205
|
+
"""Отправить рассылку. datetime_send — "DD.MM.YYYY HH:MM"; is_chunked — распределённая отправка."""
|
|
206
|
+
_require(public_id=public_id)
|
|
207
|
+
body = _clean({"public_id": public_id, "datetime_send": datetime_send, "is_chunked": is_chunked,
|
|
208
|
+
"chunk_size": chunk_size, "chunk_timeout": chunk_timeout})
|
|
209
|
+
return self._http.request("POST", "/api/v2/mailing/send/", json_body=body)
|
|
210
|
+
|
|
211
|
+
def get(self, public_id):
|
|
212
|
+
"""Рассылка по public_id."""
|
|
213
|
+
_require(public_id=public_id)
|
|
214
|
+
return self._http.request("GET", "/api/v2/mailing/{}".format(public_id))
|
|
215
|
+
|
|
216
|
+
def list(self, start_date=None, end_date=None):
|
|
217
|
+
"""Рассылки, одна страница. Даты — YYYY-MM-DD."""
|
|
218
|
+
return self._http.request("GET", "/api/v2/mailing/", query=_clean({"start_date": start_date, "end_date": end_date}))
|
|
219
|
+
|
|
220
|
+
def list_all(self, start_date=None, end_date=None):
|
|
221
|
+
"""Все рассылки, по одной."""
|
|
222
|
+
return self._http.paginate("/api/v2/mailing/", query=_clean({"start_date": start_date, "end_date": end_date}))
|
|
223
|
+
|
|
224
|
+
def stats(self, public_id, start_date=None, end_date=None):
|
|
225
|
+
"""Статистика рассылки по дням и суммарно."""
|
|
226
|
+
_require(public_id=public_id)
|
|
227
|
+
return self._http.request("GET", "/api/v2/mailing/{}/stat/".format(public_id),
|
|
228
|
+
query=_clean({"start_date": start_date, "end_date": end_date}))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class Triggers(_Resource):
|
|
232
|
+
"""Триггеры."""
|
|
233
|
+
|
|
234
|
+
_DASHED = ("html_content", "zip_content")
|
|
235
|
+
|
|
236
|
+
def send(self, trigger_id, email, **fields):
|
|
237
|
+
"""Отправить триггер подписчику. Поля: doi_ok, ignore_unsubscribed, ignore_spam, sender_name, sender_email,
|
|
238
|
+
variables (dict).
|
|
239
|
+
"""
|
|
240
|
+
_require(trigger_id=trigger_id, email=email)
|
|
241
|
+
return self._http.request("POST", "/api/v2/triggers/{}/send/".format(trigger_id),
|
|
242
|
+
json_body=_clean(dict(fields, email=email)))
|
|
243
|
+
|
|
244
|
+
def stats(self, trigger_id, start_date=None, end_date=None):
|
|
245
|
+
"""Статистика триггера: суммарно или по дням, если переданы обе даты."""
|
|
246
|
+
_require(trigger_id=trigger_id)
|
|
247
|
+
return self._http.request("GET", "/api/v2/triggers/{}/stat/".format(trigger_id),
|
|
248
|
+
query=_clean({"start_date": start_date, "end_date": end_date}))
|
|
249
|
+
|
|
250
|
+
def send_stats(self, id_list):
|
|
251
|
+
"""Статусы доставки по ID отправок (v1), не больше 100 ID за запрос. Ключ API подставляется сам."""
|
|
252
|
+
_require(id_list=id_list)
|
|
253
|
+
if len(id_list) > 100:
|
|
254
|
+
raise ValueError("Не больше 100 ID за запрос")
|
|
255
|
+
return self._http.request("POST", "/api/v1/trigger/id_stat/", json_body={"id_list": list(id_list)}, v1_key=True)
|
|
256
|
+
|
|
257
|
+
def list(self):
|
|
258
|
+
"""Триггеры, одна страница."""
|
|
259
|
+
return self._http.request("GET", "/api/v2/triggers/")
|
|
260
|
+
|
|
261
|
+
def list_all(self):
|
|
262
|
+
"""Все триггеры, по одному."""
|
|
263
|
+
return self._http.paginate("/api/v2/triggers/")
|
|
264
|
+
|
|
265
|
+
def meta(self, trigger_id):
|
|
266
|
+
"""Информация о триггере."""
|
|
267
|
+
_require(trigger_id=trigger_id)
|
|
268
|
+
return self._http.request("GET", "/api/v2/triggers/{}/meta/".format(trigger_id))
|
|
269
|
+
|
|
270
|
+
def create(self, name, **fields):
|
|
271
|
+
"""Создать триггер. Поля: theme, sites (список ID списков), html_content, zip_content."""
|
|
272
|
+
_require(name=name)
|
|
273
|
+
return self._http.request("POST", "/api/v2/triggers/create/",
|
|
274
|
+
json_body=_dash(_clean(dict(fields, name=name)), self._DASHED))
|
|
275
|
+
|
|
276
|
+
def emails(self, trigger_id, start_date=None, end_date=None):
|
|
277
|
+
"""История имейлов по триггеру, одна страница. Фильтр работает, только если переданы обе даты."""
|
|
278
|
+
_require(trigger_id=trigger_id)
|
|
279
|
+
return self._http.request("GET", "/api/v2/triggers/{}/emails/".format(trigger_id),
|
|
280
|
+
query=_clean({"start_date": start_date, "end_date": end_date}))
|
|
281
|
+
|
|
282
|
+
def emails_all(self, trigger_id, start_date=None, end_date=None):
|
|
283
|
+
"""Вся история имейлов по триггеру, по одному."""
|
|
284
|
+
_require(trigger_id=trigger_id)
|
|
285
|
+
return self._http.paginate("/api/v2/triggers/{}/emails/".format(trigger_id),
|
|
286
|
+
query=_clean({"start_date": start_date, "end_date": end_date}))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class Recommendations(_Resource):
|
|
290
|
+
"""Товарные рекомендации."""
|
|
291
|
+
|
|
292
|
+
def send_orders(self, source_id, orders):
|
|
293
|
+
"""Передать оформленные заказы (v1). Ключ API подставляется сам."""
|
|
294
|
+
_require(source_id=source_id, orders=orders)
|
|
295
|
+
return self._http.request("POST", "/api/v1/recomend/orders/",
|
|
296
|
+
json_body={"source_id": source_id, "orders": list(orders)}, v1_key=True)
|
|
297
|
+
|
|
298
|
+
def send_rfm_orders(self, source_id, orders):
|
|
299
|
+
"""Передать покупки для RFM-сегмента: [{"product_id", "date" (ДД.ММ.ГГГГ), "email", "amount"}]."""
|
|
300
|
+
_require(source_id=source_id, orders=orders)
|
|
301
|
+
return self._http.request("POST", "/api/v2/recomend/orders/", json_body={"source_id": source_id, "orders": list(orders)})
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class Users(_Resource):
|
|
305
|
+
"""Пользователи. Группа методов по умолчанию выключена, доступ — через api@mailganer.com (кроме balance())."""
|
|
306
|
+
|
|
307
|
+
def create(self, first_name, email, phone=None):
|
|
308
|
+
"""Создать пользователя (личный кабинет)."""
|
|
309
|
+
_require(first_name=first_name, email=email)
|
|
310
|
+
return self._http.request("POST", "/api/v2/users/", json_body=_clean({"first_name": first_name, "email": email, "phone": phone}))
|
|
311
|
+
|
|
312
|
+
def list(self):
|
|
313
|
+
"""Пользователи, одна страница."""
|
|
314
|
+
return self._http.request("GET", "/api/v2/users/")
|
|
315
|
+
|
|
316
|
+
def get(self, user_id):
|
|
317
|
+
"""Пользователь по ID."""
|
|
318
|
+
_require(user_id=user_id)
|
|
319
|
+
return self._http.request("GET", "/api/v2/users/{}/".format(user_id))
|
|
320
|
+
|
|
321
|
+
def update(self, user_id, **fields):
|
|
322
|
+
"""Изменить пользователя, например is_access_api_doi=True."""
|
|
323
|
+
_require(user_id=user_id)
|
|
324
|
+
return self._http.request("PATCH", "/api/v2/users/{}/".format(user_id), json_body=dict(fields))
|
|
325
|
+
|
|
326
|
+
def token(self, username, password):
|
|
327
|
+
"""Токен (ключ API) другого пользователя по логину и паролю: {"api_key", "id"}."""
|
|
328
|
+
_require(username=username, password=password)
|
|
329
|
+
return self._http.request("POST", "/api/auth/", json_body={"username": username, "password": password})
|
|
330
|
+
|
|
331
|
+
def balance(self):
|
|
332
|
+
"""Баланс аккаунта: {"balance": ...}."""
|
|
333
|
+
return self._http.request("GET", "/api/v2/users/get_balance/")
|
|
334
|
+
|
|
335
|
+
def payment_link(self, user_id, summ, payment_type, requisites=None):
|
|
336
|
+
"""Ссылка на пополнение баланса. payment_type — "card" или "invoice" (тогда нужны requisites)."""
|
|
337
|
+
_require(user_id=user_id, summ=summ, payment_type=payment_type)
|
|
338
|
+
body = _clean({"id": user_id, "summ": summ, "payment_type": payment_type, "requisites": requisites})
|
|
339
|
+
return self._http.request("POST", "/api/v2/pay/", json_body=body)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class Tags(_Resource):
|
|
343
|
+
"""Теги (метки)."""
|
|
344
|
+
|
|
345
|
+
def create(self, name):
|
|
346
|
+
"""Создать тег, не длиннее 140 символов."""
|
|
347
|
+
_require(name=name)
|
|
348
|
+
return self._http.request("POST", "/api/v2/tags/", json_body={"name": name})
|
|
349
|
+
|
|
350
|
+
def list(self):
|
|
351
|
+
"""Теги, одна страница."""
|
|
352
|
+
return self._http.request("GET", "/api/v2/tags/")
|
|
353
|
+
|
|
354
|
+
def list_all(self):
|
|
355
|
+
"""Все теги, по одному."""
|
|
356
|
+
return self._http.paginate("/api/v2/tags/")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class Templates(_Resource):
|
|
360
|
+
"""Шаблоны."""
|
|
361
|
+
|
|
362
|
+
def create(self, name, html, group_id=None):
|
|
363
|
+
"""Создать HTML-шаблон: {"template_id", "edit_url"}. Лимит 60 запросов в час."""
|
|
364
|
+
_require(name=name, html=html)
|
|
365
|
+
return self._http.request("POST", "/api/v2/template/", json_body=_clean({"name": name, "html": html, "group_id": group_id}))
|
|
366
|
+
|
|
367
|
+
def get(self, template_id):
|
|
368
|
+
"""HTML-вёрстка шаблона: {"text": "<!DOCTYPE html>..."}."""
|
|
369
|
+
_require(template_id=template_id)
|
|
370
|
+
return self._http.request("GET", "/api/v2/template/{}/".format(template_id))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class Reports(_Resource):
|
|
374
|
+
"""Отчёты: создать задачу, дождаться готовности, скачать файл."""
|
|
375
|
+
|
|
376
|
+
def create(self, file_type, site_id=None, segment_id=None, mailing_id=None, mailing_report_type=None,
|
|
377
|
+
start_date=None, end_date=None):
|
|
378
|
+
"""Создать задачу на отчёт. Нужно ровно одно из: site_id, segment_id, mailing_id (с mailing_report_type:
|
|
379
|
+
"general_mailing_report" или "clicks"). file_type — "csv" или "xlsx".
|
|
380
|
+
"""
|
|
381
|
+
_require(file_type=file_type)
|
|
382
|
+
if sum(x is not None for x in (site_id, segment_id, mailing_id)) != 1:
|
|
383
|
+
raise ValueError("Передайте ровно одно из: site_id, segment_id, mailing_id")
|
|
384
|
+
body = _clean({"file_type": file_type, "site_id": site_id, "segment_id": segment_id, "mailing_id": mailing_id,
|
|
385
|
+
"mailing_report_type": mailing_report_type, "start_date": start_date, "end_date": end_date})
|
|
386
|
+
return self._http.request("POST", "/api/v2/tasks/emails_report", json_body=body)
|
|
387
|
+
|
|
388
|
+
def get(self, task_id):
|
|
389
|
+
"""Статус задачи: state = IN_PROGRESS / COMPLETED / FAILED."""
|
|
390
|
+
_require(task_id=task_id)
|
|
391
|
+
return self._http.request("GET", "/api/v2/tasks/{}".format(task_id))
|
|
392
|
+
|
|
393
|
+
def download(self, task_id):
|
|
394
|
+
"""Скачать готовый файл отчёта, вернуть байты. Требует ограничения доступа к API по IP в аккаунте."""
|
|
395
|
+
_require(task_id=task_id)
|
|
396
|
+
return self._http.request("GET", "/api/v2/tasks/{}/download_file".format(task_id), raw=True)
|
|
397
|
+
|
|
398
|
+
def wait(self, task_id, timeout=600, interval=5):
|
|
399
|
+
"""Опрашивать задачу, пока не станет COMPLETED, и вернуть её. FAILED или таймаут — исключение."""
|
|
400
|
+
import time
|
|
401
|
+
from .errors import MailganerError
|
|
402
|
+
|
|
403
|
+
deadline = time.monotonic() + timeout
|
|
404
|
+
while True:
|
|
405
|
+
task = self.get(task_id)
|
|
406
|
+
state = (task or {}).get("state")
|
|
407
|
+
if state == "COMPLETED":
|
|
408
|
+
return task
|
|
409
|
+
if state == "FAILED":
|
|
410
|
+
raise MailganerError("Отчёт не сформирован (state=FAILED)", body=task)
|
|
411
|
+
if time.monotonic() >= deadline:
|
|
412
|
+
raise MailganerError("Отчёт не готов за {} с (state={})".format(timeout, state), body=task)
|
|
413
|
+
self._http._sleep(interval)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class Postback(_Resource):
|
|
417
|
+
"""Постбек: конверсии и обновление переменной по ссылке. Отдельный хост, без ключа API."""
|
|
418
|
+
|
|
419
|
+
def conversion(self, mailing_id, email_hash, sum=None, currency=None, offer=None, goto_url=None, **variables):
|
|
420
|
+
"""Передать конверсию по рассылке. Дополнительные именованные аргументы уходят как переменные подписчика (nps=10)."""
|
|
421
|
+
_require(mailing_id=mailing_id, email_hash=email_hash)
|
|
422
|
+
query = _clean(dict(variables, mailing_id=mailing_id, email_hash=email_hash, sum=sum, currency=currency,
|
|
423
|
+
offer=offer, goto_url=goto_url))
|
|
424
|
+
return self._http.request("GET", "/v1/", query=query, auth=False, base_url=POSTBACK_BASE_URL)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def postback_url(goto_url=None, mailing_id="{{public_id}}", email_hash="{{email_hash}}", **params):
|
|
428
|
+
"""Ссылка постбека для вставки в письмо. По умолчанию подставляет переменные письма {{public_id}} и
|
|
429
|
+
{{email_hash}}, их заменит Mailganer при отправке. Пример: postback_url("https://shop.ru/", nps=10).
|
|
430
|
+
"""
|
|
431
|
+
import urllib.parse
|
|
432
|
+
|
|
433
|
+
query = dict(params, email_hash=email_hash, mailing_id=mailing_id)
|
|
434
|
+
if goto_url is not None:
|
|
435
|
+
query["goto_url"] = goto_url
|
|
436
|
+
qs = urllib.parse.urlencode(_clean(query), safe="{}")
|
|
437
|
+
return POSTBACK_BASE_URL + "/v1/?" + qs
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def locals_query(values):
|
|
441
|
+
return _clean({k: v for k, v in values.items() if k != "self"})
|
mailganer/webhooks.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Разбор входящих вебхуков Mailganer.
|
|
2
|
+
|
|
3
|
+
Mailganer шлёт POST (application/x-www-form-urlencoded) на URL, заданный методом lists.set_webhook().
|
|
4
|
+
Тип события — в поле type:
|
|
5
|
+
1 — изменение статуса подписчика,
|
|
6
|
+
2 — изменение данных подписчика,
|
|
7
|
+
3 — взаимодействие с письмом (доставлено, открыто, клик, отписка, жалоба, недоставка),
|
|
8
|
+
4 — срабатывание события в триггере.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import urllib.parse
|
|
13
|
+
|
|
14
|
+
EVENT_NAMES = {
|
|
15
|
+
1: "subscriber_status",
|
|
16
|
+
2: "subscriber_data",
|
|
17
|
+
3: "mail_interaction",
|
|
18
|
+
4: "trigger_event",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
MAIL_ACTIONS = {
|
|
22
|
+
-3: "not_delivered",
|
|
23
|
+
-2: "spam_complaint",
|
|
24
|
+
-1: "unsubscribed",
|
|
25
|
+
1: "delivered",
|
|
26
|
+
2: "opened",
|
|
27
|
+
3: "clicked",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_INT_FIELDS = ("type", "action", "source", "source_id", "status", "src", "gender")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_webhook(body):
|
|
34
|
+
"""Разобрать тело вебхука: bytes, str (как пришло) или dict (если фреймворк уже разобрал форму).
|
|
35
|
+
|
|
36
|
+
Возвращает dict с полями события, где числа приведены к int, плюс:
|
|
37
|
+
event — имя события (subscriber_status, subscriber_data, mail_interaction, trigger_event),
|
|
38
|
+
action_name — для mail_interaction: delivered / opened / clicked / unsubscribed / spam_complaint / not_delivered,
|
|
39
|
+
user_vars — для trigger_event: переменные подписчика без префикса user_var_.
|
|
40
|
+
"""
|
|
41
|
+
if isinstance(body, (bytes, bytearray)):
|
|
42
|
+
body = body.decode("utf-8")
|
|
43
|
+
if isinstance(body, str):
|
|
44
|
+
pairs = urllib.parse.parse_qsl(body, keep_blank_values=True)
|
|
45
|
+
data = dict(pairs)
|
|
46
|
+
elif isinstance(body, dict):
|
|
47
|
+
data = {k: (v[0] if isinstance(v, list) and len(v) == 1 else v) for k, v in body.items()}
|
|
48
|
+
else:
|
|
49
|
+
raise TypeError("Тело вебхука: bytes, str или dict")
|
|
50
|
+
|
|
51
|
+
for key in _INT_FIELDS:
|
|
52
|
+
if key in data:
|
|
53
|
+
try:
|
|
54
|
+
data[key] = int(data[key])
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
data["event"] = EVENT_NAMES.get(data.get("type"), "unknown")
|
|
59
|
+
if data["event"] == "mail_interaction":
|
|
60
|
+
data["action_name"] = MAIL_ACTIONS.get(data.get("action"), "unknown")
|
|
61
|
+
if data["event"] == "trigger_event":
|
|
62
|
+
data["user_vars"] = {k[len("user_var_"):]: v for k, v in data.items() if k.startswith("user_var_")}
|
|
63
|
+
if isinstance(data.get("user_vars"), str):
|
|
64
|
+
try:
|
|
65
|
+
data["user_vars"] = json.loads(data["user_vars"])
|
|
66
|
+
except ValueError:
|
|
67
|
+
pass
|
|
68
|
+
return data
|
|
@@ -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,13 @@
|
|
|
1
|
+
mailganer/__init__.py,sha256=w2tJBaITYkPB_ToKB_5C9hbMh6dYSh_ep7Jv4uSJkjs,718
|
|
2
|
+
mailganer/_http.py,sha256=TEG1Ze5ttGRs2WF9OqgjK-MV9i7i1tuf1hBhQ-7umQU,7724
|
|
3
|
+
mailganer/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
4
|
+
mailganer/client.py,sha256=NjqwrRFrarWMhF7a045EwvZ1lQkxEAWkT9PloW_D73Y,2427
|
|
5
|
+
mailganer/errors.py,sha256=Rbj_EQ8JQcDvRi-8HbjxyIO81ioFyc0OqTTntnzel6E,2417
|
|
6
|
+
mailganer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
mailganer/resources.py,sha256=E0AEv6AZjGJFtqSKI-LbqnHkpaR0Tj1IgFUJu218asM,23766
|
|
8
|
+
mailganer/webhooks.py,sha256=uDhWUbQ1A6mkSBKK8-lK-Z8KkCdmNYmIQ5AzZKaDCd0,2778
|
|
9
|
+
mailganer-0.1.0.dist-info/licenses/LICENSE,sha256=zFPfu0qjAr4TDkbw6Vsnm0eLotZLrb0icaC-4yuKUUo,1066
|
|
10
|
+
mailganer-0.1.0.dist-info/METADATA,sha256=VjmHwywxndFocxpXLinVoYpOxiYjBKQH6M3cW52CwC0,5852
|
|
11
|
+
mailganer-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
mailganer-0.1.0.dist-info/top_level.txt,sha256=xulTdTgjOXTDA6LP3k9UkiniROyeCGUR0o49cXk0g58,10
|
|
13
|
+
mailganer-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
mailganer
|