sendora 2.2.0__tar.gz → 2.4.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 (40) hide show
  1. {sendora-2.2.0 → sendora-2.4.0}/CHANGELOG.md +13 -0
  2. {sendora-2.2.0 → sendora-2.4.0}/PKG-INFO +31 -5
  3. {sendora-2.2.0 → sendora-2.4.0}/README.md +30 -4
  4. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/__init__.py +4 -0
  5. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_client.py +5 -0
  6. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_version.py +1 -1
  7. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_webhooks.py +4 -0
  8. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/__init__.py +3 -0
  9. sendora-2.4.0/src/sendora/resources/tracking_opt_outs.py +226 -0
  10. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/webhooks.py +4 -2
  11. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/skills/sendora/SKILL.md +5 -1
  12. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/types.py +130 -1
  13. {sendora-2.2.0 → sendora-2.4.0}/.gitignore +0 -0
  14. {sendora-2.2.0 → sendora-2.4.0}/LICENSE +0 -0
  15. {sendora-2.2.0 → sendora-2.4.0}/pyproject.toml +0 -0
  16. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_account.py +0 -0
  17. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_answers.py +0 -0
  18. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_errors.py +0 -0
  19. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_json.py +0 -0
  20. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_keys.py +0 -0
  21. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_model.py +0 -0
  22. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_options.py +0 -0
  23. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_pages.py +0 -0
  24. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_retry.py +0 -0
  25. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_transport.py +0 -0
  26. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_wire.py +0 -0
  27. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/py.typed +0 -0
  28. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/broadcasts.py +0 -0
  29. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/domains.py +0 -0
  30. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/email.py +0 -0
  31. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/erasures.py +0 -0
  32. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/inbound.py +0 -0
  33. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/inbound_domains.py +0 -0
  34. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/messages.py +0 -0
  35. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/server.py +0 -0
  36. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/servers.py +0 -0
  37. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/streams.py +0 -0
  38. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/suppressions.py +0 -0
  39. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/tokens.py +0 -0
  40. {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/tracking_domains.py +0 -0
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.0
4
+
5
+ - `sendora.tracking_opt_outs` manages a server's not-tracked list: `list`,
6
+ `list_all`, `add` and `delete`. An entry names its `address`, which is
7
+ `None` once an erasure has left only a keyed hash, and its `source`.
8
+
9
+ ## 2.3.0
10
+
11
+ - Two events, `OpenedEvent` and `ClickedEvent`, for `opened` and `clicked`:
12
+ a tracked copy's pixel loaded, or a tracked link was followed. A webhook
13
+ gets them only if it names them, so `webhooks.create` without `events`
14
+ takes every other event.
15
+
3
16
  ## 2.2.0
4
17
 
5
18
  - Each `MessageRecipient` has `tracked`, `opens`, `clicks`, `first_open_at`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sendora
3
- Version: 2.2.0
3
+ Version: 2.4.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
@@ -33,7 +33,7 @@ pip install sendora
33
33
 
34
34
  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
- mail, streams, suppressions, tokens, webhooks and the server's own limits.
36
+ mail, streams, suppressions, the not-tracked list, tokens, webhooks and the server's own limits.
37
37
  `SendoraAccount` takes an account key (`ak_…`) and manages the account:
38
38
  its servers, their keys, its sending and tracking domains. `AsyncSendora` and
39
39
  `AsyncSendoraAccount` are the same clients for asyncio. Both keys are
@@ -246,6 +246,29 @@ request. A refused send carries `stream_id` beside `suppressed`.
246
246
  sendora.suppressions.delete(address="anna@example.com")
247
247
  ```
248
248
 
249
+ ## The not-tracked list
250
+
251
+ Addresses the server never tracks, on any of its streams. Mail to one still
252
+ goes out, untracked, and its opens and clicks of mail already sent are not
253
+ recorded. Tracking is not open to accounts yet, so the list changes nothing
254
+ until it is.
255
+
256
+ | Call | Answers |
257
+ | ------------------------------------------------ | -------------------------------------------------------- |
258
+ | `sendora.tracking_opt_outs.list(limit=, after=)` | `TrackingOptOutPage` with `tracking_opt_outs` and `next` |
259
+ | `sendora.tracking_opt_outs.list_all()` | Every entry, as an iterator |
260
+ | `sendora.tracking_opt_outs.add(address=)` | `TrackingOptOut`, as it stands if the address was listed |
261
+ | `sendora.tracking_opt_outs.delete(address=)` | `None`; `not_found` if the address is not listed |
262
+
263
+ An entry's `source` is `manual`, or `recipient` if the recipient asked for it.
264
+ Remove a recipient's own entry only after new consent. An entry's `address` is
265
+ `None` once an erasure has left only a keyed hash of it. `delete` still finds
266
+ that entry by its address, and nothing removes it without the address.
267
+
268
+ ```python
269
+ sendora.tracking_opt_outs.add(address="anna@example.com")
270
+ ```
271
+
249
272
  ## Tokens
250
273
 
251
274
  The server's own keys, from its own key. Every key of a server has the
@@ -310,7 +333,9 @@ Events: `delivered`, `bounced`, `deferred`, `spam_complaint` and
310
333
  `unsubscribed` about a message, with `message_id`, `stream_id`,
311
334
  `recipient`, `occurred_at`, `server_id`, your `tag` and `metadata`, and
312
335
  `details` from the receiver, or for `unsubscribed` the `source` (`link` or
313
- `one_click`); `cap_warning` and `cap_reached` about usage, with `scope`,
336
+ `one_click`); `opened` and `clicked` about a tracked copy, sent only to a
337
+ webhook that names them, with the same fields and `details` that say whether
338
+ a machine did it and, for `clicked`, give the `link`; `cap_warning` and `cap_reached` about usage, with `scope`,
314
339
  `cap`, `used` and the period; `inbound` about a message received on an
315
340
  inbound stream, with `inbound_message_id`, `envelope_recipient`, the
316
341
  sizes, the `authentication` verdicts and, for a webhook with
@@ -318,7 +343,8 @@ sizes, the `authentication` verdicts and, for a webhook with
318
343
  `to`, `subject`, `text`, `html`, `attachments` and the rest.
319
344
  `inbound_cap_warning` and `inbound_cap_reached` say that received mail
320
345
  nears or reaches the account's monthly cap, with `cap`, `used` and the
321
- period. Every event carries `id`, the delivery id, and `attempt`.
346
+ period. Every event carries `id`, the delivery id, and `attempt`. Tracking is
347
+ not open to accounts yet, so `opened` and `clicked` are not sent until it is.
322
348
 
323
349
  A failed delivery is retried with growing delays for about a day, then it
324
350
  is `dead` until you replay it. Deliveries arrive at least once and not
@@ -529,7 +555,7 @@ except SendoraError as error:
529
555
  | `account_not_active` | 403 | The account is not approved to send, or not active for an inbound stream. |
530
556
  | `spam_complaint_locked` | 403 | Only support can lift a spam-complaint suppression. |
531
557
  | `unsubscribe_locked` | 403 | Only support can lift a recipient's own unsubscribe. |
532
- | `not_found` | 404 | No such id on this server, or no such suppressed address. |
558
+ | `not_found` | 404 | No such id on this server, or no such suppressed or not-tracked address. |
533
559
  | `domain_exists` | 409 | The account already has the domain; `existing_id` names it. |
534
560
  | `domain_reserved` | 400 | The name is Sendora's own; no account can receive on it. |
535
561
  | `tracking_domain_exists` | 409 | The account already has the host, or one on that sending domain; `existing_id` names it. |
@@ -11,7 +11,7 @@ pip install sendora
11
11
 
12
12
  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
- mail, streams, suppressions, tokens, webhooks and the server's own limits.
14
+ mail, streams, suppressions, the not-tracked list, tokens, webhooks and the server's own limits.
15
15
  `SendoraAccount` takes an account key (`ak_…`) and manages the account:
16
16
  its servers, their keys, its sending and tracking domains. `AsyncSendora` and
17
17
  `AsyncSendoraAccount` are the same clients for asyncio. Both keys are
@@ -224,6 +224,29 @@ request. A refused send carries `stream_id` beside `suppressed`.
224
224
  sendora.suppressions.delete(address="anna@example.com")
225
225
  ```
226
226
 
227
+ ## The not-tracked list
228
+
229
+ Addresses the server never tracks, on any of its streams. Mail to one still
230
+ goes out, untracked, and its opens and clicks of mail already sent are not
231
+ recorded. Tracking is not open to accounts yet, so the list changes nothing
232
+ until it is.
233
+
234
+ | Call | Answers |
235
+ | ------------------------------------------------ | -------------------------------------------------------- |
236
+ | `sendora.tracking_opt_outs.list(limit=, after=)` | `TrackingOptOutPage` with `tracking_opt_outs` and `next` |
237
+ | `sendora.tracking_opt_outs.list_all()` | Every entry, as an iterator |
238
+ | `sendora.tracking_opt_outs.add(address=)` | `TrackingOptOut`, as it stands if the address was listed |
239
+ | `sendora.tracking_opt_outs.delete(address=)` | `None`; `not_found` if the address is not listed |
240
+
241
+ An entry's `source` is `manual`, or `recipient` if the recipient asked for it.
242
+ Remove a recipient's own entry only after new consent. An entry's `address` is
243
+ `None` once an erasure has left only a keyed hash of it. `delete` still finds
244
+ that entry by its address, and nothing removes it without the address.
245
+
246
+ ```python
247
+ sendora.tracking_opt_outs.add(address="anna@example.com")
248
+ ```
249
+
227
250
  ## Tokens
228
251
 
229
252
  The server's own keys, from its own key. Every key of a server has the
@@ -288,7 +311,9 @@ Events: `delivered`, `bounced`, `deferred`, `spam_complaint` and
288
311
  `unsubscribed` about a message, with `message_id`, `stream_id`,
289
312
  `recipient`, `occurred_at`, `server_id`, your `tag` and `metadata`, and
290
313
  `details` from the receiver, or for `unsubscribed` the `source` (`link` or
291
- `one_click`); `cap_warning` and `cap_reached` about usage, with `scope`,
314
+ `one_click`); `opened` and `clicked` about a tracked copy, sent only to a
315
+ webhook that names them, with the same fields and `details` that say whether
316
+ a machine did it and, for `clicked`, give the `link`; `cap_warning` and `cap_reached` about usage, with `scope`,
292
317
  `cap`, `used` and the period; `inbound` about a message received on an
293
318
  inbound stream, with `inbound_message_id`, `envelope_recipient`, the
294
319
  sizes, the `authentication` verdicts and, for a webhook with
@@ -296,7 +321,8 @@ sizes, the `authentication` verdicts and, for a webhook with
296
321
  `to`, `subject`, `text`, `html`, `attachments` and the rest.
297
322
  `inbound_cap_warning` and `inbound_cap_reached` say that received mail
298
323
  nears or reaches the account's monthly cap, with `cap`, `used` and the
299
- period. Every event carries `id`, the delivery id, and `attempt`.
324
+ period. Every event carries `id`, the delivery id, and `attempt`. Tracking is
325
+ not open to accounts yet, so `opened` and `clicked` are not sent until it is.
300
326
 
301
327
  A failed delivery is retried with growing delays for about a day, then it
302
328
  is `dead` until you replay it. Deliveries arrive at least once and not
@@ -507,7 +533,7 @@ except SendoraError as error:
507
533
  | `account_not_active` | 403 | The account is not approved to send, or not active for an inbound stream. |
508
534
  | `spam_complaint_locked` | 403 | Only support can lift a spam-complaint suppression. |
509
535
  | `unsubscribe_locked` | 403 | Only support can lift a recipient's own unsubscribe. |
510
- | `not_found` | 404 | No such id on this server, or no such suppressed address. |
536
+ | `not_found` | 404 | No such id on this server, or no such suppressed or not-tracked address. |
511
537
  | `domain_exists` | 409 | The account already has the domain; `existing_id` names it. |
512
538
  | `domain_reserved` | 400 | The name is Sendora's own; no account can receive on it. |
513
539
  | `tracking_domain_exists` | 409 | The account already has the host, or one on that sending domain; `existing_id` names it. |
@@ -33,11 +33,13 @@ from .types import (
33
33
  BouncedEvent,
34
34
  CapReachedEvent,
35
35
  CapWarningEvent,
36
+ ClickedEvent,
36
37
  DeferredEvent,
37
38
  DeliveredEvent,
38
39
  InboundCapReachedEvent,
39
40
  InboundCapWarningEvent,
40
41
  InboundEvent,
42
+ OpenedEvent,
41
43
  SendoraErrorCode,
42
44
  SpamComplaintEvent,
43
45
  UnknownEvent,
@@ -58,6 +60,7 @@ __all__ = [
58
60
  "BouncedEvent",
59
61
  "CapReachedEvent",
60
62
  "CapWarningEvent",
63
+ "ClickedEvent",
61
64
  "ConflictError",
62
65
  "DeferredEvent",
63
66
  "DeliveredEvent",
@@ -67,6 +70,7 @@ __all__ = [
67
70
  "InboundEvent",
68
71
  "InternalServerError",
69
72
  "NotFoundError",
73
+ "OpenedEvent",
70
74
  "PermissionDeniedError",
71
75
  "RateLimitError",
72
76
  "ResponseValidationError",
@@ -14,6 +14,7 @@ from .resources.server import AsyncThisServer, ThisServer
14
14
  from .resources.streams import AsyncStreams, Streams
15
15
  from .resources.suppressions import AsyncSuppressions, Suppressions
16
16
  from .resources.tokens import AsyncTokens, Tokens
17
+ from .resources.tracking_opt_outs import AsyncTrackingOptOuts, TrackingOptOuts
17
18
  from .resources.webhooks import AsyncWebhooks, Webhooks
18
19
 
19
20
  _SERVER_KEY = "SENDORA_API_TOKEN"
@@ -31,6 +32,7 @@ class Sendora:
31
32
  broadcasts: Sends one message to many on a broadcast stream.
32
33
  streams: The server's streams.
33
34
  suppressions: The addresses each stream no longer sends to.
35
+ tracking_opt_outs: The addresses the server never tracks.
34
36
  tokens: The server's keys.
35
37
  server: The key's own server, its mode and its limits.
36
38
  webhooks: Where the server's events are posted, and their deliveries.
@@ -100,6 +102,7 @@ class Sendora:
100
102
  self.broadcasts: Broadcasts = Broadcasts(transport)
101
103
  self.streams: Streams = Streams(transport)
102
104
  self.suppressions: Suppressions = Suppressions(transport)
105
+ self.tracking_opt_outs: TrackingOptOuts = TrackingOptOuts(transport)
103
106
  self.tokens: Tokens = Tokens(transport)
104
107
  self.server: ThisServer = ThisServer(transport)
105
108
  self.webhooks: Webhooks = Webhooks(transport)
@@ -157,6 +160,7 @@ class AsyncSendora:
157
160
  broadcasts: Sends one message to many on a broadcast stream.
158
161
  streams: The server's streams.
159
162
  suppressions: The addresses each stream no longer sends to.
163
+ tracking_opt_outs: The addresses the server never tracks.
160
164
  tokens: The server's keys.
161
165
  server: The key's own server, its mode and its limits.
162
166
  webhooks: Where the server's events are posted, and their deliveries.
@@ -226,6 +230,7 @@ class AsyncSendora:
226
230
  self.broadcasts: AsyncBroadcasts = AsyncBroadcasts(transport)
227
231
  self.streams: AsyncStreams = AsyncStreams(transport)
228
232
  self.suppressions: AsyncSuppressions = AsyncSuppressions(transport)
233
+ self.tracking_opt_outs: AsyncTrackingOptOuts = AsyncTrackingOptOuts(transport)
229
234
  self.tokens: AsyncTokens = AsyncTokens(transport)
230
235
  self.server: AsyncThisServer = AsyncThisServer(transport)
231
236
  self.webhooks: AsyncWebhooks = AsyncWebhooks(transport)
@@ -1,3 +1,3 @@
1
1
  """The package's version: the build reads it, and every request names it."""
2
2
 
3
- __version__ = "2.2.0"
3
+ __version__ = "2.4.0"
@@ -15,12 +15,14 @@ from .types import (
15
15
  BouncedEvent,
16
16
  CapReachedEvent,
17
17
  CapWarningEvent,
18
+ ClickedEvent,
18
19
  DeferredEvent,
19
20
  DeliveredEvent,
20
21
  InboundCapReachedEvent,
21
22
  InboundCapWarningEvent,
22
23
  InboundEvent,
23
24
  InboundEventContent,
25
+ OpenedEvent,
24
26
  SpamComplaintEvent,
25
27
  UnknownEvent,
26
28
  UnsubscribedEvent,
@@ -169,6 +171,8 @@ _EVENTS: dict[str, Callable[[dict[str, object]], WebhookEvent]] = {
169
171
  "deferred": lambda payload: decode(DeferredEvent, payload),
170
172
  "spam_complaint": lambda payload: decode(SpamComplaintEvent, payload),
171
173
  "unsubscribed": lambda payload: decode(UnsubscribedEvent, payload),
174
+ "opened": lambda payload: decode(OpenedEvent, payload),
175
+ "clicked": lambda payload: decode(ClickedEvent, payload),
172
176
  "cap_warning": lambda payload: decode(CapWarningEvent, payload),
173
177
  "cap_reached": lambda payload: decode(CapReachedEvent, payload),
174
178
  "inbound": _inbound,
@@ -13,6 +13,7 @@ from .streams import AsyncStreams, Streams
13
13
  from .suppressions import AsyncSuppressions, Suppressions
14
14
  from .tokens import AsyncTokens, Tokens
15
15
  from .tracking_domains import AsyncTrackingDomains, TrackingDomains
16
+ from .tracking_opt_outs import AsyncTrackingOptOuts, TrackingOptOuts
16
17
  from .webhooks import AsyncWebhooks, Webhooks
17
18
 
18
19
  __all__ = [
@@ -30,6 +31,7 @@ __all__ = [
30
31
  "AsyncThisServer",
31
32
  "AsyncTokens",
32
33
  "AsyncTrackingDomains",
34
+ "AsyncTrackingOptOuts",
33
35
  "AsyncWebhooks",
34
36
  "Broadcasts",
35
37
  "Domains",
@@ -45,5 +47,6 @@ __all__ = [
45
47
  "ThisServer",
46
48
  "Tokens",
47
49
  "TrackingDomains",
50
+ "TrackingOptOuts",
48
51
  "Webhooks",
49
52
  ]
@@ -0,0 +1,226 @@
1
+ """The addresses the key's server never tracks, one list per server."""
2
+
3
+ from collections.abc import AsyncIterator, Iterator
4
+
5
+ from sendora._pages import iterate, iterate_async
6
+ from sendora._transport import AsyncTransport, Call, SyncTransport
7
+ from sendora._wire import decode
8
+ from sendora.types import TrackingOptOut, TrackingOptOutPage
9
+
10
+
11
+ class TrackingOptOuts:
12
+ """Reads the server's not-tracked list, adds an address and removes one."""
13
+
14
+ def __init__(self, transport: SyncTransport) -> None:
15
+ """Takes the client's transport."""
16
+ self._transport = transport
17
+
18
+ def list(
19
+ self, *, limit: int | None = None, after: str | None = None
20
+ ) -> TrackingOptOutPage:
21
+ """One page of the server's not-tracked list, newest first.
22
+
23
+ An entry whose ``address`` is ``None`` was erased, and only a keyed
24
+ hash of it remains.
25
+
26
+ Args:
27
+ limit: The page size, 1 to 1000; 100 when left out.
28
+ after: The ``next`` of the previous page.
29
+
30
+ Returns:
31
+ The page.
32
+
33
+ Example:
34
+ >>> page = sendora.tracking_opt_outs.list(limit=200)
35
+ """
36
+ return decode(
37
+ TrackingOptOutPage, self._transport.send(_list_call(limit, after))
38
+ )
39
+
40
+ def list_all(
41
+ self, *, limit: int | None = None, after: str | None = None
42
+ ) -> Iterator[TrackingOptOut]:
43
+ """Every entry of the list, page by page, from ``after`` on.
44
+
45
+ Each page is a request of its own, repeated on its own when it
46
+ fails.
47
+
48
+ Args:
49
+ limit: The page size, 1 to 1000; 100 when left out.
50
+ after: The cursor to begin after.
51
+
52
+ Yields:
53
+ Each entry.
54
+
55
+ Example:
56
+ >>> for entry in sendora.tracking_opt_outs.list_all():
57
+ ... print(entry.address, entry.source)
58
+ """
59
+ yield from iterate(
60
+ lambda cursor: self.list(limit=limit, after=cursor),
61
+ _entries_of,
62
+ _next_of,
63
+ after,
64
+ )
65
+
66
+ def add(self, *, address: str) -> TrackingOptOut:
67
+ """Puts an address on the server's list, so that it is never tracked.
68
+
69
+ That holds for mail already sent too. An address already on the list
70
+ changes nothing, and the entry comes back as it stands.
71
+
72
+ Args:
73
+ address: The address never to track.
74
+
75
+ Returns:
76
+ The entry.
77
+
78
+ Example:
79
+ >>> entry = sendora.tracking_opt_outs.add(address="anna@example.com")
80
+ """
81
+ return decode(TrackingOptOut, self._transport.send(_add_call(address)))
82
+
83
+ def delete(self, *, address: str) -> None:
84
+ """Takes an address off the server's list, so it can be tracked again.
85
+
86
+ Remove an entry whose ``source`` is ``recipient`` only after new
87
+ consent. An erased entry is found by its address too. A delete
88
+ repeated after a lost answer whose first attempt took effect raises
89
+ ``not_found``.
90
+
91
+ Args:
92
+ address: The address to track again.
93
+
94
+ Raises:
95
+ NotFoundError: The address is not on the list.
96
+
97
+ Example:
98
+ >>> sendora.tracking_opt_outs.delete(address="anna@example.com")
99
+ """
100
+ self._transport.send(_delete_call(address))
101
+
102
+
103
+ class AsyncTrackingOptOuts:
104
+ """Reads the server's not-tracked list, adds an address and removes one."""
105
+
106
+ def __init__(self, transport: AsyncTransport) -> None:
107
+ """Takes the client's transport."""
108
+ self._transport = transport
109
+
110
+ async def list(
111
+ self, *, limit: int | None = None, after: str | None = None
112
+ ) -> TrackingOptOutPage:
113
+ """One page of the server's not-tracked list, newest first.
114
+
115
+ An entry whose ``address`` is ``None`` was erased, and only a keyed
116
+ hash of it remains.
117
+
118
+ Args:
119
+ limit: The page size, 1 to 1000; 100 when left out.
120
+ after: The ``next`` of the previous page.
121
+
122
+ Returns:
123
+ The page.
124
+
125
+ Example:
126
+ >>> page = await sendora.tracking_opt_outs.list(limit=200)
127
+ """
128
+ return decode(
129
+ TrackingOptOutPage, await self._transport.send(_list_call(limit, after))
130
+ )
131
+
132
+ async def list_all(
133
+ self, *, limit: int | None = None, after: str | None = None
134
+ ) -> AsyncIterator[TrackingOptOut]:
135
+ """Every entry of the list, page by page, from ``after`` on.
136
+
137
+ Each page is a request of its own, repeated on its own when it
138
+ fails.
139
+
140
+ Args:
141
+ limit: The page size, 1 to 1000; 100 when left out.
142
+ after: The cursor to begin after.
143
+
144
+ Yields:
145
+ Each entry.
146
+
147
+ Example:
148
+ >>> async for entry in sendora.tracking_opt_outs.list_all():
149
+ ... print(entry.address, entry.source)
150
+ """
151
+ async for entry in iterate_async(
152
+ lambda cursor: self.list(limit=limit, after=cursor),
153
+ _entries_of,
154
+ _next_of,
155
+ after,
156
+ ):
157
+ yield entry
158
+
159
+ async def add(self, *, address: str) -> TrackingOptOut:
160
+ """Puts an address on the server's list, so that it is never tracked.
161
+
162
+ That holds for mail already sent too. An address already on the list
163
+ changes nothing, and the entry comes back as it stands.
164
+
165
+ Args:
166
+ address: The address never to track.
167
+
168
+ Returns:
169
+ The entry.
170
+
171
+ Example:
172
+ >>> entry = await sendora.tracking_opt_outs.add(address="anna@example.com")
173
+ """
174
+ return decode(TrackingOptOut, await self._transport.send(_add_call(address)))
175
+
176
+ async def delete(self, *, address: str) -> None:
177
+ """Takes an address off the server's list, so it can be tracked again.
178
+
179
+ Remove an entry whose ``source`` is ``recipient`` only after new
180
+ consent. An erased entry is found by its address too. A delete
181
+ repeated after a lost answer whose first attempt took effect raises
182
+ ``not_found``.
183
+
184
+ Args:
185
+ address: The address to track again.
186
+
187
+ Raises:
188
+ NotFoundError: The address is not on the list.
189
+
190
+ Example:
191
+ >>> await sendora.tracking_opt_outs.delete(address="anna@example.com")
192
+ """
193
+ await self._transport.send(_delete_call(address))
194
+
195
+
196
+ def _list_call(limit: int | None, after: str | None) -> Call:
197
+ return Call(
198
+ "GET",
199
+ "/v1/tracking-opt-outs",
200
+ idempotent=True,
201
+ query={"limit": limit, "after": after},
202
+ )
203
+
204
+
205
+ def _add_call(address: str) -> Call:
206
+ return Call(
207
+ "POST", "/v1/tracking-opt-outs", idempotent=True, body={"address": address}
208
+ )
209
+
210
+
211
+ def _delete_call(address: str) -> Call:
212
+ return Call(
213
+ "POST",
214
+ "/v1/tracking-opt-outs/delete",
215
+ idempotent=True,
216
+ body={"address": address},
217
+ expect="none",
218
+ )
219
+
220
+
221
+ def _entries_of(page: TrackingOptOutPage) -> list[TrackingOptOut]:
222
+ return page.tracking_opt_outs
223
+
224
+
225
+ def _next_of(page: TrackingOptOutPage) -> str | None:
226
+ return page.next
@@ -44,7 +44,8 @@ class Webhooks:
44
44
  Args:
45
45
  url: Where the events are posted: https, without credentials or
46
46
  a fragment.
47
- events: The events to deliver; every event when left out.
47
+ events: The events to deliver; every event except ``opened`` and
48
+ ``clicked`` when left out.
48
49
  stream_id: The one stream whose message events it receives;
49
50
  every stream when left out. Usage events arrive either way.
50
51
  inbound_content: How the ``inbound`` event carries a received
@@ -261,7 +262,8 @@ class AsyncWebhooks:
261
262
  Args:
262
263
  url: Where the events are posted: https, without credentials or
263
264
  a fragment.
264
- events: The events to deliver; every event when left out.
265
+ events: The events to deliver; every event except ``opened`` and
266
+ ``clicked`` when left out.
265
267
  stream_id: The one stream whose message events it receives;
266
268
  every stream when left out. Usage events arrive either way.
267
269
  inbound_content: How the ``inbound`` event carries a received
@@ -14,7 +14,8 @@ and every event is in it.
14
14
 
15
15
  - Two kinds of key, two clients. A server key (`sk_…`) drives
16
16
  `Sendora()`: `email`, `broadcasts`, `messages`, `inbound`,
17
- `inbound_domains`, `server`, `streams`, `suppressions`, `tokens`,
17
+ `inbound_domains`, `server`, `streams`, `suppressions`,
18
+ `tracking_opt_outs`, `tokens`,
18
19
  `webhooks`, everything inside one server. An account key (`ak_…`)
19
20
  drives `SendoraAccount()`: `servers`, `servers.tokens`, `domains` and
20
21
  `tracking_domains`, and it never sends. A key of the wrong kind raises `wrong_token_kind`.
@@ -63,6 +64,9 @@ and every event is in it.
63
64
  comparing `event.event`, and handle `UnknownEvent`, an event newer than
64
65
  the release. Answer 2xx quickly and do the work afterwards. Deliveries
65
66
  arrive at least once, so key handling on `event.id`.
67
+ - `tracking_opt_outs` is the server's list of addresses that are never
68
+ tracked. Remove an entry whose `source` is `recipient` only after new
69
+ consent. An erased entry has `address=None`, and only its address removes it.
66
70
  - Addresses on the suppression list are refused with
67
71
  `recipient_suppressed`; lift a hard bounce with
68
72
  `suppressions.delete(address=...)`; a spam complaint or an unsubscribe
@@ -48,6 +48,8 @@ __all__ = [
48
48
  "CapEventFields",
49
49
  "CapReachedEvent",
50
50
  "CapWarningEvent",
51
+ "ClickedDetails",
52
+ "ClickedEvent",
51
53
  "CnameRecord",
52
54
  "CreatedServer",
53
55
  "CreatedToken",
@@ -99,6 +101,8 @@ __all__ = [
99
101
  "MessageStatus",
100
102
  "MessageTracking",
101
103
  "MxRecord",
104
+ "OpenedDetails",
105
+ "OpenedEvent",
102
106
  "OwnServer",
103
107
  "RecipientKind",
104
108
  "RecipientStatus",
@@ -139,6 +143,9 @@ __all__ = [
139
143
  "TrackingDomain",
140
144
  "TrackingDomainList",
141
145
  "TrackingDomainState",
146
+ "TrackingOptOut",
147
+ "TrackingOptOutPage",
148
+ "TrackingOptOutSource",
142
149
  "TrackingReason",
143
150
  "TrackingSkipped",
144
151
  "TxtRecord",
@@ -493,7 +500,7 @@ report received by mail, ``Feedback`` for a spam complaint, ``Open`` and
493
500
 
494
501
  TrackingAutomated = Literal["likely", "unlikely", "unknown"]
495
502
  """Whether a machine rather than a person did it: ``unknown`` where nobody
496
- can tell, as with Apple Mail."""
503
+ can tell, as with Apple's privacy protection."""
497
504
 
498
505
  TrackingAgent = Literal[
499
506
  "apple_privacy",
@@ -1041,6 +1048,44 @@ class SuppressionPage(Answer):
1041
1048
  next: str | None
1042
1049
 
1043
1050
 
1051
+ TrackingOptOutSource = Literal["manual", "recipient"]
1052
+ """How an address came onto a server's not-tracked list."""
1053
+
1054
+
1055
+ @model
1056
+ class TrackingOptOut(Answer):
1057
+ """An address that the server never tracks.
1058
+
1059
+ Attributes:
1060
+ address: The address, lower-cased. ``None`` once an erasure has left
1061
+ only a keyed hash.
1062
+ source: ``manual`` if added through the API or the dashboard,
1063
+ ``recipient`` if the recipient asked for it. Remove a recipient's
1064
+ own entry only after new consent.
1065
+ message_id: The message that a recipient asked from. ``None`` on a
1066
+ manual entry, and once that message or the address has gone.
1067
+ created_at: When it was added.
1068
+ """
1069
+
1070
+ address: str | None
1071
+ source: TrackingOptOutSource
1072
+ message_id: str | None
1073
+ created_at: datetime
1074
+
1075
+
1076
+ @model
1077
+ class TrackingOptOutPage(Answer):
1078
+ """One page of a server's not-tracked list, newest first.
1079
+
1080
+ Attributes:
1081
+ tracking_opt_outs: The page's entries.
1082
+ next: Pass as ``after`` for the next page; ``None`` on the last.
1083
+ """
1084
+
1085
+ tracking_opt_outs: list[TrackingOptOut]
1086
+ next: str | None
1087
+
1088
+
1044
1089
  @model
1045
1090
  class Token(Answer):
1046
1091
  """A key of a server; its value is shown only when it is created.
@@ -1189,6 +1234,8 @@ WebhookEventName = Literal[
1189
1234
  "deferred",
1190
1235
  "spam_complaint",
1191
1236
  "unsubscribed",
1237
+ "opened",
1238
+ "clicked",
1192
1239
  "cap_warning",
1193
1240
  "cap_reached",
1194
1241
  "inbound",
@@ -2083,6 +2130,86 @@ class UnsubscribedEvent(MessageEventFields):
2083
2130
  details: UnsubscribedDetails
2084
2131
 
2085
2132
 
2133
+ @model
2134
+ class OpenedDetails(Answer):
2135
+ """What tracking knows of an open.
2136
+
2137
+ Attributes:
2138
+ automated: Whether a machine rather than a person did it.
2139
+ agent: The proxy or machine it came through, where Sendora knows it.
2140
+ reasons: Why it was labelled a machine's.
2141
+ client: The mail program, where Sendora knows it.
2142
+ device: The kind of device, where Sendora knows it.
2143
+ first: The recipient's first event of this kind.
2144
+ first_human: The recipient's first event of this kind that was
2145
+ likely a person's.
2146
+ """
2147
+
2148
+ automated: TrackingAutomated
2149
+ agent: TrackingAgent | None
2150
+ reasons: list[TrackingReason]
2151
+ client: TrackingClient | None
2152
+ device: TrackingDevice | None
2153
+ first: bool
2154
+ first_human: bool
2155
+
2156
+
2157
+ @model
2158
+ class OpenedEvent(MessageEventFields):
2159
+ """The pixel of a tracked copy loaded, for a person or a machine.
2160
+
2161
+ A webhook gets this event only if it names it.
2162
+
2163
+ Attributes:
2164
+ event: ``opened``.
2165
+ details: What tracking knows of it.
2166
+ """
2167
+
2168
+ event: Literal["opened"]
2169
+ details: OpenedDetails
2170
+
2171
+
2172
+ @model
2173
+ class ClickedDetails(Answer):
2174
+ """What tracking knows of a click.
2175
+
2176
+ Attributes:
2177
+ automated: Whether a machine rather than a person did it.
2178
+ agent: The proxy or machine it came through, where Sendora knows it.
2179
+ reasons: Why it was labelled a machine's.
2180
+ client: The mail program, where Sendora knows it.
2181
+ device: The kind of device, where Sendora knows it.
2182
+ first: The recipient's first event of this kind.
2183
+ first_human: The recipient's first event of this kind that was
2184
+ likely a person's.
2185
+ link: The link that was followed.
2186
+ """
2187
+
2188
+ automated: TrackingAutomated
2189
+ agent: TrackingAgent | None
2190
+ reasons: list[TrackingReason]
2191
+ client: TrackingClient | None
2192
+ device: TrackingDevice | None
2193
+ first: bool
2194
+ first_human: bool
2195
+ link: TrackedLink
2196
+
2197
+
2198
+ @model
2199
+ class ClickedEvent(MessageEventFields):
2200
+ """A tracked link was followed, by a person or a machine.
2201
+
2202
+ A webhook gets this event only if it names it.
2203
+
2204
+ Attributes:
2205
+ event: ``clicked``.
2206
+ details: What tracking knows of it.
2207
+ """
2208
+
2209
+ event: Literal["clicked"]
2210
+ details: ClickedDetails
2211
+
2212
+
2086
2213
  @model
2087
2214
  class CapEventFields(Answer):
2088
2215
  """What both usage events carry.
@@ -2316,6 +2443,8 @@ WebhookEvent = (
2316
2443
  | DeferredEvent
2317
2444
  | SpamComplaintEvent
2318
2445
  | UnsubscribedEvent
2446
+ | OpenedEvent
2447
+ | ClickedEvent
2319
2448
  | CapWarningEvent
2320
2449
  | CapReachedEvent
2321
2450
  | InboundEvent
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