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.
Files changed (39) hide show
  1. {sendora-2.0.2 → sendora-2.2.0}/CHANGELOG.md +16 -0
  2. {sendora-2.0.2 → sendora-2.2.0}/PKG-INFO +48 -19
  3. {sendora-2.0.2 → sendora-2.2.0}/README.md +47 -18
  4. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_account.py +5 -0
  5. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_answers.py +1 -0
  6. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_version.py +1 -1
  7. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/__init__.py +3 -0
  8. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/broadcasts.py +15 -7
  9. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/messages.py +2 -2
  10. sendora-2.2.0/src/sendora/resources/tracking_domains.py +236 -0
  11. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/skills/sendora/SKILL.md +2 -2
  12. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/types.py +312 -2
  13. {sendora-2.0.2 → sendora-2.2.0}/.gitignore +0 -0
  14. {sendora-2.0.2 → sendora-2.2.0}/LICENSE +0 -0
  15. {sendora-2.0.2 → sendora-2.2.0}/pyproject.toml +0 -0
  16. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/__init__.py +0 -0
  17. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_client.py +0 -0
  18. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_errors.py +0 -0
  19. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_json.py +0 -0
  20. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_keys.py +0 -0
  21. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_model.py +0 -0
  22. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_options.py +0 -0
  23. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_pages.py +0 -0
  24. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_retry.py +0 -0
  25. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_transport.py +0 -0
  26. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_webhooks.py +0 -0
  27. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/_wire.py +0 -0
  28. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/py.typed +0 -0
  29. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/domains.py +0 -0
  30. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/email.py +0 -0
  31. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/erasures.py +0 -0
  32. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/inbound.py +0 -0
  33. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/inbound_domains.py +0 -0
  34. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/server.py +0 -0
  35. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/servers.py +0 -0
  36. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/streams.py +0 -0
  37. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/suppressions.py +0 -0
  38. {sendora-2.0.2 → sendora-2.2.0}/src/sendora/resources/tokens.py +0 -0
  39. {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.2
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 and its sending domains. `AsyncSendora` and
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 `failed` |
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 and its sending domains. `AsyncSendora` and
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 `failed` |
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(
@@ -246,6 +246,7 @@ _EXISTING_IDS = {
246
246
  "stream_exists": "streamId",
247
247
  "inbound_stream_exists": "streamId",
248
248
  "inbound_domain_exists": "inboundDomainId",
249
+ "tracking_domain_exists": "trackingDomainId",
249
250
  }
250
251
 
251
252
 
@@ -1,3 +1,3 @@
1
1
  """The package's version: the build reads it, and every request names it."""
2
2
 
3
- __version__ = "2.0.2"
3
+ __version__ = "2.2.0"
@@ -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 AcceptedBroadcast, Broadcast, BroadcastPage, BroadcastParams
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) -> Broadcast:
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(Broadcast, self._transport.send(_get_call(broadcast_id)))
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) -> Broadcast:
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(Broadcast, await self._transport.send(_get_call(broadcast_id)))
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 every event, oldest first. Its bodies are never returned.
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 every event, oldest first. Its bodies are never returned.
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 `domains`,
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: Every event, oldest first.
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