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/types.py
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""The shapes of the API answers, from `components.schemas` of the OpenAPI document.
|
|
2
|
+
|
|
3
|
+
These are `TypedDict` definitions: at run time each answer is a plain `dict`.
|
|
4
|
+
A field that is not in every answer is in a `total=False` class.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Dict, List, Literal, Optional, TypedDict
|
|
8
|
+
|
|
9
|
+
Plan = Literal["free", "paid"]
|
|
10
|
+
AccountStatus = Literal["active", "warned", "suspended"]
|
|
11
|
+
BillingInterval = Literal["month", "year"]
|
|
12
|
+
CheckResult = Literal["ok", "warn", "fail"]
|
|
13
|
+
LogDirection = Literal["in", "out"]
|
|
14
|
+
LogStatus = Literal["forwarded", "sent", "deferred", "bounced", "expired", "rejected"]
|
|
15
|
+
NotificationEvent = Literal["bounced", "expired", "rejected", "deferred"]
|
|
16
|
+
WebhookStatus = Literal["none", "pending", "delivered", "failed"]
|
|
17
|
+
StatusState = Literal["operational", "degraded", "outage", "unknown"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _ErrorBodyRequired(TypedDict):
|
|
21
|
+
error: str
|
|
22
|
+
message: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ErrorBody(_ErrorBodyRequired, total=False):
|
|
26
|
+
"""Every error body. Switch on `error`; show `message` to a person; `fix` is the next step."""
|
|
27
|
+
|
|
28
|
+
fix: str
|
|
29
|
+
upgrade_url: str
|
|
30
|
+
retry_after_seconds: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _IndexRequired(TypedDict):
|
|
34
|
+
name: str
|
|
35
|
+
version: str
|
|
36
|
+
about: str
|
|
37
|
+
docs: str
|
|
38
|
+
reference: str
|
|
39
|
+
openapi: str
|
|
40
|
+
auth: str
|
|
41
|
+
flow: List[str]
|
|
42
|
+
endpoints: Dict[str, str]
|
|
43
|
+
mx_hosts: List[str]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Index(_IndexRequired, total=False):
|
|
47
|
+
"""The answer to GET /: what the service is and what it has."""
|
|
48
|
+
|
|
49
|
+
mcp: str
|
|
50
|
+
sign_in: List[str]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Limits(TypedDict):
|
|
54
|
+
"""The limits of the plan. None means no limit."""
|
|
55
|
+
|
|
56
|
+
domains: Optional[int]
|
|
57
|
+
aliases_per_domain: Optional[int]
|
|
58
|
+
destinations_per_alias: int
|
|
59
|
+
send_per_day: Optional[int]
|
|
60
|
+
send_per_month: Optional[int]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AccountUsage(TypedDict):
|
|
64
|
+
"""What the account uses now."""
|
|
65
|
+
|
|
66
|
+
domains: int
|
|
67
|
+
sent_today: int
|
|
68
|
+
sent_this_month: int
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Billing(TypedDict):
|
|
72
|
+
"""The subscription to the paid plan, as the payment provider last reported it."""
|
|
73
|
+
|
|
74
|
+
status: Optional[str]
|
|
75
|
+
interval: Optional[BillingInterval]
|
|
76
|
+
current_period_end: Optional[str]
|
|
77
|
+
cancels_at_period_end: bool
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _AccountRequired(TypedDict):
|
|
81
|
+
email: str
|
|
82
|
+
plan: Plan
|
|
83
|
+
status: AccountStatus
|
|
84
|
+
status_reason: Optional[str]
|
|
85
|
+
send_hold: Optional[str]
|
|
86
|
+
limits: Limits
|
|
87
|
+
usage: AccountUsage
|
|
88
|
+
created_at: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Account(_AccountRequired, total=False):
|
|
92
|
+
"""An account: its plan, limits, usage and state."""
|
|
93
|
+
|
|
94
|
+
billing: Optional[Billing]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class SignupResult(TypedDict):
|
|
98
|
+
"""The answer to POST /v1/signup."""
|
|
99
|
+
|
|
100
|
+
api_key: str
|
|
101
|
+
account: Account
|
|
102
|
+
mailed: bool
|
|
103
|
+
message: str
|
|
104
|
+
next_steps: List[str]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class LoginResult(TypedDict):
|
|
108
|
+
"""The answer to POST /v1/login. The same for every address, with or without an account."""
|
|
109
|
+
|
|
110
|
+
message: str
|
|
111
|
+
next_step: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Session(TypedDict):
|
|
115
|
+
"""The answer to POST /v1/login/confirm: a session for the web dashboard."""
|
|
116
|
+
|
|
117
|
+
session_token: str
|
|
118
|
+
expires_at: str
|
|
119
|
+
account: Account
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class LogoutResult(TypedDict):
|
|
123
|
+
"""The answer to POST /v1/logout."""
|
|
124
|
+
|
|
125
|
+
signed_out: bool
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Key(TypedDict):
|
|
129
|
+
"""One API key of the account. The key itself is not shown."""
|
|
130
|
+
|
|
131
|
+
prefix: str
|
|
132
|
+
created_at: str
|
|
133
|
+
last_used_at: Optional[str]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class KeyList(TypedDict):
|
|
137
|
+
"""The answer to GET /v1/keys."""
|
|
138
|
+
|
|
139
|
+
keys: List[Key]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ConnectedApp(TypedDict):
|
|
143
|
+
"""An app that the owner connected to the account through OAuth."""
|
|
144
|
+
|
|
145
|
+
id: int
|
|
146
|
+
name: str
|
|
147
|
+
client_id: str
|
|
148
|
+
connected_at: str
|
|
149
|
+
last_used_at: Optional[str]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ConnectedAppList(TypedDict):
|
|
153
|
+
"""The answer to GET /v1/oauth/apps."""
|
|
154
|
+
|
|
155
|
+
apps: List[ConnectedApp]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class RemovedApp(TypedDict):
|
|
159
|
+
"""The answer to DELETE /v1/oauth/apps/{id}."""
|
|
160
|
+
|
|
161
|
+
removed: int
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class RecoverResult(TypedDict):
|
|
165
|
+
"""The answer to POST /v1/keys/recover. The same for every address, with or without an account."""
|
|
166
|
+
|
|
167
|
+
message: str
|
|
168
|
+
next_step: str
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class NewKey(TypedDict):
|
|
172
|
+
"""A new API key from POST /v1/keys/recover/confirm or POST /v1/keys/rotate."""
|
|
173
|
+
|
|
174
|
+
api_key: str
|
|
175
|
+
account: Account
|
|
176
|
+
revoked_keys: int
|
|
177
|
+
message: str
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class Checkout(TypedDict):
|
|
181
|
+
"""The answer to POST /v1/billing/checkout: a page where a person pays."""
|
|
182
|
+
|
|
183
|
+
url: str
|
|
184
|
+
interval: BillingInterval
|
|
185
|
+
price_usd: float
|
|
186
|
+
message: str
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class Portal(TypedDict):
|
|
190
|
+
"""The answer to POST /v1/billing/portal: a page to change the card, see invoices, or cancel."""
|
|
191
|
+
|
|
192
|
+
url: str
|
|
193
|
+
message: str
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class _DnsRecordRequired(TypedDict):
|
|
197
|
+
type: Literal["MX", "TXT"]
|
|
198
|
+
name: str
|
|
199
|
+
value: str
|
|
200
|
+
required: bool
|
|
201
|
+
purpose: str
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class DnsRecord(_DnsRecordRequired, total=False):
|
|
205
|
+
"""One DNS record to publish at the DNS host of the domain."""
|
|
206
|
+
|
|
207
|
+
priority: int
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class _DomainChecksRequired(TypedDict):
|
|
211
|
+
ownership: CheckResult
|
|
212
|
+
mx: CheckResult
|
|
213
|
+
mx_found: List[str]
|
|
214
|
+
checked_at: str
|
|
215
|
+
problems: List[str]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class DomainChecks(_DomainChecksRequired, total=False):
|
|
219
|
+
"""The result of the last DNS check of a domain."""
|
|
220
|
+
|
|
221
|
+
dkim: CheckResult
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class SmtpPort(TypedDict):
|
|
225
|
+
"""One port of the send-as SMTP host."""
|
|
226
|
+
|
|
227
|
+
port: int
|
|
228
|
+
security: Literal["starttls", "tls"]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class SmtpCredential(TypedDict):
|
|
232
|
+
"""One SMTP login of a domain. The password is not shown."""
|
|
233
|
+
|
|
234
|
+
username: str
|
|
235
|
+
created_at: str
|
|
236
|
+
last_used_at: Optional[str]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class SendAs(TypedDict):
|
|
240
|
+
"""Send-as for one domain: sending mail as addresses of the domain, through smtp.mxgate.io."""
|
|
241
|
+
|
|
242
|
+
ready: bool
|
|
243
|
+
reasons: List[str]
|
|
244
|
+
host: str
|
|
245
|
+
ports: List[SmtpPort]
|
|
246
|
+
credentials: List[SmtpCredential]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class SendAsResult(TypedDict):
|
|
250
|
+
"""The answer to GET /v1/domains/{domain}/smtp."""
|
|
251
|
+
|
|
252
|
+
send_as: SendAs
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class SmtpCredentialCreated(TypedDict):
|
|
256
|
+
"""A new SMTP login. Returned once, when the credential is made."""
|
|
257
|
+
|
|
258
|
+
credential: SmtpCredential
|
|
259
|
+
password: str
|
|
260
|
+
host: str
|
|
261
|
+
ports: List[SmtpPort]
|
|
262
|
+
message: str
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class Domain(TypedDict):
|
|
266
|
+
"""One domain of the account, with the DNS records it needs and the last check."""
|
|
267
|
+
|
|
268
|
+
name: str
|
|
269
|
+
status: Literal["pending", "verified"]
|
|
270
|
+
created_at: str
|
|
271
|
+
verified_at: Optional[str]
|
|
272
|
+
records: List[DnsRecord]
|
|
273
|
+
checks: Optional[DomainChecks]
|
|
274
|
+
aliases: int
|
|
275
|
+
send_as: SendAs
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class DomainList(TypedDict):
|
|
279
|
+
"""The answer to GET /v1/domains."""
|
|
280
|
+
|
|
281
|
+
domains: List[Domain]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class DomainResult(TypedDict):
|
|
285
|
+
"""The answer to GET /v1/domains/{domain}."""
|
|
286
|
+
|
|
287
|
+
domain: Domain
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class _AddDomainResultRequired(TypedDict):
|
|
291
|
+
domain: Domain
|
|
292
|
+
created: bool
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class AddDomainResult(_AddDomainResultRequired, total=False):
|
|
296
|
+
"""The answer to POST /v1/domains."""
|
|
297
|
+
|
|
298
|
+
next_step: str
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class VerifyResult(TypedDict):
|
|
302
|
+
"""The answer to POST /v1/domains/{domain}/verify."""
|
|
303
|
+
|
|
304
|
+
domain: Domain
|
|
305
|
+
verified: bool
|
|
306
|
+
message: str
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class Deleted(TypedDict):
|
|
310
|
+
"""The answer to a DELETE call."""
|
|
311
|
+
|
|
312
|
+
deleted: str
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class AliasTarget(TypedDict):
|
|
316
|
+
"""One mailbox that an alias forwards to."""
|
|
317
|
+
|
|
318
|
+
email: str
|
|
319
|
+
confirmed: bool
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class Alias(TypedDict):
|
|
323
|
+
"""One alias: an address at a domain and the mailboxes it forwards to."""
|
|
324
|
+
|
|
325
|
+
alias: str
|
|
326
|
+
address: str
|
|
327
|
+
domain: str
|
|
328
|
+
to: List[AliasTarget]
|
|
329
|
+
active: bool
|
|
330
|
+
inactive_reasons: List[str]
|
|
331
|
+
created_at: str
|
|
332
|
+
updated_at: str
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class AliasList(TypedDict):
|
|
336
|
+
"""The answer to GET /v1/domains/{domain}/aliases."""
|
|
337
|
+
|
|
338
|
+
aliases: List[Alias]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class SetAliasResult(TypedDict):
|
|
342
|
+
"""The answer to PUT /v1/domains/{domain}/aliases/{alias}."""
|
|
343
|
+
|
|
344
|
+
alias: Alias
|
|
345
|
+
created: bool
|
|
346
|
+
confirmation_mailed_to: List[str]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class Destination(TypedDict):
|
|
350
|
+
"""One mailbox that the account can forward to."""
|
|
351
|
+
|
|
352
|
+
email: str
|
|
353
|
+
confirmed: bool
|
|
354
|
+
created_at: str
|
|
355
|
+
confirmed_at: Optional[str]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class DestinationList(TypedDict):
|
|
359
|
+
"""The answer to GET /v1/destinations."""
|
|
360
|
+
|
|
361
|
+
destinations: List[Destination]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class DestinationResult(TypedDict):
|
|
365
|
+
"""The answer to POST /v1/destinations."""
|
|
366
|
+
|
|
367
|
+
destination: Destination
|
|
368
|
+
mailed: bool
|
|
369
|
+
message: str
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# The answer to POST /v1/send. "from" is a Python keyword, so this one uses the
|
|
373
|
+
# call form of TypedDict.
|
|
374
|
+
SendResult = TypedDict(
|
|
375
|
+
"SendResult",
|
|
376
|
+
{
|
|
377
|
+
"message_id": str,
|
|
378
|
+
"from": str,
|
|
379
|
+
"to": List[str],
|
|
380
|
+
"cc": List[str],
|
|
381
|
+
"bcc": List[str],
|
|
382
|
+
"status": Literal["queued"],
|
|
383
|
+
"replayed": bool,
|
|
384
|
+
"message": str,
|
|
385
|
+
},
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class LogEntry(TypedDict):
|
|
390
|
+
"""One row of the per-message log. No subject and no body are stored."""
|
|
391
|
+
|
|
392
|
+
received_at: str
|
|
393
|
+
updated_at: str
|
|
394
|
+
direction: LogDirection
|
|
395
|
+
status: LogStatus
|
|
396
|
+
sender: Optional[str]
|
|
397
|
+
recipient: str
|
|
398
|
+
destination: str
|
|
399
|
+
domain: str
|
|
400
|
+
reason: Optional[str]
|
|
401
|
+
explanation: Optional[str]
|
|
402
|
+
dsn: Optional[str]
|
|
403
|
+
relay: Optional[str]
|
|
404
|
+
client: Optional[str]
|
|
405
|
+
message_id: Optional[str]
|
|
406
|
+
attempts: int
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class LogsPage(TypedDict):
|
|
410
|
+
"""The answer to GET /v1/logs."""
|
|
411
|
+
|
|
412
|
+
entries: List[LogEntry]
|
|
413
|
+
next_before: Optional[str]
|
|
414
|
+
retention_days: int
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class NotificationRecent(TypedDict):
|
|
418
|
+
"""One recent notification and what happened to it."""
|
|
419
|
+
|
|
420
|
+
event: NotificationEvent
|
|
421
|
+
created_at: str
|
|
422
|
+
recipient: str
|
|
423
|
+
webhook_status: WebhookStatus
|
|
424
|
+
webhook_attempts: int
|
|
425
|
+
webhook_last_error: Optional[str]
|
|
426
|
+
emailed: bool
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class _NotificationSettingsRequired(TypedDict):
|
|
430
|
+
email: bool
|
|
431
|
+
events: List[NotificationEvent]
|
|
432
|
+
webhook_url: Optional[str]
|
|
433
|
+
webhook_secret: Optional[str]
|
|
434
|
+
signature: str
|
|
435
|
+
recent: List[NotificationRecent]
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class NotificationSettings(_NotificationSettingsRequired, total=False):
|
|
439
|
+
"""The notification settings of the account and the 20 newest notifications."""
|
|
440
|
+
|
|
441
|
+
message: str
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class WebhookTestResult(TypedDict):
|
|
445
|
+
"""The answer to POST /v1/notifications/test."""
|
|
446
|
+
|
|
447
|
+
ok: bool
|
|
448
|
+
status: Optional[int]
|
|
449
|
+
error: Optional[str]
|
|
450
|
+
message: str
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class WebhookEvent(TypedDict):
|
|
454
|
+
"""The JSON body that mxgate posts to your webhook URL.
|
|
455
|
+
|
|
456
|
+
`data` is the log row of the message. The test call (type `test`) has an
|
|
457
|
+
object with one `message` text instead.
|
|
458
|
+
"""
|
|
459
|
+
|
|
460
|
+
id: str
|
|
461
|
+
type: Literal["message.bounced", "message.expired", "message.rejected", "message.deferred", "test"]
|
|
462
|
+
created_at: str
|
|
463
|
+
data: LogEntry
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class StatusCheck(TypedDict):
|
|
467
|
+
"""One check of the outside monitor."""
|
|
468
|
+
|
|
469
|
+
name: str
|
|
470
|
+
ok: Optional[bool]
|
|
471
|
+
detail: str
|
|
472
|
+
last_check_at: Optional[str]
|
|
473
|
+
last_ok_at: Optional[str]
|
|
474
|
+
success_rate: Optional[float]
|
|
475
|
+
daily: List[Optional[float]]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
class Status(TypedDict):
|
|
479
|
+
"""The health of the service, as a monitor on another machine sees it."""
|
|
480
|
+
|
|
481
|
+
state: StatusState
|
|
482
|
+
updated_at: Optional[str]
|
|
483
|
+
window_days: int
|
|
484
|
+
days: List[str]
|
|
485
|
+
checks: List[StatusCheck]
|
|
486
|
+
stale: bool
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
__all__ = [
|
|
490
|
+
"Account",
|
|
491
|
+
"AccountStatus",
|
|
492
|
+
"AccountUsage",
|
|
493
|
+
"AddDomainResult",
|
|
494
|
+
"Alias",
|
|
495
|
+
"AliasList",
|
|
496
|
+
"AliasTarget",
|
|
497
|
+
"Billing",
|
|
498
|
+
"BillingInterval",
|
|
499
|
+
"CheckResult",
|
|
500
|
+
"Checkout",
|
|
501
|
+
"ConnectedApp",
|
|
502
|
+
"ConnectedAppList",
|
|
503
|
+
"Deleted",
|
|
504
|
+
"Destination",
|
|
505
|
+
"DestinationList",
|
|
506
|
+
"DestinationResult",
|
|
507
|
+
"DnsRecord",
|
|
508
|
+
"Domain",
|
|
509
|
+
"DomainChecks",
|
|
510
|
+
"DomainList",
|
|
511
|
+
"DomainResult",
|
|
512
|
+
"ErrorBody",
|
|
513
|
+
"Index",
|
|
514
|
+
"Key",
|
|
515
|
+
"KeyList",
|
|
516
|
+
"Limits",
|
|
517
|
+
"LogDirection",
|
|
518
|
+
"LogEntry",
|
|
519
|
+
"LogStatus",
|
|
520
|
+
"LoginResult",
|
|
521
|
+
"LogoutResult",
|
|
522
|
+
"LogsPage",
|
|
523
|
+
"NewKey",
|
|
524
|
+
"NotificationEvent",
|
|
525
|
+
"NotificationRecent",
|
|
526
|
+
"NotificationSettings",
|
|
527
|
+
"Plan",
|
|
528
|
+
"Portal",
|
|
529
|
+
"RecoverResult",
|
|
530
|
+
"RemovedApp",
|
|
531
|
+
"SendAs",
|
|
532
|
+
"SendAsResult",
|
|
533
|
+
"SendResult",
|
|
534
|
+
"Session",
|
|
535
|
+
"SetAliasResult",
|
|
536
|
+
"SignupResult",
|
|
537
|
+
"SmtpCredential",
|
|
538
|
+
"SmtpCredentialCreated",
|
|
539
|
+
"SmtpPort",
|
|
540
|
+
"Status",
|
|
541
|
+
"StatusCheck",
|
|
542
|
+
"StatusState",
|
|
543
|
+
"VerifyResult",
|
|
544
|
+
"WebhookEvent",
|
|
545
|
+
"WebhookStatus",
|
|
546
|
+
"WebhookTestResult",
|
|
547
|
+
]
|
mxgate/webhooks.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""The check of the signature of a webhook call from mxgate.
|
|
2
|
+
|
|
3
|
+
mxgate sends the header `mxgate-signature: t=<unix seconds>,v1=<hex>`.
|
|
4
|
+
`v1` is the HMAC-SHA256 of the text `"<t>.<raw body>"` with the webhook secret.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import time
|
|
10
|
+
from typing import List, Optional, Union
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def verify_webhook(
|
|
14
|
+
secret: str,
|
|
15
|
+
signature_header: str,
|
|
16
|
+
raw_body: Union[bytes, str],
|
|
17
|
+
*,
|
|
18
|
+
tolerance_seconds: int = 300,
|
|
19
|
+
now: Optional[float] = None,
|
|
20
|
+
) -> bool:
|
|
21
|
+
"""Returns True when the signature is correct and `t` is not older than the tolerance.
|
|
22
|
+
|
|
23
|
+
`secret` is `webhook_secret` from the notification settings (`whsec_...`).
|
|
24
|
+
`signature_header` is the value of the `mxgate-signature` header.
|
|
25
|
+
`raw_body` is the body of the call as it came, before a JSON parser changed it.
|
|
26
|
+
`now` is the time in unix seconds; the default is the clock of the machine.
|
|
27
|
+
"""
|
|
28
|
+
if not secret or not isinstance(signature_header, str):
|
|
29
|
+
return False
|
|
30
|
+
timestamp: Optional[str] = None
|
|
31
|
+
signatures: List[str] = []
|
|
32
|
+
for part in signature_header.split(","):
|
|
33
|
+
name, sep, value = part.strip().partition("=")
|
|
34
|
+
if not sep:
|
|
35
|
+
continue
|
|
36
|
+
if name == "t" and timestamp is None:
|
|
37
|
+
timestamp = value
|
|
38
|
+
elif name == "v1":
|
|
39
|
+
signatures.append(value)
|
|
40
|
+
# isascii: isdigit() is also true for digits that are not 0 to 9.
|
|
41
|
+
if timestamp is None or not (timestamp.isascii() and timestamp.isdigit()) or not signatures:
|
|
42
|
+
return False
|
|
43
|
+
current = time.time() if now is None else now
|
|
44
|
+
if current - int(timestamp) > tolerance_seconds:
|
|
45
|
+
return False
|
|
46
|
+
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else bytes(raw_body)
|
|
47
|
+
expected = hmac.new(secret.encode("utf-8"), timestamp.encode("ascii") + b"." + body, hashlib.sha256).hexdigest().encode("ascii")
|
|
48
|
+
ok = False
|
|
49
|
+
for signature in signatures:
|
|
50
|
+
# No early exit: each candidate gets the same constant-time compare.
|
|
51
|
+
if hmac.compare_digest(expected, signature.strip().lower().encode("utf-8")):
|
|
52
|
+
ok = True
|
|
53
|
+
return ok
|