sendora 2.0.2__tar.gz → 2.2.0__tar.gz
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.
- {sendora-2.0.2 → sendora-2.2.0}/CHANGELOG.md +16 -0
- {sendora-2.0.2 → sendora-2.2.0}/PKG-INFO +48 -19
- {sendora-2.0.2 → sendora-2.2.0}/README.md +47 -18
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_account.py +5 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_answers.py +1 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_version.py +1 -1
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/__init__.py +3 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/broadcasts.py +15 -7
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/messages.py +2 -2
- sendora-2.2.0/src/sendora/resources/tracking_domains.py +236 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/skills/sendora/SKILL.md +2 -2
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/types.py +312 -2
- {sendora-2.0.2 → sendora-2.2.0}/.gitignore +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/LICENSE +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/pyproject.toml +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/__init__.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_client.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_errors.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_json.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_keys.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_model.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_options.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_pages.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_retry.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_transport.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_webhooks.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_wire.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/py.typed +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/domains.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/email.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/erasures.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/inbound.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/inbound_domains.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/server.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/servers.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/streams.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/suppressions.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/tokens.py +0 -0
- {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/webhooks.py +0 -0
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.2.0
|
|
4
|
+
|
|
5
|
+
- Each `MessageRecipient` has `tracked`, `opens`, `clicks`, `first_open_at`
|
|
6
|
+
and `first_human_open_at`.
|
|
7
|
+
- `messages.get` returns `tracking`, with `recording_opens`,
|
|
8
|
+
`recording_clicks` and `skipped`. Its timeline holds `Open` and `Click`
|
|
9
|
+
events, each with its `tracking`.
|
|
10
|
+
- `broadcasts.get` returns `BroadcastDetail`, a `Broadcast` with `opened`,
|
|
11
|
+
`opened_by_people`, `clicked`, `clicked_by_people` and `links`.
|
|
12
|
+
|
|
13
|
+
## 2.1.0
|
|
14
|
+
|
|
15
|
+
- `SendoraAccount.tracking_domains` and `AsyncSendoraAccount.tracking_domains`
|
|
16
|
+
add, list, read, check and remove tracking domains, with the models
|
|
17
|
+
`TrackingDomain` and `TrackingDomainList` and five new error codes.
|
|
18
|
+
|
|
3
19
|
## 2.0.2
|
|
4
20
|
|
|
5
21
|
- The README says what `payment_required` means in each of its four cases:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sendora
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: The Python client for Sendora's email API.
|
|
5
5
|
Project-URL: Homepage, https://sendora.se/docs
|
|
6
6
|
License-Expression: MIT
|
|
@@ -35,7 +35,7 @@ Two kinds of key, two clients. `Sendora` takes a server key (`sk_…`) and
|
|
|
35
35
|
does everything inside one server: sending, broadcasts, the log, received
|
|
36
36
|
mail, streams, suppressions, tokens, webhooks and the server's own limits.
|
|
37
37
|
`SendoraAccount` takes an account key (`ak_…`) and manages the account:
|
|
38
|
-
its servers, their keys
|
|
38
|
+
its servers, their keys, its sending and tracking domains. `AsyncSendora` and
|
|
39
39
|
`AsyncSendoraAccount` are the same clients for asyncio. Both keys are
|
|
40
40
|
secrets: keep them on the server and never ship them to a browser.
|
|
41
41
|
|
|
@@ -113,16 +113,16 @@ for result in batch.results:
|
|
|
113
113
|
|
|
114
114
|
## Messages
|
|
115
115
|
|
|
116
|
-
| Call | Answers
|
|
117
|
-
| --------------------------------------------------------------- |
|
|
118
|
-
| `sendora.messages.get(message_id)` | The message with its recipients, its attachments described and its events
|
|
119
|
-
| `sendora.messages.search(**filters)` | `MessagePage` with `messages` and `next`, newest first
|
|
120
|
-
| `sendora.messages.search_all(**filters)` | Every match, page by page, as an iterator
|
|
121
|
-
| `sendora.inbound.get(inbound_message_id)` | A received message: envelope, parties, headers, text, HTML, attachments described
|
|
122
|
-
| `sendora.inbound.search(**filters)` | `InboundPage` of received mail, newest first
|
|
123
|
-
| `sendora.inbound.search_all(**filters)` | Every received match, page by page, as an iterator
|
|
124
|
-
| `sendora.inbound.raw(inbound_message_id)` | The raw message as `bytes`, `message/rfc822`
|
|
125
|
-
| `sendora.inbound.attachment(inbound_message_id, attachment_id)` | An attachment's `bytes`
|
|
116
|
+
| Call | Answers |
|
|
117
|
+
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
118
|
+
| `sendora.messages.get(message_id)` | The message with its recipients, `tracking`, its attachments described and its events |
|
|
119
|
+
| `sendora.messages.search(**filters)` | `MessagePage` with `messages` and `next`, newest first |
|
|
120
|
+
| `sendora.messages.search_all(**filters)` | Every match, page by page, as an iterator |
|
|
121
|
+
| `sendora.inbound.get(inbound_message_id)` | A received message: envelope, parties, headers, text, HTML, attachments described |
|
|
122
|
+
| `sendora.inbound.search(**filters)` | `InboundPage` of received mail, newest first |
|
|
123
|
+
| `sendora.inbound.search_all(**filters)` | Every received match, page by page, as an iterator |
|
|
124
|
+
| `sendora.inbound.raw(inbound_message_id)` | The raw message as `bytes`, `message/rfc822` |
|
|
125
|
+
| `sendora.inbound.attachment(inbound_message_id, attachment_id)` | An attachment's `bytes` |
|
|
126
126
|
|
|
127
127
|
A stream can also receive on a domain of yours: every address on it,
|
|
128
128
|
whatever the local part, lands on the stream. Claim it with the stream
|
|
@@ -146,6 +146,11 @@ list of them), `from_` and `to` as an aware `datetime` or ISO 8601,
|
|
|
146
146
|
`limit` (1 to 100, 50 by default) and `after`, the previous page's
|
|
147
147
|
`next`. Bodies are never returned; the log keeps messages for 13 months.
|
|
148
148
|
|
|
149
|
+
Where mail is tracked, each recipient has `tracked`, `opens`, `clicks`,
|
|
150
|
+
`first_open_at` and `first_human_open_at`. `messages.get` adds `tracking`, and
|
|
151
|
+
its timeline holds `Open` and `Click` events with their labels. Tracking is
|
|
152
|
+
not open to accounts yet.
|
|
153
|
+
|
|
149
154
|
```python
|
|
150
155
|
from datetime import UTC, datetime
|
|
151
156
|
|
|
@@ -198,12 +203,12 @@ message's `substitutions`, up to 20 strings, replace `{{ key }}` in the
|
|
|
198
203
|
subject, the text and the HTML, escaped in the HTML; a key the content
|
|
199
204
|
names that a message lacks raises `substitution_missing`.
|
|
200
205
|
|
|
201
|
-
| Call | Answers
|
|
202
|
-
| ----------------------------------------- |
|
|
203
|
-
| `sendora.broadcasts.send(**broadcast)` | `AcceptedBroadcast` with `broadcast_id`, `total`, `suppressed` and `test`
|
|
204
|
-
| `sendora.broadcasts.get(broadcast_id)` | The broadcast with `status`, `released` and `
|
|
205
|
-
| `sendora.broadcasts.list(limit=, after=)` | `BroadcastPage` with `broadcasts` and `next`, newest first
|
|
206
|
-
| `sendora.broadcasts.cancel(broadcast_id)` | The cancelled broadcast; what had not left counts as failed
|
|
206
|
+
| Call | Answers |
|
|
207
|
+
| ----------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
208
|
+
| `sendora.broadcasts.send(**broadcast)` | `AcceptedBroadcast` with `broadcast_id`, `total`, `suppressed` and `test` |
|
|
209
|
+
| `sendora.broadcasts.get(broadcast_id)` | The broadcast with `status`, `released`, `failed`, `opened`, `clicked` and `links` |
|
|
210
|
+
| `sendora.broadcasts.list(limit=, after=)` | `BroadcastPage` with `broadcasts` and `next`, newest first |
|
|
211
|
+
| `sendora.broadcasts.cancel(broadcast_id)` | The cancelled broadcast; what had not left counts as failed |
|
|
207
212
|
|
|
208
213
|
```python
|
|
209
214
|
accepted = sendora.broadcasts.send(
|
|
@@ -436,6 +441,25 @@ print(domain.return_path.type, domain.return_path.host, domain.return_path.value
|
|
|
436
441
|
print(domain.dkim.type, domain.dkim.host, domain.dkim.value)
|
|
437
442
|
```
|
|
438
443
|
|
|
444
|
+
A tracking domain is the host that tracked links and the open pixel use: a
|
|
445
|
+
subdomain of a verified sending domain, with one CNAME record to add. CAA
|
|
446
|
+
records above it must let Let's Encrypt issue its certificate. Tracking is
|
|
447
|
+
not open to accounts yet, so a host carries no mail until it is. A host
|
|
448
|
+
whose records are not right within 7 days goes.
|
|
449
|
+
|
|
450
|
+
| Call | Answers |
|
|
451
|
+
| ----------------------------------------------------- | --------------------------------------------------------------------- |
|
|
452
|
+
| `account.tracking_domains.create(host=)` | `TrackingDomain` with `cname` to add |
|
|
453
|
+
| `account.tracking_domains.list()` | `TrackingDomainList` with `tracking_domains` |
|
|
454
|
+
| `account.tracking_domains.get(tracking_domain_id)` | One tracking domain with what the last check found |
|
|
455
|
+
| `account.tracking_domains.verify(tracking_domain_id)` | The tracking domain as it stands: `state`, `cname.check`, `caa.check` |
|
|
456
|
+
| `account.tracking_domains.delete(tracking_domain_id)` | `None`; new mail stops using it at once |
|
|
457
|
+
|
|
458
|
+
```python
|
|
459
|
+
tracking = account.tracking_domains.create(host="links.example.se")
|
|
460
|
+
print(tracking.cname.type, tracking.cname.host, tracking.cname.value)
|
|
461
|
+
```
|
|
462
|
+
|
|
439
463
|
An erasure removes one person's data from every server of the account,
|
|
440
464
|
as a request under the right to erasure asks. The content of every
|
|
441
465
|
message to or from the address, sent and received, is deleted, and the
|
|
@@ -508,6 +532,11 @@ except SendoraError as error:
|
|
|
508
532
|
| `not_found` | 404 | No such id on this server, or no such suppressed address. |
|
|
509
533
|
| `domain_exists` | 409 | The account already has the domain; `existing_id` names it. |
|
|
510
534
|
| `domain_reserved` | 400 | The name is Sendora's own; no account can receive on it. |
|
|
535
|
+
| `tracking_domain_exists` | 409 | The account already has the host, or one on that sending domain; `existing_id` names it. |
|
|
536
|
+
| `tracking_domain_limit` | 409 | The account has as many tracking domains as it may. |
|
|
537
|
+
| `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself; choose a subdomain. |
|
|
538
|
+
| `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of the account. |
|
|
539
|
+
| `tracking_host_taken` | 409 | Another account holds the host. |
|
|
511
540
|
| `inbound_domain_exists` | 409 | The stream already has a domain (`existing_id` names it), or another account holds the name verified. |
|
|
512
541
|
| `webhook_exists` | 409 | The server already has a webhook for that URL; `existing_id` names it. |
|
|
513
542
|
| `stream_exists` | 409 | The server already has a stream with that name; `existing_id` names it. |
|
|
@@ -574,7 +603,7 @@ seconds of waiting in all. A longer `retry_after` is raised at once for
|
|
|
574
603
|
you to schedule. A call that creates something (`streams.create`,
|
|
575
604
|
`tokens.create`, `webhooks.create`, `webhooks.create_secret`,
|
|
576
605
|
`webhooks.replay`, `inbound_domains.create`, `servers.create`,
|
|
577
|
-
`servers.tokens.create`, `domains.create`) is repeated only after a
|
|
606
|
+
`servers.tokens.create`, `domains.create`, `tracking_domains.create`) is repeated only after a
|
|
578
607
|
`rate_limited` or `sending_disabled` answer, never after a lost connection
|
|
579
608
|
or a server error, so nothing is created twice. Nothing else is retried.
|
|
580
609
|
|
|
@@ -13,7 +13,7 @@ Two kinds of key, two clients. `Sendora` takes a server key (`sk_…`) and
|
|
|
13
13
|
does everything inside one server: sending, broadcasts, the log, received
|
|
14
14
|
mail, streams, suppressions, tokens, webhooks and the server's own limits.
|
|
15
15
|
`SendoraAccount` takes an account key (`ak_…`) and manages the account:
|
|
16
|
-
its servers, their keys
|
|
16
|
+
its servers, their keys, its sending and tracking domains. `AsyncSendora` and
|
|
17
17
|
`AsyncSendoraAccount` are the same clients for asyncio. Both keys are
|
|
18
18
|
secrets: keep them on the server and never ship them to a browser.
|
|
19
19
|
|
|
@@ -91,16 +91,16 @@ for result in batch.results:
|
|
|
91
91
|
|
|
92
92
|
## Messages
|
|
93
93
|
|
|
94
|
-
| Call | Answers
|
|
95
|
-
| --------------------------------------------------------------- |
|
|
96
|
-
| `sendora.messages.get(message_id)` | The message with its recipients, its attachments described and its events
|
|
97
|
-
| `sendora.messages.search(**filters)` | `MessagePage` with `messages` and `next`, newest first
|
|
98
|
-
| `sendora.messages.search_all(**filters)` | Every match, page by page, as an iterator
|
|
99
|
-
| `sendora.inbound.get(inbound_message_id)` | A received message: envelope, parties, headers, text, HTML, attachments described
|
|
100
|
-
| `sendora.inbound.search(**filters)` | `InboundPage` of received mail, newest first
|
|
101
|
-
| `sendora.inbound.search_all(**filters)` | Every received match, page by page, as an iterator
|
|
102
|
-
| `sendora.inbound.raw(inbound_message_id)` | The raw message as `bytes`, `message/rfc822`
|
|
103
|
-
| `sendora.inbound.attachment(inbound_message_id, attachment_id)` | An attachment's `bytes`
|
|
94
|
+
| Call | Answers |
|
|
95
|
+
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
96
|
+
| `sendora.messages.get(message_id)` | The message with its recipients, `tracking`, its attachments described and its events |
|
|
97
|
+
| `sendora.messages.search(**filters)` | `MessagePage` with `messages` and `next`, newest first |
|
|
98
|
+
| `sendora.messages.search_all(**filters)` | Every match, page by page, as an iterator |
|
|
99
|
+
| `sendora.inbound.get(inbound_message_id)` | A received message: envelope, parties, headers, text, HTML, attachments described |
|
|
100
|
+
| `sendora.inbound.search(**filters)` | `InboundPage` of received mail, newest first |
|
|
101
|
+
| `sendora.inbound.search_all(**filters)` | Every received match, page by page, as an iterator |
|
|
102
|
+
| `sendora.inbound.raw(inbound_message_id)` | The raw message as `bytes`, `message/rfc822` |
|
|
103
|
+
| `sendora.inbound.attachment(inbound_message_id, attachment_id)` | An attachment's `bytes` |
|
|
104
104
|
|
|
105
105
|
A stream can also receive on a domain of yours: every address on it,
|
|
106
106
|
whatever the local part, lands on the stream. Claim it with the stream
|
|
@@ -124,6 +124,11 @@ list of them), `from_` and `to` as an aware `datetime` or ISO 8601,
|
|
|
124
124
|
`limit` (1 to 100, 50 by default) and `after`, the previous page's
|
|
125
125
|
`next`. Bodies are never returned; the log keeps messages for 13 months.
|
|
126
126
|
|
|
127
|
+
Where mail is tracked, each recipient has `tracked`, `opens`, `clicks`,
|
|
128
|
+
`first_open_at` and `first_human_open_at`. `messages.get` adds `tracking`, and
|
|
129
|
+
its timeline holds `Open` and `Click` events with their labels. Tracking is
|
|
130
|
+
not open to accounts yet.
|
|
131
|
+
|
|
127
132
|
```python
|
|
128
133
|
from datetime import UTC, datetime
|
|
129
134
|
|
|
@@ -176,12 +181,12 @@ message's `substitutions`, up to 20 strings, replace `{{ key }}` in the
|
|
|
176
181
|
subject, the text and the HTML, escaped in the HTML; a key the content
|
|
177
182
|
names that a message lacks raises `substitution_missing`.
|
|
178
183
|
|
|
179
|
-
| Call | Answers
|
|
180
|
-
| ----------------------------------------- |
|
|
181
|
-
| `sendora.broadcasts.send(**broadcast)` | `AcceptedBroadcast` with `broadcast_id`, `total`, `suppressed` and `test`
|
|
182
|
-
| `sendora.broadcasts.get(broadcast_id)` | The broadcast with `status`, `released` and `
|
|
183
|
-
| `sendora.broadcasts.list(limit=, after=)` | `BroadcastPage` with `broadcasts` and `next`, newest first
|
|
184
|
-
| `sendora.broadcasts.cancel(broadcast_id)` | The cancelled broadcast; what had not left counts as failed
|
|
184
|
+
| Call | Answers |
|
|
185
|
+
| ----------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
186
|
+
| `sendora.broadcasts.send(**broadcast)` | `AcceptedBroadcast` with `broadcast_id`, `total`, `suppressed` and `test` |
|
|
187
|
+
| `sendora.broadcasts.get(broadcast_id)` | The broadcast with `status`, `released`, `failed`, `opened`, `clicked` and `links` |
|
|
188
|
+
| `sendora.broadcasts.list(limit=, after=)` | `BroadcastPage` with `broadcasts` and `next`, newest first |
|
|
189
|
+
| `sendora.broadcasts.cancel(broadcast_id)` | The cancelled broadcast; what had not left counts as failed |
|
|
185
190
|
|
|
186
191
|
```python
|
|
187
192
|
accepted = sendora.broadcasts.send(
|
|
@@ -414,6 +419,25 @@ print(domain.return_path.type, domain.return_path.host, domain.return_path.value
|
|
|
414
419
|
print(domain.dkim.type, domain.dkim.host, domain.dkim.value)
|
|
415
420
|
```
|
|
416
421
|
|
|
422
|
+
A tracking domain is the host that tracked links and the open pixel use: a
|
|
423
|
+
subdomain of a verified sending domain, with one CNAME record to add. CAA
|
|
424
|
+
records above it must let Let's Encrypt issue its certificate. Tracking is
|
|
425
|
+
not open to accounts yet, so a host carries no mail until it is. A host
|
|
426
|
+
whose records are not right within 7 days goes.
|
|
427
|
+
|
|
428
|
+
| Call | Answers |
|
|
429
|
+
| ----------------------------------------------------- | --------------------------------------------------------------------- |
|
|
430
|
+
| `account.tracking_domains.create(host=)` | `TrackingDomain` with `cname` to add |
|
|
431
|
+
| `account.tracking_domains.list()` | `TrackingDomainList` with `tracking_domains` |
|
|
432
|
+
| `account.tracking_domains.get(tracking_domain_id)` | One tracking domain with what the last check found |
|
|
433
|
+
| `account.tracking_domains.verify(tracking_domain_id)` | The tracking domain as it stands: `state`, `cname.check`, `caa.check` |
|
|
434
|
+
| `account.tracking_domains.delete(tracking_domain_id)` | `None`; new mail stops using it at once |
|
|
435
|
+
|
|
436
|
+
```python
|
|
437
|
+
tracking = account.tracking_domains.create(host="links.example.se")
|
|
438
|
+
print(tracking.cname.type, tracking.cname.host, tracking.cname.value)
|
|
439
|
+
```
|
|
440
|
+
|
|
417
441
|
An erasure removes one person's data from every server of the account,
|
|
418
442
|
as a request under the right to erasure asks. The content of every
|
|
419
443
|
message to or from the address, sent and received, is deleted, and the
|
|
@@ -486,6 +510,11 @@ except SendoraError as error:
|
|
|
486
510
|
| `not_found` | 404 | No such id on this server, or no such suppressed address. |
|
|
487
511
|
| `domain_exists` | 409 | The account already has the domain; `existing_id` names it. |
|
|
488
512
|
| `domain_reserved` | 400 | The name is Sendora's own; no account can receive on it. |
|
|
513
|
+
| `tracking_domain_exists` | 409 | The account already has the host, or one on that sending domain; `existing_id` names it. |
|
|
514
|
+
| `tracking_domain_limit` | 409 | The account has as many tracking domains as it may. |
|
|
515
|
+
| `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself; choose a subdomain. |
|
|
516
|
+
| `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of the account. |
|
|
517
|
+
| `tracking_host_taken` | 409 | Another account holds the host. |
|
|
489
518
|
| `inbound_domain_exists` | 409 | The stream already has a domain (`existing_id` names it), or another account holds the name verified. |
|
|
490
519
|
| `webhook_exists` | 409 | The server already has a webhook for that URL; `existing_id` names it. |
|
|
491
520
|
| `stream_exists` | 409 | The server already has a stream with that name; `existing_id` names it. |
|
|
@@ -552,7 +581,7 @@ seconds of waiting in all. A longer `retry_after` is raised at once for
|
|
|
552
581
|
you to schedule. A call that creates something (`streams.create`,
|
|
553
582
|
`tokens.create`, `webhooks.create`, `webhooks.create_secret`,
|
|
554
583
|
`webhooks.replay`, `inbound_domains.create`, `servers.create`,
|
|
555
|
-
`servers.tokens.create`, `domains.create`) is repeated only after a
|
|
584
|
+
`servers.tokens.create`, `domains.create`, `tracking_domains.create`) is repeated only after a
|
|
556
585
|
`rate_limited` or `sending_disabled` answer, never after a lost connection
|
|
557
586
|
or a server error, so nothing is created twice. Nothing else is retried.
|
|
558
587
|
|
|
@@ -8,6 +8,7 @@ from ._transport import AsyncHttpClient, AsyncTransport, HttpClient, SyncTranspo
|
|
|
8
8
|
from .resources.domains import AsyncDomains, Domains
|
|
9
9
|
from .resources.erasures import AsyncErasures, Erasures
|
|
10
10
|
from .resources.servers import AsyncServers, Servers
|
|
11
|
+
from .resources.tracking_domains import AsyncTrackingDomains, TrackingDomains
|
|
11
12
|
|
|
12
13
|
_ACCOUNT_KEY = "SENDORA_ACCOUNT_TOKEN"
|
|
13
14
|
|
|
@@ -23,6 +24,7 @@ class SendoraAccount:
|
|
|
23
24
|
Attributes:
|
|
24
25
|
servers: The account's servers, and each server's keys.
|
|
25
26
|
domains: The account's sending domains.
|
|
27
|
+
tracking_domains: The hosts that tracked links and the open pixel use.
|
|
26
28
|
erasures: A person's data erased from every server of the account.
|
|
27
29
|
|
|
28
30
|
Example:
|
|
@@ -81,6 +83,7 @@ class SendoraAccount:
|
|
|
81
83
|
self._transport = transport
|
|
82
84
|
self.servers: Servers = Servers(transport)
|
|
83
85
|
self.domains: Domains = Domains(transport)
|
|
86
|
+
self.tracking_domains: TrackingDomains = TrackingDomains(transport)
|
|
84
87
|
self.erasures: Erasures = Erasures(transport)
|
|
85
88
|
|
|
86
89
|
def with_options(
|
|
@@ -131,6 +134,7 @@ class AsyncSendoraAccount:
|
|
|
131
134
|
Attributes:
|
|
132
135
|
servers: The account's servers, and each server's keys.
|
|
133
136
|
domains: The account's sending domains.
|
|
137
|
+
tracking_domains: The hosts that tracked links and the open pixel use.
|
|
134
138
|
erasures: A person's data erased from every server of the account.
|
|
135
139
|
|
|
136
140
|
Example:
|
|
@@ -188,6 +192,7 @@ class AsyncSendoraAccount:
|
|
|
188
192
|
self._transport = transport
|
|
189
193
|
self.servers: AsyncServers = AsyncServers(transport)
|
|
190
194
|
self.domains: AsyncDomains = AsyncDomains(transport)
|
|
195
|
+
self.tracking_domains: AsyncTrackingDomains = AsyncTrackingDomains(transport)
|
|
191
196
|
self.erasures: AsyncErasures = AsyncErasures(transport)
|
|
192
197
|
|
|
193
198
|
def with_options(
|
|
@@ -12,6 +12,7 @@ from .servers import AsyncServers, AsyncServerTokens, Servers, ServerTokens
|
|
|
12
12
|
from .streams import AsyncStreams, Streams
|
|
13
13
|
from .suppressions import AsyncSuppressions, Suppressions
|
|
14
14
|
from .tokens import AsyncTokens, Tokens
|
|
15
|
+
from .tracking_domains import AsyncTrackingDomains, TrackingDomains
|
|
15
16
|
from .webhooks import AsyncWebhooks, Webhooks
|
|
16
17
|
|
|
17
18
|
__all__ = [
|
|
@@ -28,6 +29,7 @@ __all__ = [
|
|
|
28
29
|
"AsyncSuppressions",
|
|
29
30
|
"AsyncThisServer",
|
|
30
31
|
"AsyncTokens",
|
|
32
|
+
"AsyncTrackingDomains",
|
|
31
33
|
"AsyncWebhooks",
|
|
32
34
|
"Broadcasts",
|
|
33
35
|
"Domains",
|
|
@@ -42,5 +44,6 @@ __all__ = [
|
|
|
42
44
|
"Suppressions",
|
|
43
45
|
"ThisServer",
|
|
44
46
|
"Tokens",
|
|
47
|
+
"TrackingDomains",
|
|
45
48
|
"Webhooks",
|
|
46
49
|
]
|
|
@@ -7,7 +7,13 @@ from urllib.parse import quote
|
|
|
7
7
|
from sendora._answers import decode_sent
|
|
8
8
|
from sendora._transport import AsyncTransport, Call, SyncTransport
|
|
9
9
|
from sendora._wire import decode, encode
|
|
10
|
-
from sendora.types import
|
|
10
|
+
from sendora.types import (
|
|
11
|
+
AcceptedBroadcast,
|
|
12
|
+
Broadcast,
|
|
13
|
+
BroadcastDetail,
|
|
14
|
+
BroadcastPage,
|
|
15
|
+
BroadcastParams,
|
|
16
|
+
)
|
|
11
17
|
|
|
12
18
|
|
|
13
19
|
class Broadcasts:
|
|
@@ -74,7 +80,7 @@ class Broadcasts:
|
|
|
74
80
|
"""
|
|
75
81
|
return decode(BroadcastPage, self._transport.send(_list_call(limit, after)))
|
|
76
82
|
|
|
77
|
-
def get(self, broadcast_id: str) ->
|
|
83
|
+
def get(self, broadcast_id: str) -> BroadcastDetail:
|
|
78
84
|
"""A broadcast and how far it has come.
|
|
79
85
|
|
|
80
86
|
``released`` and ``failed`` move as the worker hands its messages
|
|
@@ -84,7 +90,7 @@ class Broadcasts:
|
|
|
84
90
|
broadcast_id: The broadcast's id.
|
|
85
91
|
|
|
86
92
|
Returns:
|
|
87
|
-
The broadcast.
|
|
93
|
+
The broadcast, with what tracking recorded of it.
|
|
88
94
|
|
|
89
95
|
Raises:
|
|
90
96
|
NotFoundError: No broadcast of this server has the id.
|
|
@@ -93,7 +99,7 @@ class Broadcasts:
|
|
|
93
99
|
>>> broadcast = sendora.broadcasts.get(broadcast_id)
|
|
94
100
|
>>> print(broadcast.released, "of", broadcast.total)
|
|
95
101
|
"""
|
|
96
|
-
return decode(
|
|
102
|
+
return decode(BroadcastDetail, self._transport.send(_get_call(broadcast_id)))
|
|
97
103
|
|
|
98
104
|
def cancel(self, broadcast_id: str) -> Broadcast:
|
|
99
105
|
"""Holds every message not yet handed to the mail server.
|
|
@@ -186,7 +192,7 @@ class AsyncBroadcasts:
|
|
|
186
192
|
BroadcastPage, await self._transport.send(_list_call(limit, after))
|
|
187
193
|
)
|
|
188
194
|
|
|
189
|
-
async def get(self, broadcast_id: str) ->
|
|
195
|
+
async def get(self, broadcast_id: str) -> BroadcastDetail:
|
|
190
196
|
"""A broadcast and how far it has come.
|
|
191
197
|
|
|
192
198
|
``released`` and ``failed`` move as the worker hands its messages
|
|
@@ -196,7 +202,7 @@ class AsyncBroadcasts:
|
|
|
196
202
|
broadcast_id: The broadcast's id.
|
|
197
203
|
|
|
198
204
|
Returns:
|
|
199
|
-
The broadcast.
|
|
205
|
+
The broadcast, with what tracking recorded of it.
|
|
200
206
|
|
|
201
207
|
Raises:
|
|
202
208
|
NotFoundError: No broadcast of this server has the id.
|
|
@@ -205,7 +211,9 @@ class AsyncBroadcasts:
|
|
|
205
211
|
>>> broadcast = await sendora.broadcasts.get(broadcast_id)
|
|
206
212
|
>>> print(broadcast.released, "of", broadcast.total)
|
|
207
213
|
"""
|
|
208
|
-
return decode(
|
|
214
|
+
return decode(
|
|
215
|
+
BroadcastDetail, await self._transport.send(_get_call(broadcast_id))
|
|
216
|
+
)
|
|
209
217
|
|
|
210
218
|
async def cancel(self, broadcast_id: str) -> Broadcast:
|
|
211
219
|
"""Holds every message not yet handed to the mail server.
|
|
@@ -21,7 +21,7 @@ class Messages:
|
|
|
21
21
|
"""One message as the log holds it, with its timeline.
|
|
22
22
|
|
|
23
23
|
Its recipients with where each stands, its attachments described,
|
|
24
|
-
and
|
|
24
|
+
and the events, oldest first. Its bodies are never returned.
|
|
25
25
|
|
|
26
26
|
Args:
|
|
27
27
|
message_id: The message's id.
|
|
@@ -91,7 +91,7 @@ class AsyncMessages:
|
|
|
91
91
|
"""One message as the log holds it, with its timeline.
|
|
92
92
|
|
|
93
93
|
Its recipients with where each stands, its attachments described,
|
|
94
|
-
and
|
|
94
|
+
and the events, oldest first. Its bodies are never returned.
|
|
95
95
|
|
|
96
96
|
Args:
|
|
97
97
|
message_id: The message's id.
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""The account's tracking domains, which tracked links and the open pixel use."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
from sendora._transport import AsyncTransport, Call, SyncTransport
|
|
6
|
+
from sendora._wire import decode
|
|
7
|
+
from sendora.types import TrackingDomain, TrackingDomainList
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TrackingDomains:
|
|
11
|
+
"""Adds, checks and removes the hosts that tracked links use."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, transport: SyncTransport) -> None:
|
|
14
|
+
"""Takes the client's transport."""
|
|
15
|
+
self._transport = transport
|
|
16
|
+
|
|
17
|
+
def create(self, *, host: str) -> TrackingDomain:
|
|
18
|
+
"""Claims a host under a verified sending domain and answers the record to add.
|
|
19
|
+
|
|
20
|
+
A host whose records are not right within 7 days goes. A create is
|
|
21
|
+
never repeated after a lost connection, which may have claimed it.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
host: The host, such as ``links.example.se``.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The tracking domain, with the CNAME record to add.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ConflictError: The account has the host or one on that sending
|
|
31
|
+
domain (``tracking_domain_exists``, with ``existing_id``),
|
|
32
|
+
another account holds it (``tracking_host_taken``), or the
|
|
33
|
+
account has no room (``tracking_domain_limit``).
|
|
34
|
+
UnprocessableEntityError: The host is not under a verified sending
|
|
35
|
+
domain, or is one itself.
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
>>> added = account.tracking_domains.create(host="links.example.se")
|
|
39
|
+
>>> print(added.cname.host, added.cname.value)
|
|
40
|
+
"""
|
|
41
|
+
return decode(TrackingDomain, self._transport.send(_create_call(host)))
|
|
42
|
+
|
|
43
|
+
def list(self) -> TrackingDomainList:
|
|
44
|
+
"""Every tracking domain of the account.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The tracking domains.
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
>>> listed = account.tracking_domains.list()
|
|
51
|
+
>>> print(len(listed.tracking_domains))
|
|
52
|
+
"""
|
|
53
|
+
return decode(TrackingDomainList, self._transport.send(_LIST))
|
|
54
|
+
|
|
55
|
+
def get(self, tracking_domain_id: str) -> TrackingDomain:
|
|
56
|
+
"""One tracking domain by its id, with what the last check found.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
tracking_domain_id: The tracking domain's id.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The tracking domain.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
NotFoundError: The account has no tracking domain with the id.
|
|
66
|
+
|
|
67
|
+
Example:
|
|
68
|
+
>>> domain = account.tracking_domains.get(tracking_domain_id)
|
|
69
|
+
"""
|
|
70
|
+
call = _get_call(tracking_domain_id)
|
|
71
|
+
return decode(TrackingDomain, self._transport.send(call))
|
|
72
|
+
|
|
73
|
+
def verify(self, tracking_domain_id: str) -> TrackingDomain:
|
|
74
|
+
"""Looks the CNAME and the CAA records up now.
|
|
75
|
+
|
|
76
|
+
A ``dns_error`` changes nothing.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
tracking_domain_id: The tracking domain's id.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The tracking domain as it stands.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
NotFoundError: The account has no tracking domain with the id.
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
>>> checked = account.tracking_domains.verify(tracking_domain_id)
|
|
89
|
+
>>> print(checked.state, checked.cname.check, checked.caa.check)
|
|
90
|
+
"""
|
|
91
|
+
call = _verify_call(tracking_domain_id)
|
|
92
|
+
return decode(TrackingDomain, self._transport.send(call))
|
|
93
|
+
|
|
94
|
+
def delete(self, tracking_domain_id: str) -> None:
|
|
95
|
+
"""Removes the tracking domain; new mail stops using it at once.
|
|
96
|
+
|
|
97
|
+
Links in sent mail keep working while the CNAME record points at
|
|
98
|
+
Sendora. A delete repeated after a lost answer whose first attempt
|
|
99
|
+
took effect raises ``not_found``.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
tracking_domain_id: The tracking domain's id.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
NotFoundError: The account has no tracking domain with the id.
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
>>> account.tracking_domains.delete(tracking_domain_id)
|
|
109
|
+
"""
|
|
110
|
+
self._transport.send(_delete_call(tracking_domain_id))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class AsyncTrackingDomains:
|
|
114
|
+
"""Adds, checks and removes the hosts that tracked links use."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, transport: AsyncTransport) -> None:
|
|
117
|
+
"""Takes the client's transport."""
|
|
118
|
+
self._transport = transport
|
|
119
|
+
|
|
120
|
+
async def create(self, *, host: str) -> TrackingDomain:
|
|
121
|
+
"""Claims a host under a verified sending domain and answers the record to add.
|
|
122
|
+
|
|
123
|
+
A host whose records are not right within 7 days goes. A create is
|
|
124
|
+
never repeated after a lost connection, which may have claimed it.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
host: The host, such as ``links.example.se``.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The tracking domain, with the CNAME record to add.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
ConflictError: The account has the host or one on that sending
|
|
134
|
+
domain (``tracking_domain_exists``, with ``existing_id``),
|
|
135
|
+
another account holds it (``tracking_host_taken``), or the
|
|
136
|
+
account has no room (``tracking_domain_limit``).
|
|
137
|
+
UnprocessableEntityError: The host is not under a verified sending
|
|
138
|
+
domain, or is one itself.
|
|
139
|
+
|
|
140
|
+
Example:
|
|
141
|
+
>>> added = await account.tracking_domains.create(host="links.example.se")
|
|
142
|
+
>>> print(added.cname.host, added.cname.value)
|
|
143
|
+
"""
|
|
144
|
+
return decode(TrackingDomain, await self._transport.send(_create_call(host)))
|
|
145
|
+
|
|
146
|
+
async def list(self) -> TrackingDomainList:
|
|
147
|
+
"""Every tracking domain of the account.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The tracking domains.
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
>>> listed = await account.tracking_domains.list()
|
|
154
|
+
>>> print(len(listed.tracking_domains))
|
|
155
|
+
"""
|
|
156
|
+
return decode(TrackingDomainList, await self._transport.send(_LIST))
|
|
157
|
+
|
|
158
|
+
async def get(self, tracking_domain_id: str) -> TrackingDomain:
|
|
159
|
+
"""One tracking domain by its id, with what the last check found.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
tracking_domain_id: The tracking domain's id.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
The tracking domain.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
NotFoundError: The account has no tracking domain with the id.
|
|
169
|
+
|
|
170
|
+
Example:
|
|
171
|
+
>>> domain = await account.tracking_domains.get(tracking_domain_id)
|
|
172
|
+
"""
|
|
173
|
+
call = _get_call(tracking_domain_id)
|
|
174
|
+
return decode(TrackingDomain, await self._transport.send(call))
|
|
175
|
+
|
|
176
|
+
async def verify(self, tracking_domain_id: str) -> TrackingDomain:
|
|
177
|
+
"""Looks the CNAME and the CAA records up now.
|
|
178
|
+
|
|
179
|
+
A ``dns_error`` changes nothing.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
tracking_domain_id: The tracking domain's id.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
The tracking domain as it stands.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
NotFoundError: The account has no tracking domain with the id.
|
|
189
|
+
|
|
190
|
+
Example:
|
|
191
|
+
>>> checked = await account.tracking_domains.verify(tracking_domain_id)
|
|
192
|
+
>>> print(checked.state, checked.cname.check, checked.caa.check)
|
|
193
|
+
"""
|
|
194
|
+
call = _verify_call(tracking_domain_id)
|
|
195
|
+
return decode(TrackingDomain, await self._transport.send(call))
|
|
196
|
+
|
|
197
|
+
async def delete(self, tracking_domain_id: str) -> None:
|
|
198
|
+
"""Removes the tracking domain; new mail stops using it at once.
|
|
199
|
+
|
|
200
|
+
Links in sent mail keep working while the CNAME record points at
|
|
201
|
+
Sendora. A delete repeated after a lost answer whose first attempt
|
|
202
|
+
took effect raises ``not_found``.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
tracking_domain_id: The tracking domain's id.
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
NotFoundError: The account has no tracking domain with the id.
|
|
209
|
+
|
|
210
|
+
Example:
|
|
211
|
+
>>> await account.tracking_domains.delete(tracking_domain_id)
|
|
212
|
+
"""
|
|
213
|
+
await self._transport.send(_delete_call(tracking_domain_id))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
_LIST = Call("GET", "/v1/tracking-domains", idempotent=True)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _create_call(host: str) -> Call:
|
|
220
|
+
return Call("POST", "/v1/tracking-domains", idempotent=False, body={"host": host})
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _get_call(tracking_domain_id: str) -> Call:
|
|
224
|
+
return Call("GET", _path(tracking_domain_id), idempotent=True)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _verify_call(tracking_domain_id: str) -> Call:
|
|
228
|
+
return Call("POST", f"{_path(tracking_domain_id)}/verify", idempotent=True)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _delete_call(tracking_domain_id: str) -> Call:
|
|
232
|
+
return Call("DELETE", _path(tracking_domain_id), idempotent=True, expect="none")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _path(tracking_domain_id: str) -> str:
|
|
236
|
+
return f"/v1/tracking-domains/{quote(tracking_domain_id, safe='')}"
|
|
@@ -16,8 +16,8 @@ and every event is in it.
|
|
|
16
16
|
`Sendora()`: `email`, `broadcasts`, `messages`, `inbound`,
|
|
17
17
|
`inbound_domains`, `server`, `streams`, `suppressions`, `tokens`,
|
|
18
18
|
`webhooks`, everything inside one server. An account key (`ak_…`)
|
|
19
|
-
drives `SendoraAccount()`: `servers`, `servers.tokens` and
|
|
20
|
-
and it never sends. A key of the wrong kind raises `wrong_token_kind`.
|
|
19
|
+
drives `SendoraAccount()`: `servers`, `servers.tokens`, `domains` and
|
|
20
|
+
`tracking_domains`, and it never sends. A key of the wrong kind raises `wrong_token_kind`.
|
|
21
21
|
`AsyncSendora` and `AsyncSendoraAccount` are the same clients for
|
|
22
22
|
asyncio; use them in async code and never call the regular ones there.
|
|
23
23
|
- Every key is a secret. Leave `token` out and the clients read
|
|
@@ -39,6 +39,8 @@ __all__ = [
|
|
|
39
39
|
"BouncedDetails",
|
|
40
40
|
"BouncedEvent",
|
|
41
41
|
"Broadcast",
|
|
42
|
+
"BroadcastDetail",
|
|
43
|
+
"BroadcastLink",
|
|
42
44
|
"BroadcastMessageParams",
|
|
43
45
|
"BroadcastPage",
|
|
44
46
|
"BroadcastParams",
|
|
@@ -89,11 +91,13 @@ __all__ = [
|
|
|
89
91
|
"MessageDetail",
|
|
90
92
|
"MessageEvent",
|
|
91
93
|
"MessageEventFields",
|
|
94
|
+
"MessageEventTracking",
|
|
92
95
|
"MessageEventType",
|
|
93
96
|
"MessagePage",
|
|
94
97
|
"MessageRecipient",
|
|
95
98
|
"MessageSearchParams",
|
|
96
99
|
"MessageStatus",
|
|
100
|
+
"MessageTracking",
|
|
97
101
|
"MxRecord",
|
|
98
102
|
"OwnServer",
|
|
99
103
|
"RecipientKind",
|
|
@@ -122,6 +126,21 @@ __all__ = [
|
|
|
122
126
|
"SuppressionReason",
|
|
123
127
|
"Token",
|
|
124
128
|
"TokenList",
|
|
129
|
+
"TrackedLink",
|
|
130
|
+
"TrackingAgent",
|
|
131
|
+
"TrackingAutomated",
|
|
132
|
+
"TrackingCaaCheck",
|
|
133
|
+
"TrackingCaaCheckResult",
|
|
134
|
+
"TrackingCaaRecord",
|
|
135
|
+
"TrackingClient",
|
|
136
|
+
"TrackingCnameCheck",
|
|
137
|
+
"TrackingCnameRecord",
|
|
138
|
+
"TrackingDevice",
|
|
139
|
+
"TrackingDomain",
|
|
140
|
+
"TrackingDomainList",
|
|
141
|
+
"TrackingDomainState",
|
|
142
|
+
"TrackingReason",
|
|
143
|
+
"TrackingSkipped",
|
|
125
144
|
"TxtRecord",
|
|
126
145
|
"UnknownEvent",
|
|
127
146
|
"UnsubscribedDetails",
|
|
@@ -175,6 +194,11 @@ SendoraErrorCode = Literal[
|
|
|
175
194
|
"domain_exists",
|
|
176
195
|
"domain_reserved",
|
|
177
196
|
"inbound_domain_exists",
|
|
197
|
+
"tracking_domain_exists",
|
|
198
|
+
"tracking_domain_limit",
|
|
199
|
+
"tracking_host_is_sending_domain",
|
|
200
|
+
"tracking_host_not_verified",
|
|
201
|
+
"tracking_host_taken",
|
|
178
202
|
"webhook_exists",
|
|
179
203
|
"stream_exists",
|
|
180
204
|
"inbound_stream_exists",
|
|
@@ -459,10 +483,58 @@ MessageEventType = Literal[
|
|
|
459
483
|
"DeferredInjectionRebind",
|
|
460
484
|
"Rejection",
|
|
461
485
|
"Delayed",
|
|
486
|
+
"Open",
|
|
487
|
+
"Click",
|
|
462
488
|
]
|
|
463
489
|
"""A step of a message's timeline: ``Reception`` when the mail server took
|
|
464
490
|
it, ``Delivery``, ``TransientFailure``, ``Bounce``, ``OOB`` for a bounce
|
|
465
|
-
report received by mail, ``Feedback`` for a spam complaint
|
|
491
|
+
report received by mail, ``Feedback`` for a spam complaint, ``Open`` and
|
|
492
|
+
``Click`` for what tracking recorded."""
|
|
493
|
+
|
|
494
|
+
TrackingAutomated = Literal["likely", "unlikely", "unknown"]
|
|
495
|
+
"""Whether a machine rather than a person did it: ``unknown`` where nobody
|
|
496
|
+
can tell, as with Apple Mail."""
|
|
497
|
+
|
|
498
|
+
TrackingAgent = Literal[
|
|
499
|
+
"apple_privacy",
|
|
500
|
+
"gmail_prefetch",
|
|
501
|
+
"gmail_proxy",
|
|
502
|
+
"yahoo_proxy",
|
|
503
|
+
"outlook_prefetch",
|
|
504
|
+
"proton_proxy",
|
|
505
|
+
"scanner",
|
|
506
|
+
"crawler",
|
|
507
|
+
]
|
|
508
|
+
"""The proxy or machine an open or a click came through."""
|
|
509
|
+
|
|
510
|
+
TrackingReason = Literal[
|
|
511
|
+
"privacy_proxy",
|
|
512
|
+
"prefetch_signature",
|
|
513
|
+
"delivery_timing",
|
|
514
|
+
"link_burst",
|
|
515
|
+
"scanner_network",
|
|
516
|
+
"bot_user_agent",
|
|
517
|
+
"many_networks",
|
|
518
|
+
]
|
|
519
|
+
"""Why an open or a click was labelled a machine's."""
|
|
520
|
+
|
|
521
|
+
TrackingClient = Literal[
|
|
522
|
+
"apple_mail",
|
|
523
|
+
"gmail",
|
|
524
|
+
"outlook",
|
|
525
|
+
"yahoo",
|
|
526
|
+
"thunderbird",
|
|
527
|
+
"proton",
|
|
528
|
+
"browser",
|
|
529
|
+
"other",
|
|
530
|
+
]
|
|
531
|
+
"""The mail program an open or a click came from."""
|
|
532
|
+
|
|
533
|
+
TrackingDevice = Literal["desktop", "mobile", "tablet"]
|
|
534
|
+
"""The kind of device an open or a click came from."""
|
|
535
|
+
|
|
536
|
+
TrackingSkipped = Literal["no_tracking_domain", "gmail_clipping"]
|
|
537
|
+
"""Why tracking stayed out of at least one copy of a message."""
|
|
466
538
|
|
|
467
539
|
|
|
468
540
|
@model
|
|
@@ -473,11 +545,25 @@ class MessageRecipient(Answer):
|
|
|
473
545
|
address: The address.
|
|
474
546
|
kind: Whether it was in ``to``, ``cc`` or ``bcc``.
|
|
475
547
|
status: Where its delivery stands.
|
|
548
|
+
tracked: Whether tracking applied to its copy when it left. It does
|
|
549
|
+
not say to opens, links or both, or that the HTML held a link to
|
|
550
|
+
track.
|
|
551
|
+
opens: Opens recorded, machines' included.
|
|
552
|
+
clicks: Clicks recorded, machines' included.
|
|
553
|
+
first_open_at: The first open, or the first click that was likely a
|
|
554
|
+
person's; ``None`` before that.
|
|
555
|
+
first_human_open_at: The same, counting only what was likely a
|
|
556
|
+
person; ``None`` before that.
|
|
476
557
|
"""
|
|
477
558
|
|
|
478
559
|
address: str
|
|
479
560
|
kind: RecipientKind
|
|
480
561
|
status: RecipientStatus
|
|
562
|
+
tracked: bool
|
|
563
|
+
opens: int
|
|
564
|
+
clicks: int
|
|
565
|
+
first_open_at: datetime | None
|
|
566
|
+
first_human_open_at: datetime | None
|
|
481
567
|
|
|
482
568
|
|
|
483
569
|
@model
|
|
@@ -529,6 +615,47 @@ class MessageAttachment(Answer):
|
|
|
529
615
|
size: int
|
|
530
616
|
|
|
531
617
|
|
|
618
|
+
@model
|
|
619
|
+
class TrackedLink(Answer):
|
|
620
|
+
"""The link a recipient clicked.
|
|
621
|
+
|
|
622
|
+
Attributes:
|
|
623
|
+
index: Its position in the HTML, the same in every copy, counting
|
|
624
|
+
from 0.
|
|
625
|
+
url: Where it led; ``None`` once it has gone with the message's text
|
|
626
|
+
and HTML.
|
|
627
|
+
"""
|
|
628
|
+
|
|
629
|
+
index: int
|
|
630
|
+
url: str | None
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@model
|
|
634
|
+
class MessageEventTracking(Answer):
|
|
635
|
+
"""What tracking knows of an ``Open`` or a ``Click``.
|
|
636
|
+
|
|
637
|
+
Attributes:
|
|
638
|
+
automated: Whether a machine rather than a person did it.
|
|
639
|
+
agent: The proxy or machine it came through, where Sendora knows it.
|
|
640
|
+
reasons: Why it was labelled a machine's.
|
|
641
|
+
client: The mail program, where Sendora knows it.
|
|
642
|
+
device: The kind of device, where Sendora knows it.
|
|
643
|
+
first: The recipient's first event of this kind.
|
|
644
|
+
first_human: The recipient's first event of this kind that was
|
|
645
|
+
likely a person's.
|
|
646
|
+
link: The link clicked; ``None`` on an ``Open``.
|
|
647
|
+
"""
|
|
648
|
+
|
|
649
|
+
automated: TrackingAutomated
|
|
650
|
+
agent: TrackingAgent | None
|
|
651
|
+
reasons: list[TrackingReason]
|
|
652
|
+
client: TrackingClient | None
|
|
653
|
+
device: TrackingDevice | None
|
|
654
|
+
first: bool
|
|
655
|
+
first_human: bool
|
|
656
|
+
link: TrackedLink | None
|
|
657
|
+
|
|
658
|
+
|
|
532
659
|
@model
|
|
533
660
|
class MessageEvent(Answer):
|
|
534
661
|
"""One step of a message's timeline.
|
|
@@ -542,6 +669,8 @@ class MessageEvent(Answer):
|
|
|
542
669
|
details: The receiver's response text.
|
|
543
670
|
classification: How a bounce was classified, such as
|
|
544
671
|
``InvalidRecipient``.
|
|
672
|
+
tracking: What tracking knows of an ``Open`` or a ``Click``; ``None``
|
|
673
|
+
on any other event.
|
|
545
674
|
"""
|
|
546
675
|
|
|
547
676
|
recipient: str | None
|
|
@@ -550,6 +679,28 @@ class MessageEvent(Answer):
|
|
|
550
679
|
code: int | None
|
|
551
680
|
details: str | None
|
|
552
681
|
classification: str | None
|
|
682
|
+
tracking: MessageEventTracking | None
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
@model
|
|
686
|
+
class MessageTracking(Answer):
|
|
687
|
+
"""Whether opens and clicks are recorded now, and why tracking stayed out.
|
|
688
|
+
|
|
689
|
+
Attributes:
|
|
690
|
+
recording_opens: Whether new opens of this message are recorded now,
|
|
691
|
+
by its own setting, then its broadcast's, then its stream's at
|
|
692
|
+
this moment. This says nothing of what a copy already sent
|
|
693
|
+
carries.
|
|
694
|
+
recording_clicks: The same for clicks on its links.
|
|
695
|
+
skipped: Why tracking stayed out of at least one copy: the from
|
|
696
|
+
domain had no working tracking domain, or tracked links would
|
|
697
|
+
have taken the HTML past where Gmail clips it. Each recipient's
|
|
698
|
+
``tracked`` says whether its copy was tracked.
|
|
699
|
+
"""
|
|
700
|
+
|
|
701
|
+
recording_opens: bool
|
|
702
|
+
recording_clicks: bool
|
|
703
|
+
skipped: TrackingSkipped | None
|
|
553
704
|
|
|
554
705
|
|
|
555
706
|
@model
|
|
@@ -557,10 +708,13 @@ class MessageDetail(Message):
|
|
|
557
708
|
"""A message with its attachments described and its timeline.
|
|
558
709
|
|
|
559
710
|
Attributes:
|
|
711
|
+
tracking: Whether opens and clicks are recorded now.
|
|
560
712
|
attachments: Each attachment, described.
|
|
561
|
-
events:
|
|
713
|
+
events: The events, oldest first. Opens and clicks are capped, while
|
|
714
|
+
each recipient's ``opens`` and ``clicks`` count them all.
|
|
562
715
|
"""
|
|
563
716
|
|
|
717
|
+
tracking: MessageTracking
|
|
564
718
|
attachments: list[MessageAttachment]
|
|
565
719
|
events: list[MessageEvent]
|
|
566
720
|
|
|
@@ -738,6 +892,47 @@ class Broadcast(Answer):
|
|
|
738
892
|
completed_at: datetime | None
|
|
739
893
|
|
|
740
894
|
|
|
895
|
+
@model
|
|
896
|
+
class BroadcastLink(Answer):
|
|
897
|
+
"""A tracked link of a broadcast that has a click.
|
|
898
|
+
|
|
899
|
+
Attributes:
|
|
900
|
+
index: Its position in the HTML, the same in every copy, counting
|
|
901
|
+
from 0.
|
|
902
|
+
url: Where the first click led, since a substitution can give a link
|
|
903
|
+
another URL per recipient; ``None`` once it has gone with the
|
|
904
|
+
content.
|
|
905
|
+
clicked: Recipients who clicked this link, machines' included.
|
|
906
|
+
clicked_by_people: Those of them likely to be people.
|
|
907
|
+
"""
|
|
908
|
+
|
|
909
|
+
index: int
|
|
910
|
+
url: str | None
|
|
911
|
+
clicked: int
|
|
912
|
+
clicked_by_people: int
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
@model
|
|
916
|
+
class BroadcastDetail(Broadcast):
|
|
917
|
+
"""A broadcast, how far it has come and what tracking recorded of it.
|
|
918
|
+
|
|
919
|
+
Attributes:
|
|
920
|
+
opened: Recipients with an open, machines' included, or a click that
|
|
921
|
+
was likely a person's.
|
|
922
|
+
opened_by_people: Those of them likely to be people.
|
|
923
|
+
clicked: Recipients who clicked a link, machines' included.
|
|
924
|
+
clicked_by_people: Those of them likely to be people.
|
|
925
|
+
links: Each tracked link that has a click, by its index, counted from
|
|
926
|
+
the clicks each recipient keeps.
|
|
927
|
+
"""
|
|
928
|
+
|
|
929
|
+
opened: int
|
|
930
|
+
opened_by_people: int
|
|
931
|
+
clicked: int
|
|
932
|
+
clicked_by_people: int
|
|
933
|
+
links: list[BroadcastLink]
|
|
934
|
+
|
|
935
|
+
|
|
741
936
|
@model
|
|
742
937
|
class BroadcastPage(Answer):
|
|
743
938
|
"""One page of the server's broadcasts, newest first.
|
|
@@ -1574,6 +1769,121 @@ class DomainList(Answer):
|
|
|
1574
1769
|
domains: list[SendingDomain]
|
|
1575
1770
|
|
|
1576
1771
|
|
|
1772
|
+
TrackingDomainState = Literal[
|
|
1773
|
+
"pending",
|
|
1774
|
+
"waiting_for_certificate",
|
|
1775
|
+
"ready",
|
|
1776
|
+
"failing",
|
|
1777
|
+
"stopped",
|
|
1778
|
+
"sending_domain_lost",
|
|
1779
|
+
]
|
|
1780
|
+
TrackingCnameCheck = Literal["ok", "missing", "mismatch", "proxied", "dns_error"]
|
|
1781
|
+
TrackingCaaCheck = Literal[
|
|
1782
|
+
"ok", "no_lets_encrypt", "other_account", "no_http_01", "dns_error"
|
|
1783
|
+
]
|
|
1784
|
+
|
|
1785
|
+
|
|
1786
|
+
@model
|
|
1787
|
+
class TrackingCnameRecord(Answer):
|
|
1788
|
+
"""The CNAME record a tracking domain needs, and what the last check found.
|
|
1789
|
+
|
|
1790
|
+
Attributes:
|
|
1791
|
+
type: ``CNAME``.
|
|
1792
|
+
host: The name to create it under.
|
|
1793
|
+
value: The value it must hold.
|
|
1794
|
+
check: What the last check found; ``None`` before the first.
|
|
1795
|
+
``mismatch`` is a record that points elsewhere, an A record
|
|
1796
|
+
included. ``proxied`` is a proxy in front of the name.
|
|
1797
|
+
``dns_error`` is a resolver failure and changes nothing.
|
|
1798
|
+
"""
|
|
1799
|
+
|
|
1800
|
+
type: Literal["CNAME"]
|
|
1801
|
+
host: str
|
|
1802
|
+
value: str
|
|
1803
|
+
check: TrackingCnameCheck | None
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
@model
|
|
1807
|
+
class TrackingCaaRecord(Answer):
|
|
1808
|
+
"""The CAA record that lets a tracking domain's certificate be issued.
|
|
1809
|
+
|
|
1810
|
+
Attributes:
|
|
1811
|
+
type: ``CAA``.
|
|
1812
|
+
host: The name that holds the CAA records in the way.
|
|
1813
|
+
value: The record to add beside them.
|
|
1814
|
+
"""
|
|
1815
|
+
|
|
1816
|
+
type: Literal["CAA"]
|
|
1817
|
+
host: str
|
|
1818
|
+
value: str
|
|
1819
|
+
|
|
1820
|
+
|
|
1821
|
+
@model
|
|
1822
|
+
class TrackingCaaCheckResult(Answer):
|
|
1823
|
+
"""Whether CAA records let Let's Encrypt issue the certificate.
|
|
1824
|
+
|
|
1825
|
+
Attributes:
|
|
1826
|
+
check: What the last check found; ``None`` before the first.
|
|
1827
|
+
``dns_error`` is a resolver failure and changes nothing.
|
|
1828
|
+
record: What lets the certificate be issued; ``None`` unless the
|
|
1829
|
+
check found a problem.
|
|
1830
|
+
"""
|
|
1831
|
+
|
|
1832
|
+
check: TrackingCaaCheck | None
|
|
1833
|
+
record: TrackingCaaRecord | None
|
|
1834
|
+
|
|
1835
|
+
|
|
1836
|
+
@model
|
|
1837
|
+
class TrackingDomain(Answer):
|
|
1838
|
+
"""A tracking domain, the record it needs and what the last check found.
|
|
1839
|
+
|
|
1840
|
+
Attributes:
|
|
1841
|
+
tracking_domain_id: The tracking domain's id.
|
|
1842
|
+
host: The host, lower-cased and IDNA-encoded.
|
|
1843
|
+
sending_domain_id: The sending domain whose new mail will use the
|
|
1844
|
+
host; ``None`` once that domain is deleted.
|
|
1845
|
+
state: ``pending``: the records have not been right yet.
|
|
1846
|
+
``waiting_for_certificate``: the records are right. ``ready``:
|
|
1847
|
+
new mail from the sending domain uses the host. ``failing``: a
|
|
1848
|
+
check found the records wrong, and new mail keeps using a host
|
|
1849
|
+
that was ready until 24 hours after ``failing_since``.
|
|
1850
|
+
``stopped``: those 24 hours have passed.
|
|
1851
|
+
``sending_domain_lost``: the sending domain is deleted or has
|
|
1852
|
+
lost its verification.
|
|
1853
|
+
created_at: When it was added.
|
|
1854
|
+
last_checked_at: When the records were last looked up.
|
|
1855
|
+
certificate_ready_at: When the certificate was proved to work.
|
|
1856
|
+
failing_since: When a check first found the records wrong on a host
|
|
1857
|
+
whose records have been right.
|
|
1858
|
+
expires_at: When a host whose records have never been right goes.
|
|
1859
|
+
cname: The CNAME record to add.
|
|
1860
|
+
caa: Whether CAA records let Let's Encrypt issue the certificate.
|
|
1861
|
+
"""
|
|
1862
|
+
|
|
1863
|
+
tracking_domain_id: str
|
|
1864
|
+
host: str
|
|
1865
|
+
sending_domain_id: str | None
|
|
1866
|
+
state: TrackingDomainState
|
|
1867
|
+
created_at: datetime
|
|
1868
|
+
last_checked_at: datetime | None
|
|
1869
|
+
certificate_ready_at: datetime | None
|
|
1870
|
+
failing_since: datetime | None
|
|
1871
|
+
expires_at: datetime | None
|
|
1872
|
+
cname: TrackingCnameRecord
|
|
1873
|
+
caa: TrackingCaaCheckResult
|
|
1874
|
+
|
|
1875
|
+
|
|
1876
|
+
@model
|
|
1877
|
+
class TrackingDomainList(Answer):
|
|
1878
|
+
"""Every tracking domain of the account.
|
|
1879
|
+
|
|
1880
|
+
Attributes:
|
|
1881
|
+
tracking_domains: The tracking domains.
|
|
1882
|
+
"""
|
|
1883
|
+
|
|
1884
|
+
tracking_domains: list[TrackingDomain]
|
|
1885
|
+
|
|
1886
|
+
|
|
1577
1887
|
ErasureStatus = Literal["pending", "done"]
|
|
1578
1888
|
|
|
1579
1889
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|