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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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