mxgate 0.3.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.
- mxgate/__init__.py +13 -0
- mxgate/_operations.py +45 -0
- mxgate/_version.py +3 -0
- mxgate/client.py +533 -0
- mxgate/py.typed +0 -0
- mxgate/types.py +547 -0
- mxgate/webhooks.py +53 -0
- mxgate-0.3.0.dist-info/METADATA +239 -0
- mxgate-0.3.0.dist-info/RECORD +11 -0
- mxgate-0.3.0.dist-info/WHEEL +4 -0
- mxgate-0.3.0.dist-info/licenses/LICENSE +21 -0
mxgate/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Python client for mxgate: email forwarding and send-as for a domain you own.
|
|
2
|
+
|
|
3
|
+
from mxgate import Mxgate, MxgateError
|
|
4
|
+
|
|
5
|
+
mx = Mxgate() # the key comes from MXGATE_API_KEY
|
|
6
|
+
print(mx.account()["plan"])
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .client import Mxgate, MxgateError
|
|
11
|
+
from .webhooks import verify_webhook
|
|
12
|
+
|
|
13
|
+
__all__ = ["Mxgate", "MxgateError", "__version__", "verify_webhook"]
|
mxgate/_operations.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Each `operationId` of the OpenAPI document and the `Mxgate` method that calls it.
|
|
2
|
+
|
|
3
|
+
`tests/test_openapi.py` fails when the document has an operation that is not
|
|
4
|
+
here, or when a method that is named here does not exist.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Dict
|
|
8
|
+
|
|
9
|
+
OPERATIONS: Dict[str, str] = {
|
|
10
|
+
"getIndex": "index",
|
|
11
|
+
"getStatus": "status",
|
|
12
|
+
"signUp": "signup",
|
|
13
|
+
"getAccount": "account",
|
|
14
|
+
"requestLogin": "login",
|
|
15
|
+
"confirmLogin": "confirm_login",
|
|
16
|
+
"startGoogleLogin": "google_login_url",
|
|
17
|
+
"confirmLoginGrant": "confirm_login_grant",
|
|
18
|
+
"logout": "logout",
|
|
19
|
+
"listKeys": "keys",
|
|
20
|
+
"recoverKey": "recover_key",
|
|
21
|
+
"confirmKeyRecovery": "confirm_recovery",
|
|
22
|
+
"rotateKey": "rotate_key",
|
|
23
|
+
"listConnectedApps": "apps",
|
|
24
|
+
"removeConnectedApp": "remove_app",
|
|
25
|
+
"createCheckout": "checkout",
|
|
26
|
+
"createPortalLink": "billing_portal",
|
|
27
|
+
"listDomains": "domains",
|
|
28
|
+
"addDomain": "add_domain",
|
|
29
|
+
"getDomain": "domain",
|
|
30
|
+
"deleteDomain": "remove_domain",
|
|
31
|
+
"verifyDomain": "verify_domain",
|
|
32
|
+
"listAliases": "aliases",
|
|
33
|
+
"setAlias": "set_alias",
|
|
34
|
+
"deleteAlias": "remove_alias",
|
|
35
|
+
"getSendAs": "send_as",
|
|
36
|
+
"createSmtpCredential": "create_smtp_credential",
|
|
37
|
+
"deleteSmtpCredential": "remove_smtp_credential",
|
|
38
|
+
"sendMessage": "send",
|
|
39
|
+
"listLogs": "logs",
|
|
40
|
+
"getNotifications": "notifications",
|
|
41
|
+
"setNotifications": "set_notifications",
|
|
42
|
+
"testWebhook": "test_webhook",
|
|
43
|
+
"listDestinations": "destinations",
|
|
44
|
+
"addDestination": "add_destination",
|
|
45
|
+
}
|
mxgate/_version.py
ADDED
mxgate/client.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
"""The client of the mxgate HTTP API. Standard library only.
|
|
2
|
+
|
|
3
|
+
It mirrors the TypeScript client of the `mxgate` npm package: the same
|
|
4
|
+
methods, the same places for the key, the same split of an alias address.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import enum
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
from http.client import HTTPException
|
|
15
|
+
from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple, Union
|
|
16
|
+
|
|
17
|
+
from ._operations import OPERATIONS
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
from .types import (
|
|
20
|
+
Account,
|
|
21
|
+
AddDomainResult,
|
|
22
|
+
AliasList,
|
|
23
|
+
BillingInterval,
|
|
24
|
+
Checkout,
|
|
25
|
+
ConnectedAppList,
|
|
26
|
+
Deleted,
|
|
27
|
+
DestinationList,
|
|
28
|
+
DestinationResult,
|
|
29
|
+
DomainList,
|
|
30
|
+
DomainResult,
|
|
31
|
+
Index,
|
|
32
|
+
KeyList,
|
|
33
|
+
LogDirection,
|
|
34
|
+
LogEntry,
|
|
35
|
+
LoginResult,
|
|
36
|
+
LogoutResult,
|
|
37
|
+
LogsPage,
|
|
38
|
+
LogStatus,
|
|
39
|
+
NewKey,
|
|
40
|
+
NotificationEvent,
|
|
41
|
+
NotificationSettings,
|
|
42
|
+
Portal,
|
|
43
|
+
RecoverResult,
|
|
44
|
+
RemovedApp,
|
|
45
|
+
SendAsResult,
|
|
46
|
+
SendResult,
|
|
47
|
+
Session,
|
|
48
|
+
SetAliasResult,
|
|
49
|
+
SignupResult,
|
|
50
|
+
SmtpCredentialCreated,
|
|
51
|
+
Status,
|
|
52
|
+
VerifyResult,
|
|
53
|
+
WebhookTestResult,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
DEFAULT_API_URL = "https://api.mxgate.io"
|
|
57
|
+
USER_AGENT = "mxgate-python/" + __version__
|
|
58
|
+
CATCH_ALL = "*"
|
|
59
|
+
|
|
60
|
+
__all__ = ["CATCH_ALL", "DEFAULT_API_URL", "OPERATIONS", "Mxgate", "MxgateError", "config_path", "split_alias_address"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _NotGiven(enum.Enum):
|
|
64
|
+
"""The default of a parameter where None has a meaning of its own."""
|
|
65
|
+
|
|
66
|
+
NOT_GIVEN = "NOT_GIVEN"
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str:
|
|
69
|
+
return "NOT_GIVEN"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
NOT_GIVEN = _NotGiven.NOT_GIVEN
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class MxgateError(Exception):
|
|
76
|
+
"""An error answer of the API (status 400 or more), or a failure before an answer came.
|
|
77
|
+
|
|
78
|
+
`code` is the `error` field of the body: let the program switch on it.
|
|
79
|
+
`message` is one sentence for a person. `fix` is the next step, when the
|
|
80
|
+
API gives one. A network failure has the status 0 and the code `network`.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
status: int
|
|
84
|
+
code: str
|
|
85
|
+
message: str
|
|
86
|
+
fix: Optional[str]
|
|
87
|
+
retry_after_seconds: Optional[int]
|
|
88
|
+
upgrade_url: Optional[str]
|
|
89
|
+
body: Dict[str, Any]
|
|
90
|
+
|
|
91
|
+
def __init__(self, status: int, body: Optional[Mapping[str, Any]] = None) -> None:
|
|
92
|
+
self.status = status
|
|
93
|
+
self.body = dict(body) if isinstance(body, Mapping) else {}
|
|
94
|
+
code = self.body.get("error")
|
|
95
|
+
has_code = isinstance(code, str) and bool(code)
|
|
96
|
+
self.code = str(code) if has_code else "http_error"
|
|
97
|
+
message = self.body.get("message")
|
|
98
|
+
if isinstance(message, str) and message:
|
|
99
|
+
self.message = message
|
|
100
|
+
else:
|
|
101
|
+
self.message = self.code if has_code else "HTTP %d" % status
|
|
102
|
+
fix = self.body.get("fix")
|
|
103
|
+
self.fix = fix if isinstance(fix, str) and fix else None
|
|
104
|
+
retry = self.body.get("retry_after_seconds")
|
|
105
|
+
self.retry_after_seconds = int(retry) if isinstance(retry, (int, float)) and not isinstance(retry, bool) else None
|
|
106
|
+
upgrade = self.body.get("upgrade_url")
|
|
107
|
+
self.upgrade_url = upgrade if isinstance(upgrade, str) and upgrade else None
|
|
108
|
+
super().__init__(self.message)
|
|
109
|
+
|
|
110
|
+
def __str__(self) -> str:
|
|
111
|
+
text = "%s: %s" % (self.code, self.message)
|
|
112
|
+
return "%s Fix: %s" % (text, self.fix) if self.fix else text
|
|
113
|
+
|
|
114
|
+
def __repr__(self) -> str:
|
|
115
|
+
return "MxgateError(status=%r, code=%r, message=%r)" % (self.status, self.code, self.message)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------- names
|
|
119
|
+
|
|
120
|
+
_DOMAIN_RE = re.compile(r"(?=.{1,253}\Z)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}")
|
|
121
|
+
_LOCAL_RE = re.compile(r"[a-z0-9_+-]+(\.[a-z0-9_+-]+)*")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_domain(value: str) -> str:
|
|
125
|
+
if not value.strip():
|
|
126
|
+
raise ValueError("give a domain name, for example example.com")
|
|
127
|
+
if "@" in value:
|
|
128
|
+
raise ValueError("give the domain only, without a mailbox part")
|
|
129
|
+
domain = value.strip().lower()
|
|
130
|
+
if domain.endswith("."):
|
|
131
|
+
domain = domain[:-1]
|
|
132
|
+
if not _DOMAIN_RE.fullmatch(domain):
|
|
133
|
+
raise ValueError('"%s" is not a domain name' % value)
|
|
134
|
+
return domain
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _parse_alias_name(value: str) -> str:
|
|
138
|
+
name = value.strip().lower()
|
|
139
|
+
if not name:
|
|
140
|
+
raise ValueError('give the alias, for example "hello", or "*" for the catch-all')
|
|
141
|
+
if name == CATCH_ALL:
|
|
142
|
+
return CATCH_ALL
|
|
143
|
+
if "@" in name:
|
|
144
|
+
raise ValueError('give the part before the @ only, for example "hello"')
|
|
145
|
+
if len(name) > 64:
|
|
146
|
+
raise ValueError("an alias has at most 64 characters")
|
|
147
|
+
if not _LOCAL_RE.fullmatch(name):
|
|
148
|
+
raise ValueError('"%s" is not a valid alias: use letters, digits and . _ + -' % value)
|
|
149
|
+
return name
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def split_alias_address(address: str) -> Tuple[str, str]:
|
|
153
|
+
"""Splits "hello@example.com", "*@example.com" or "@example.com" into (alias, domain).
|
|
154
|
+
|
|
155
|
+
The catch-all has the alias "*". Raises ValueError for a text that is not
|
|
156
|
+
an alias address.
|
|
157
|
+
"""
|
|
158
|
+
text = address.strip()
|
|
159
|
+
at = text.rfind("@")
|
|
160
|
+
if at == -1:
|
|
161
|
+
raise ValueError('"%s" is not an alias address: write it as hello@example.com, or *@example.com for the catch-all' % address)
|
|
162
|
+
domain = _parse_domain(text[at + 1 :])
|
|
163
|
+
alias = _parse_alias_name(text[:at] or CATCH_ALL)
|
|
164
|
+
return alias, domain
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------- config
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def config_path(env: Optional[Mapping[str, str]] = None) -> str:
|
|
171
|
+
"""The config file that `mxgate signup` of the CLI writes: ~/.config/mxgate/config.json."""
|
|
172
|
+
e = os.environ if env is None else env
|
|
173
|
+
base = e.get("XDG_CONFIG_HOME") or os.path.join(e.get("HOME") or os.path.expanduser("~"), ".config")
|
|
174
|
+
return os.path.join(base, "mxgate", "config.json")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _read_config_file(path: str) -> Dict[str, Any]:
|
|
178
|
+
try:
|
|
179
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
180
|
+
parsed = json.load(f)
|
|
181
|
+
except (OSError, ValueError):
|
|
182
|
+
return {} # no file yet, or not JSON
|
|
183
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _text(value: Any) -> Optional[str]:
|
|
187
|
+
return value if isinstance(value, str) and value else None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _segment(value: str) -> str:
|
|
191
|
+
# The same characters stay as in encodeURIComponent of the TypeScript client.
|
|
192
|
+
return urllib.parse.quote(value, safe="!*'()")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
196
|
+
"""The API does not redirect. A redirect is not followed, so the key is not sent to a second host."""
|
|
197
|
+
|
|
198
|
+
def redirect_request(self, req: Any, fp: Any, code: Any, msg: Any, headers: Any, newurl: Any) -> None:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------- client
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class Mxgate:
|
|
206
|
+
"""The client of the mxgate API.
|
|
207
|
+
|
|
208
|
+
The key: the `api_key` argument, then `MXGATE_API_KEY` in the environment,
|
|
209
|
+
then `api_key` in ~/.config/mxgate/config.json (`XDG_CONFIG_HOME` is
|
|
210
|
+
honoured). The base URL: the `api_url` argument, then `MXGATE_API_URL`,
|
|
211
|
+
then `api_url` in the config file, then https://api.mxgate.io.
|
|
212
|
+
|
|
213
|
+
Each method returns the parsed JSON answer as a `dict` and raises
|
|
214
|
+
`MxgateError` for an error answer or a network failure.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def __init__(self, api_key: Optional[str] = None, *, api_url: Optional[str] = None, timeout: float = 30.0) -> None:
|
|
218
|
+
file: Dict[str, Any] = {}
|
|
219
|
+
if not (api_key and api_url):
|
|
220
|
+
file = _read_config_file(config_path())
|
|
221
|
+
self.api_key: Optional[str] = api_key or _text(os.environ.get("MXGATE_API_KEY")) or _text(file.get("api_key"))
|
|
222
|
+
url = api_url or _text(os.environ.get("MXGATE_API_URL")) or _text(file.get("api_url")) or DEFAULT_API_URL
|
|
223
|
+
if not re.match(r"https?://", url, re.IGNORECASE):
|
|
224
|
+
raise ValueError('api_url must start with https:// or http://, not "%s"' % url)
|
|
225
|
+
self.api_url: str = url.rstrip("/")
|
|
226
|
+
self.timeout: float = timeout
|
|
227
|
+
self._opener = urllib.request.build_opener(_NoRedirect())
|
|
228
|
+
|
|
229
|
+
def __repr__(self) -> str:
|
|
230
|
+
return "Mxgate(api_url=%r, api_key=%s)" % (self.api_url, "'***'" if self.api_key else "None")
|
|
231
|
+
|
|
232
|
+
def _request(
|
|
233
|
+
self,
|
|
234
|
+
method: str,
|
|
235
|
+
path: str,
|
|
236
|
+
body: Optional[Mapping[str, Any]] = None,
|
|
237
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
238
|
+
auth: bool = True,
|
|
239
|
+
) -> Any:
|
|
240
|
+
"""Sends one call and returns the parsed JSON answer. Raises MxgateError for a status of 400 or more."""
|
|
241
|
+
headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
|
|
242
|
+
if auth:
|
|
243
|
+
if not self.api_key:
|
|
244
|
+
raise MxgateError(
|
|
245
|
+
401,
|
|
246
|
+
{
|
|
247
|
+
"error": "no_api_key",
|
|
248
|
+
"message": "No API key",
|
|
249
|
+
"fix": 'Give api_key to Mxgate(), or set MXGATE_API_KEY. For a new account: Mxgate().signup("you@example.com").',
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
headers["Authorization"] = "Bearer " + self.api_key
|
|
253
|
+
url = self.api_url + path
|
|
254
|
+
if query:
|
|
255
|
+
pairs = [(k, str(v)) for k, v in query.items() if v is not None and v != ""]
|
|
256
|
+
if pairs:
|
|
257
|
+
url += "?" + urllib.parse.urlencode(pairs)
|
|
258
|
+
data: Optional[bytes] = None
|
|
259
|
+
if body is not None:
|
|
260
|
+
data = json.dumps(body).encode("utf-8")
|
|
261
|
+
headers["Content-Type"] = "application/json"
|
|
262
|
+
request = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
263
|
+
try:
|
|
264
|
+
with self._opener.open(request, timeout=self.timeout) as response:
|
|
265
|
+
status = int(response.status)
|
|
266
|
+
raw = response.read()
|
|
267
|
+
except urllib.error.HTTPError as err:
|
|
268
|
+
# An answer came, with an error status (or a redirect that was not followed).
|
|
269
|
+
status = int(err.code)
|
|
270
|
+
try:
|
|
271
|
+
raw = err.read()
|
|
272
|
+
except (OSError, HTTPException, AttributeError):
|
|
273
|
+
raw = b""
|
|
274
|
+
finally:
|
|
275
|
+
err.close()
|
|
276
|
+
except (urllib.error.URLError, OSError, HTTPException) as err:
|
|
277
|
+
reason = getattr(err, "reason", None) or err
|
|
278
|
+
raise MxgateError(
|
|
279
|
+
0,
|
|
280
|
+
{
|
|
281
|
+
"error": "network",
|
|
282
|
+
"message": "No answer from %s: %s" % (self.api_url, reason),
|
|
283
|
+
"fix": "Make sure that the network and the API URL are correct, then try again.",
|
|
284
|
+
},
|
|
285
|
+
) from err
|
|
286
|
+
|
|
287
|
+
if 300 <= status < 400:
|
|
288
|
+
raise MxgateError(status, {"error": "bad_response", "message": "HTTP %d: %s redirects" % (status, self.api_url), "fix": "Give the final URL as api_url."})
|
|
289
|
+
text = raw.decode("utf-8", errors="replace")
|
|
290
|
+
parsed: Any
|
|
291
|
+
try:
|
|
292
|
+
parsed = json.loads(text) if text.strip() else {}
|
|
293
|
+
except ValueError:
|
|
294
|
+
parsed = None
|
|
295
|
+
if not isinstance(parsed, dict):
|
|
296
|
+
# Not a JSON object: for example the page of a proxy.
|
|
297
|
+
raise MxgateError(status, {"error": "bad_response", "message": text[:200] or "HTTP %d" % status})
|
|
298
|
+
if status >= 400:
|
|
299
|
+
raise MxgateError(status, parsed)
|
|
300
|
+
return parsed
|
|
301
|
+
|
|
302
|
+
# ------------------------------------------------------------ account and keys
|
|
303
|
+
|
|
304
|
+
def index(self) -> Index:
|
|
305
|
+
"""List the endpoints: the name and the version of the service, the setup steps, one line for each endpoint. No key."""
|
|
306
|
+
return self._request("GET", "/", auth=False)
|
|
307
|
+
|
|
308
|
+
def status(self) -> Status:
|
|
309
|
+
"""Get the service status: the health of the service for the last 30 days, as an outside monitor saw it. No key."""
|
|
310
|
+
return self._request("GET", "/status", auth=False)
|
|
311
|
+
|
|
312
|
+
def signup(self, email: str) -> SignupResult:
|
|
313
|
+
"""Create an account for an email address; the answer has its first API key, shown once. No key."""
|
|
314
|
+
return self._request("POST", "/v1/signup", {"email": email}, auth=False)
|
|
315
|
+
|
|
316
|
+
def account(self) -> Account:
|
|
317
|
+
"""Get the account: the plan, the limits, the usage, the status and the state of the subscription."""
|
|
318
|
+
return self._request("GET", "/v1/account")
|
|
319
|
+
|
|
320
|
+
def login(self, email: str) -> LoginResult:
|
|
321
|
+
"""Mail a six-digit sign-in code to the account address. For the website. No key."""
|
|
322
|
+
return self._request("POST", "/v1/login", {"email": email}, auth=False)
|
|
323
|
+
|
|
324
|
+
def confirm_login(self, email: str, code: str) -> Session:
|
|
325
|
+
"""Get a session token for the sign-in code; it works like an API key for 30 days. No key."""
|
|
326
|
+
return self._request("POST", "/v1/login/confirm", {"email": email, "code": code}, auth=False)
|
|
327
|
+
|
|
328
|
+
def google_login_url(self, state: str) -> str:
|
|
329
|
+
"""Sign in with Google: returns the URL that a browser opens. It makes no call; the endpoint answers 302 and is not for a program."""
|
|
330
|
+
return "%s/v1/login/google?%s" % (self.api_url, urllib.parse.urlencode({"state": state}))
|
|
331
|
+
|
|
332
|
+
def confirm_login_grant(self, grant: str) -> Session:
|
|
333
|
+
"""Trade a Google grant for a session: gives a session token for the one-time grant of the sign-in with Google. No key."""
|
|
334
|
+
return self._request("POST", "/v1/login/grant", {"grant": grant}, auth=False)
|
|
335
|
+
|
|
336
|
+
def logout(self) -> LogoutResult:
|
|
337
|
+
"""End the session: the session token of this client stops working. An API key has no sign-out."""
|
|
338
|
+
return self._request("POST", "/v1/logout")
|
|
339
|
+
|
|
340
|
+
def keys(self) -> KeyList:
|
|
341
|
+
"""List the API keys of the account that work now; only the first 12 characters of each."""
|
|
342
|
+
return self._request("GET", "/v1/keys")
|
|
343
|
+
|
|
344
|
+
def recover_key(self, email: str) -> RecoverResult:
|
|
345
|
+
"""Mail a key recovery token to the account address, for a lost API key. No key."""
|
|
346
|
+
return self._request("POST", "/v1/keys/recover", {"email": email}, auth=False)
|
|
347
|
+
|
|
348
|
+
def confirm_recovery(self, token: str) -> NewKey:
|
|
349
|
+
"""Get a new key with the recovery token; every older key and session token stop working. No key."""
|
|
350
|
+
return self._request("POST", "/v1/keys/recover/confirm", {"token": token}, auth=False)
|
|
351
|
+
|
|
352
|
+
def rotate_key(self) -> NewKey:
|
|
353
|
+
"""Replace the API key: the key of this client and every older key stop working. Store the new key."""
|
|
354
|
+
return self._request("POST", "/v1/keys/rotate")
|
|
355
|
+
|
|
356
|
+
def apps(self) -> ConnectedAppList:
|
|
357
|
+
"""List connected apps: the apps that the owner allowed through OAuth, each with the date and the last use."""
|
|
358
|
+
return self._request("GET", "/v1/oauth/apps")
|
|
359
|
+
|
|
360
|
+
def remove_app(self, app_id: int) -> RemovedApp:
|
|
361
|
+
"""Disconnect an app, by its `id` from `apps()`. Its token stops working at once."""
|
|
362
|
+
return self._request("DELETE", "/v1/oauth/apps/%s" % _segment(str(app_id)))
|
|
363
|
+
|
|
364
|
+
# ------------------------------------------------------------ billing
|
|
365
|
+
|
|
366
|
+
def checkout(self, interval: BillingInterval = "month") -> Checkout:
|
|
367
|
+
"""Get a payment link for the paid plan (9 USD a month or 79 USD a year); a person opens it and pays."""
|
|
368
|
+
return self._request("POST", "/v1/billing/checkout", {"interval": interval})
|
|
369
|
+
|
|
370
|
+
def billing_portal(self) -> Portal:
|
|
371
|
+
"""Get a subscription management link for a person: change the card, see invoices, or cancel."""
|
|
372
|
+
return self._request("POST", "/v1/billing/portal")
|
|
373
|
+
|
|
374
|
+
# ------------------------------------------------------------ domains
|
|
375
|
+
|
|
376
|
+
def domains(self) -> DomainList:
|
|
377
|
+
"""List the domains of the account, each with its DNS records, its last check and its send-as state."""
|
|
378
|
+
return self._request("GET", "/v1/domains")
|
|
379
|
+
|
|
380
|
+
def add_domain(self, name: str) -> AddDomainResult:
|
|
381
|
+
"""Add a domain to the account; the answer has the DNS records to publish."""
|
|
382
|
+
return self._request("POST", "/v1/domains", {"name": name})
|
|
383
|
+
|
|
384
|
+
def domain(self, name: str) -> DomainResult:
|
|
385
|
+
"""Get one domain with the DNS records it needs, the result of its last check and its send-as state."""
|
|
386
|
+
return self._request("GET", "/v1/domains/%s" % _segment(name))
|
|
387
|
+
|
|
388
|
+
def verify_domain(self, name: str) -> VerifyResult:
|
|
389
|
+
"""Check the DNS records of the domain now. Repeat the call until `verified` is true."""
|
|
390
|
+
return self._request("POST", "/v1/domains/%s/verify" % _segment(name))
|
|
391
|
+
|
|
392
|
+
def remove_domain(self, name: str) -> Deleted:
|
|
393
|
+
"""Remove a domain, its aliases and its SMTP logins from the account."""
|
|
394
|
+
return self._request("DELETE", "/v1/domains/%s" % _segment(name))
|
|
395
|
+
|
|
396
|
+
# ------------------------------------------------------------ aliases and destinations
|
|
397
|
+
|
|
398
|
+
def aliases(self, domain: str) -> AliasList:
|
|
399
|
+
"""List the aliases of the domain; each alias says if mail to it is forwarded now and, if not, why."""
|
|
400
|
+
return self._request("GET", "/v1/domains/%s/aliases" % _segment(domain))
|
|
401
|
+
|
|
402
|
+
def set_alias(self, address: str, to: Union[str, Sequence[str]]) -> SetAliasResult:
|
|
403
|
+
"""Create or replace an alias. `address` is hello@example.com, or *@example.com for the catch-all; `to` is one mailbox or a list."""
|
|
404
|
+
alias, domain = split_alias_address(address)
|
|
405
|
+
destinations = [to] if isinstance(to, str) else list(to)
|
|
406
|
+
return self._request("PUT", "/v1/domains/%s/aliases/%s" % (_segment(domain), _segment(alias)), {"to": destinations})
|
|
407
|
+
|
|
408
|
+
def remove_alias(self, address: str) -> Deleted:
|
|
409
|
+
"""Remove an alias, given as hello@example.com or *@example.com. The destinations stay in the account."""
|
|
410
|
+
alias, domain = split_alias_address(address)
|
|
411
|
+
return self._request("DELETE", "/v1/domains/%s/aliases/%s" % (_segment(domain), _segment(alias)))
|
|
412
|
+
|
|
413
|
+
def destinations(self) -> DestinationList:
|
|
414
|
+
"""List the destinations: the mailboxes that the account can forward to, and if each one is confirmed."""
|
|
415
|
+
return self._request("GET", "/v1/destinations")
|
|
416
|
+
|
|
417
|
+
def add_destination(self, email: str) -> DestinationResult:
|
|
418
|
+
"""Add a destination: a mailbox to forward to. A confirmation link is mailed to it."""
|
|
419
|
+
return self._request("POST", "/v1/destinations", {"email": email})
|
|
420
|
+
|
|
421
|
+
# ------------------------------------------------------------ send-as
|
|
422
|
+
|
|
423
|
+
def send_as(self, domain: str) -> SendAsResult:
|
|
424
|
+
"""Get the send-as state of the domain: if it is ready and why not, the SMTP host, the ports and the SMTP logins."""
|
|
425
|
+
return self._request("GET", "/v1/domains/%s/smtp" % _segment(domain))
|
|
426
|
+
|
|
427
|
+
def create_smtp_credential(self, domain: str) -> SmtpCredentialCreated:
|
|
428
|
+
"""Create an SMTP login for the domain; the password is shown once in the answer."""
|
|
429
|
+
return self._request("POST", "/v1/domains/%s/smtp" % _segment(domain))
|
|
430
|
+
|
|
431
|
+
def remove_smtp_credential(self, domain: str, username: str) -> Deleted:
|
|
432
|
+
"""Remove an SMTP login of the domain; its password stops working at once."""
|
|
433
|
+
return self._request("DELETE", "/v1/domains/%s/smtp/%s" % (_segment(domain), _segment(username)))
|
|
434
|
+
|
|
435
|
+
def send(
|
|
436
|
+
self,
|
|
437
|
+
from_: str,
|
|
438
|
+
to: Union[str, Sequence[str]],
|
|
439
|
+
subject: str,
|
|
440
|
+
text: str,
|
|
441
|
+
*,
|
|
442
|
+
cc: Union[str, Sequence[str], None] = None,
|
|
443
|
+
bcc: Union[str, Sequence[str], None] = None,
|
|
444
|
+
reply_to: Optional[str] = None,
|
|
445
|
+
in_reply_to: Optional[str] = None,
|
|
446
|
+
references: Union[str, Sequence[str], None] = None,
|
|
447
|
+
copy_to_owner: bool = False,
|
|
448
|
+
idempotency_key: Optional[str] = None,
|
|
449
|
+
) -> SendResult:
|
|
450
|
+
"""Send one plain-text message from an address at a domain of the account. Give an `idempotency_key` so a retry cannot send a second copy."""
|
|
451
|
+
|
|
452
|
+
def many(value: Union[str, Sequence[str]]) -> Union[str, List[str]]:
|
|
453
|
+
return value if isinstance(value, str) else list(value)
|
|
454
|
+
|
|
455
|
+
body: Dict[str, Any] = {"from": from_, "to": many(to), "subject": subject, "text": text}
|
|
456
|
+
if cc is not None:
|
|
457
|
+
body["cc"] = many(cc)
|
|
458
|
+
if bcc is not None:
|
|
459
|
+
body["bcc"] = many(bcc)
|
|
460
|
+
if reply_to is not None:
|
|
461
|
+
body["reply_to"] = reply_to
|
|
462
|
+
if in_reply_to is not None:
|
|
463
|
+
body["in_reply_to"] = in_reply_to
|
|
464
|
+
if references is not None:
|
|
465
|
+
body["references"] = many(references)
|
|
466
|
+
if copy_to_owner:
|
|
467
|
+
body["copy_to_owner"] = True
|
|
468
|
+
if idempotency_key is not None:
|
|
469
|
+
body["idempotency_key"] = idempotency_key
|
|
470
|
+
return self._request("POST", "/v1/send", body)
|
|
471
|
+
|
|
472
|
+
# ------------------------------------------------------------ logs
|
|
473
|
+
|
|
474
|
+
def logs(
|
|
475
|
+
self,
|
|
476
|
+
*,
|
|
477
|
+
domain: Optional[str] = None,
|
|
478
|
+
direction: Optional[LogDirection] = None,
|
|
479
|
+
status: Optional[LogStatus] = None,
|
|
480
|
+
address: Optional[str] = None,
|
|
481
|
+
before: Optional[str] = None,
|
|
482
|
+
limit: Optional[int] = None,
|
|
483
|
+
) -> LogsPage:
|
|
484
|
+
"""List the message log, newest first: one row for each message and recipient. `next_before` is `before` of the next page."""
|
|
485
|
+
query = {"domain": domain, "direction": direction, "status": status, "address": address, "before": before, "limit": limit}
|
|
486
|
+
return self._request("GET", "/v1/logs", query=query)
|
|
487
|
+
|
|
488
|
+
def iter_logs(
|
|
489
|
+
self,
|
|
490
|
+
*,
|
|
491
|
+
domain: Optional[str] = None,
|
|
492
|
+
direction: Optional[LogDirection] = None,
|
|
493
|
+
status: Optional[LogStatus] = None,
|
|
494
|
+
address: Optional[str] = None,
|
|
495
|
+
before: Optional[str] = None,
|
|
496
|
+
limit: Optional[int] = None,
|
|
497
|
+
) -> Iterator[LogEntry]:
|
|
498
|
+
"""Yields each row of the message log, newest first. It follows `next_before` to the end; `limit` is the size of one page."""
|
|
499
|
+
while True:
|
|
500
|
+
page = self.logs(domain=domain, direction=direction, status=status, address=address, before=before, limit=limit)
|
|
501
|
+
for entry in page.get("entries") or []:
|
|
502
|
+
yield entry
|
|
503
|
+
following = page.get("next_before")
|
|
504
|
+
if not following or following == before:
|
|
505
|
+
return
|
|
506
|
+
before = following
|
|
507
|
+
|
|
508
|
+
# ------------------------------------------------------------ notifications
|
|
509
|
+
|
|
510
|
+
def notifications(self) -> NotificationSettings:
|
|
511
|
+
"""Get the notification settings and the 20 newest notifications."""
|
|
512
|
+
return self._request("GET", "/v1/notifications")
|
|
513
|
+
|
|
514
|
+
def set_notifications(
|
|
515
|
+
self,
|
|
516
|
+
*,
|
|
517
|
+
email: Optional[bool] = None,
|
|
518
|
+
events: Optional[Sequence[NotificationEvent]] = None,
|
|
519
|
+
webhook_url: Union[str, None, _NotGiven] = NOT_GIVEN,
|
|
520
|
+
) -> NotificationSettings:
|
|
521
|
+
"""Change the notification settings. A field that is not given stays as it is; `webhook_url=None` removes the webhook."""
|
|
522
|
+
body: Dict[str, Any] = {}
|
|
523
|
+
if email is not None:
|
|
524
|
+
body["email"] = email
|
|
525
|
+
if events is not None:
|
|
526
|
+
body["events"] = list(events)
|
|
527
|
+
if not isinstance(webhook_url, _NotGiven):
|
|
528
|
+
body["webhook_url"] = webhook_url
|
|
529
|
+
return self._request("PUT", "/v1/notifications", body)
|
|
530
|
+
|
|
531
|
+
def test_webhook(self) -> WebhookTestResult:
|
|
532
|
+
"""Send a test webhook call now; the answer says what the webhook answered. Read `ok`."""
|
|
533
|
+
return self._request("POST", "/v1/notifications/test")
|
mxgate/py.typed
ADDED
|
File without changes
|