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/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