sendora 2.1.0__tar.gz → 2.3.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.1.0 → sendora-2.3.0}/CHANGELOG.md +17 -0
  2. {sendora-2.1.0 → sendora-2.3.0}/PKG-INFO +27 -19
  3. {sendora-2.1.0 → sendora-2.3.0}/README.md +26 -18
  4. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/__init__.py +4 -0
  5. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/_version.py +1 -1
  6. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/_webhooks.py +4 -0
  7. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/resources/broadcasts.py +15 -7
  8. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/resources/messages.py +2 -2
  9. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/resources/webhooks.py +4 -2
  10. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/types.py +272 -2
  11. {sendora-2.1.0 → sendora-2.3.0}/.gitignore +0 -0
  12. {sendora-2.1.0 → sendora-2.3.0}/LICENSE +0 -0
  13. {sendora-2.1.0 → sendora-2.3.0}/pyproject.toml +0 -0
  14. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/_account.py +0 -0
  15. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/_answers.py +0 -0
  16. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/_client.py +0 -0
  17. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/_errors.py +0 -0
  18. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/_json.py +0 -0
  19. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/_keys.py +0 -0
  20. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/_model.py +0 -0
  21. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/_options.py +0 -0
  22. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/_pages.py +0 -0
  23. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/_retry.py +0 -0
  24. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/_transport.py +0 -0
  25. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/_wire.py +0 -0
  26. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/py.typed +0 -0
  27. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/resources/__init__.py +0 -0
  28. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/resources/domains.py +0 -0
  29. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/resources/email.py +0 -0
  30. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/resources/erasures.py +0 -0
  31. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/resources/inbound.py +0 -0
  32. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/resources/inbound_domains.py +0 -0
  33. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/resources/server.py +0 -0
  34. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/resources/servers.py +0 -0
  35. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/resources/streams.py +0 -0
  36. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/resources/suppressions.py +0 -0
  37. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/resources/tokens.py +0 -0
  38. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/resources/tracking_domains.py +0 -0
  39. {sendora-2.1.0 → sendora-2.3.0}/src/sendora/skills/sendora/SKILL.md +0 -0
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.3.0
4
+
5
+ - Two events, `OpenedEvent` and `ClickedEvent`, for `opened` and `clicked`:
6
+ a tracked copy's pixel loaded, or a tracked link was followed. A webhook
7
+ gets them only if it names them, so `webhooks.create` without `events`
8
+ takes every other event.
9
+
10
+ ## 2.2.0
11
+
12
+ - Each `MessageRecipient` has `tracked`, `opens`, `clicks`, `first_open_at`
13
+ and `first_human_open_at`.
14
+ - `messages.get` returns `tracking`, with `recording_opens`,
15
+ `recording_clicks` and `skipped`. Its timeline holds `Open` and `Click`
16
+ events, each with its `tracking`.
17
+ - `broadcasts.get` returns `BroadcastDetail`, a `Broadcast` with `opened`,
18
+ `opened_by_people`, `clicked`, `clicked_by_people` and `links`.
19
+
3
20
  ## 2.1.0
4
21
 
5
22
  - `SendoraAccount.tracking_domains` and `AsyncSendoraAccount.tracking_domains`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sendora
3
- Version: 2.1.0
3
+ Version: 2.3.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
@@ -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(
@@ -305,7 +310,9 @@ Events: `delivered`, `bounced`, `deferred`, `spam_complaint` and
305
310
  `unsubscribed` about a message, with `message_id`, `stream_id`,
306
311
  `recipient`, `occurred_at`, `server_id`, your `tag` and `metadata`, and
307
312
  `details` from the receiver, or for `unsubscribed` the `source` (`link` or
308
- `one_click`); `cap_warning` and `cap_reached` about usage, with `scope`,
313
+ `one_click`); `opened` and `clicked` about a tracked copy, sent only to a
314
+ webhook that names them, with the same fields and `details` that say whether
315
+ a machine did it and, for `clicked`, give the `link`; `cap_warning` and `cap_reached` about usage, with `scope`,
309
316
  `cap`, `used` and the period; `inbound` about a message received on an
310
317
  inbound stream, with `inbound_message_id`, `envelope_recipient`, the
311
318
  sizes, the `authentication` verdicts and, for a webhook with
@@ -313,7 +320,8 @@ sizes, the `authentication` verdicts and, for a webhook with
313
320
  `to`, `subject`, `text`, `html`, `attachments` and the rest.
314
321
  `inbound_cap_warning` and `inbound_cap_reached` say that received mail
315
322
  nears or reaches the account's monthly cap, with `cap`, `used` and the
316
- period. Every event carries `id`, the delivery id, and `attempt`.
323
+ period. Every event carries `id`, the delivery id, and `attempt`. Tracking is
324
+ not open to accounts yet, so `opened` and `clicked` are not sent until it is.
317
325
 
318
326
  A failed delivery is retried with growing delays for about a day, then it
319
327
  is `dead` until you replay it. Deliveries arrive at least once and not
@@ -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(
@@ -283,7 +288,9 @@ Events: `delivered`, `bounced`, `deferred`, `spam_complaint` and
283
288
  `unsubscribed` about a message, with `message_id`, `stream_id`,
284
289
  `recipient`, `occurred_at`, `server_id`, your `tag` and `metadata`, and
285
290
  `details` from the receiver, or for `unsubscribed` the `source` (`link` or
286
- `one_click`); `cap_warning` and `cap_reached` about usage, with `scope`,
291
+ `one_click`); `opened` and `clicked` about a tracked copy, sent only to a
292
+ webhook that names them, with the same fields and `details` that say whether
293
+ a machine did it and, for `clicked`, give the `link`; `cap_warning` and `cap_reached` about usage, with `scope`,
287
294
  `cap`, `used` and the period; `inbound` about a message received on an
288
295
  inbound stream, with `inbound_message_id`, `envelope_recipient`, the
289
296
  sizes, the `authentication` verdicts and, for a webhook with
@@ -291,7 +298,8 @@ sizes, the `authentication` verdicts and, for a webhook with
291
298
  `to`, `subject`, `text`, `html`, `attachments` and the rest.
292
299
  `inbound_cap_warning` and `inbound_cap_reached` say that received mail
293
300
  nears or reaches the account's monthly cap, with `cap`, `used` and the
294
- period. Every event carries `id`, the delivery id, and `attempt`.
301
+ period. Every event carries `id`, the delivery id, and `attempt`. Tracking is
302
+ not open to accounts yet, so `opened` and `clicked` are not sent until it is.
295
303
 
296
304
  A failed delivery is retried with growing delays for about a day, then it
297
305
  is `dead` until you replay it. Deliveries arrive at least once and not
@@ -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",
@@ -1,3 +1,3 @@
1
1
  """The package's version: the build reads it, and every request names it."""
2
2
 
3
- __version__ = "2.1.0"
3
+ __version__ = "2.3.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,
@@ -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.
@@ -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
@@ -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",
@@ -46,6 +48,8 @@ __all__ = [
46
48
  "CapEventFields",
47
49
  "CapReachedEvent",
48
50
  "CapWarningEvent",
51
+ "ClickedDetails",
52
+ "ClickedEvent",
49
53
  "CnameRecord",
50
54
  "CreatedServer",
51
55
  "CreatedToken",
@@ -89,12 +93,16 @@ __all__ = [
89
93
  "MessageDetail",
90
94
  "MessageEvent",
91
95
  "MessageEventFields",
96
+ "MessageEventTracking",
92
97
  "MessageEventType",
93
98
  "MessagePage",
94
99
  "MessageRecipient",
95
100
  "MessageSearchParams",
96
101
  "MessageStatus",
102
+ "MessageTracking",
97
103
  "MxRecord",
104
+ "OpenedDetails",
105
+ "OpenedEvent",
98
106
  "OwnServer",
99
107
  "RecipientKind",
100
108
  "RecipientStatus",
@@ -122,14 +130,21 @@ __all__ = [
122
130
  "SuppressionReason",
123
131
  "Token",
124
132
  "TokenList",
133
+ "TrackedLink",
134
+ "TrackingAgent",
135
+ "TrackingAutomated",
125
136
  "TrackingCaaCheck",
126
137
  "TrackingCaaCheckResult",
127
138
  "TrackingCaaRecord",
139
+ "TrackingClient",
128
140
  "TrackingCnameCheck",
129
141
  "TrackingCnameRecord",
142
+ "TrackingDevice",
130
143
  "TrackingDomain",
131
144
  "TrackingDomainList",
132
145
  "TrackingDomainState",
146
+ "TrackingReason",
147
+ "TrackingSkipped",
133
148
  "TxtRecord",
134
149
  "UnknownEvent",
135
150
  "UnsubscribedDetails",
@@ -472,10 +487,58 @@ MessageEventType = Literal[
472
487
  "DeferredInjectionRebind",
473
488
  "Rejection",
474
489
  "Delayed",
490
+ "Open",
491
+ "Click",
475
492
  ]
476
493
  """A step of a message's timeline: ``Reception`` when the mail server took
477
494
  it, ``Delivery``, ``TransientFailure``, ``Bounce``, ``OOB`` for a bounce
478
- report received by mail, ``Feedback`` for a spam complaint."""
495
+ report received by mail, ``Feedback`` for a spam complaint, ``Open`` and
496
+ ``Click`` for what tracking recorded."""
497
+
498
+ TrackingAutomated = Literal["likely", "unlikely", "unknown"]
499
+ """Whether a machine rather than a person did it: ``unknown`` where nobody
500
+ can tell, as with Apple's privacy protection."""
501
+
502
+ TrackingAgent = Literal[
503
+ "apple_privacy",
504
+ "gmail_prefetch",
505
+ "gmail_proxy",
506
+ "yahoo_proxy",
507
+ "outlook_prefetch",
508
+ "proton_proxy",
509
+ "scanner",
510
+ "crawler",
511
+ ]
512
+ """The proxy or machine an open or a click came through."""
513
+
514
+ TrackingReason = Literal[
515
+ "privacy_proxy",
516
+ "prefetch_signature",
517
+ "delivery_timing",
518
+ "link_burst",
519
+ "scanner_network",
520
+ "bot_user_agent",
521
+ "many_networks",
522
+ ]
523
+ """Why an open or a click was labelled a machine's."""
524
+
525
+ TrackingClient = Literal[
526
+ "apple_mail",
527
+ "gmail",
528
+ "outlook",
529
+ "yahoo",
530
+ "thunderbird",
531
+ "proton",
532
+ "browser",
533
+ "other",
534
+ ]
535
+ """The mail program an open or a click came from."""
536
+
537
+ TrackingDevice = Literal["desktop", "mobile", "tablet"]
538
+ """The kind of device an open or a click came from."""
539
+
540
+ TrackingSkipped = Literal["no_tracking_domain", "gmail_clipping"]
541
+ """Why tracking stayed out of at least one copy of a message."""
479
542
 
480
543
 
481
544
  @model
@@ -486,11 +549,25 @@ class MessageRecipient(Answer):
486
549
  address: The address.
487
550
  kind: Whether it was in ``to``, ``cc`` or ``bcc``.
488
551
  status: Where its delivery stands.
552
+ tracked: Whether tracking applied to its copy when it left. It does
553
+ not say to opens, links or both, or that the HTML held a link to
554
+ track.
555
+ opens: Opens recorded, machines' included.
556
+ clicks: Clicks recorded, machines' included.
557
+ first_open_at: The first open, or the first click that was likely a
558
+ person's; ``None`` before that.
559
+ first_human_open_at: The same, counting only what was likely a
560
+ person; ``None`` before that.
489
561
  """
490
562
 
491
563
  address: str
492
564
  kind: RecipientKind
493
565
  status: RecipientStatus
566
+ tracked: bool
567
+ opens: int
568
+ clicks: int
569
+ first_open_at: datetime | None
570
+ first_human_open_at: datetime | None
494
571
 
495
572
 
496
573
  @model
@@ -542,6 +619,47 @@ class MessageAttachment(Answer):
542
619
  size: int
543
620
 
544
621
 
622
+ @model
623
+ class TrackedLink(Answer):
624
+ """The link a recipient clicked.
625
+
626
+ Attributes:
627
+ index: Its position in the HTML, the same in every copy, counting
628
+ from 0.
629
+ url: Where it led; ``None`` once it has gone with the message's text
630
+ and HTML.
631
+ """
632
+
633
+ index: int
634
+ url: str | None
635
+
636
+
637
+ @model
638
+ class MessageEventTracking(Answer):
639
+ """What tracking knows of an ``Open`` or a ``Click``.
640
+
641
+ Attributes:
642
+ automated: Whether a machine rather than a person did it.
643
+ agent: The proxy or machine it came through, where Sendora knows it.
644
+ reasons: Why it was labelled a machine's.
645
+ client: The mail program, where Sendora knows it.
646
+ device: The kind of device, where Sendora knows it.
647
+ first: The recipient's first event of this kind.
648
+ first_human: The recipient's first event of this kind that was
649
+ likely a person's.
650
+ link: The link clicked; ``None`` on an ``Open``.
651
+ """
652
+
653
+ automated: TrackingAutomated
654
+ agent: TrackingAgent | None
655
+ reasons: list[TrackingReason]
656
+ client: TrackingClient | None
657
+ device: TrackingDevice | None
658
+ first: bool
659
+ first_human: bool
660
+ link: TrackedLink | None
661
+
662
+
545
663
  @model
546
664
  class MessageEvent(Answer):
547
665
  """One step of a message's timeline.
@@ -555,6 +673,8 @@ class MessageEvent(Answer):
555
673
  details: The receiver's response text.
556
674
  classification: How a bounce was classified, such as
557
675
  ``InvalidRecipient``.
676
+ tracking: What tracking knows of an ``Open`` or a ``Click``; ``None``
677
+ on any other event.
558
678
  """
559
679
 
560
680
  recipient: str | None
@@ -563,6 +683,28 @@ class MessageEvent(Answer):
563
683
  code: int | None
564
684
  details: str | None
565
685
  classification: str | None
686
+ tracking: MessageEventTracking | None
687
+
688
+
689
+ @model
690
+ class MessageTracking(Answer):
691
+ """Whether opens and clicks are recorded now, and why tracking stayed out.
692
+
693
+ Attributes:
694
+ recording_opens: Whether new opens of this message are recorded now,
695
+ by its own setting, then its broadcast's, then its stream's at
696
+ this moment. This says nothing of what a copy already sent
697
+ carries.
698
+ recording_clicks: The same for clicks on its links.
699
+ skipped: Why tracking stayed out of at least one copy: the from
700
+ domain had no working tracking domain, or tracked links would
701
+ have taken the HTML past where Gmail clips it. Each recipient's
702
+ ``tracked`` says whether its copy was tracked.
703
+ """
704
+
705
+ recording_opens: bool
706
+ recording_clicks: bool
707
+ skipped: TrackingSkipped | None
566
708
 
567
709
 
568
710
  @model
@@ -570,10 +712,13 @@ class MessageDetail(Message):
570
712
  """A message with its attachments described and its timeline.
571
713
 
572
714
  Attributes:
715
+ tracking: Whether opens and clicks are recorded now.
573
716
  attachments: Each attachment, described.
574
- events: Every event, oldest first.
717
+ events: The events, oldest first. Opens and clicks are capped, while
718
+ each recipient's ``opens`` and ``clicks`` count them all.
575
719
  """
576
720
 
721
+ tracking: MessageTracking
577
722
  attachments: list[MessageAttachment]
578
723
  events: list[MessageEvent]
579
724
 
@@ -751,6 +896,47 @@ class Broadcast(Answer):
751
896
  completed_at: datetime | None
752
897
 
753
898
 
899
+ @model
900
+ class BroadcastLink(Answer):
901
+ """A tracked link of a broadcast that has a click.
902
+
903
+ Attributes:
904
+ index: Its position in the HTML, the same in every copy, counting
905
+ from 0.
906
+ url: Where the first click led, since a substitution can give a link
907
+ another URL per recipient; ``None`` once it has gone with the
908
+ content.
909
+ clicked: Recipients who clicked this link, machines' included.
910
+ clicked_by_people: Those of them likely to be people.
911
+ """
912
+
913
+ index: int
914
+ url: str | None
915
+ clicked: int
916
+ clicked_by_people: int
917
+
918
+
919
+ @model
920
+ class BroadcastDetail(Broadcast):
921
+ """A broadcast, how far it has come and what tracking recorded of it.
922
+
923
+ Attributes:
924
+ opened: Recipients with an open, machines' included, or a click that
925
+ was likely a person's.
926
+ opened_by_people: Those of them likely to be people.
927
+ clicked: Recipients who clicked a link, machines' included.
928
+ clicked_by_people: Those of them likely to be people.
929
+ links: Each tracked link that has a click, by its index, counted from
930
+ the clicks each recipient keeps.
931
+ """
932
+
933
+ opened: int
934
+ opened_by_people: int
935
+ clicked: int
936
+ clicked_by_people: int
937
+ links: list[BroadcastLink]
938
+
939
+
754
940
  @model
755
941
  class BroadcastPage(Answer):
756
942
  """One page of the server's broadcasts, newest first.
@@ -1007,6 +1193,8 @@ WebhookEventName = Literal[
1007
1193
  "deferred",
1008
1194
  "spam_complaint",
1009
1195
  "unsubscribed",
1196
+ "opened",
1197
+ "clicked",
1010
1198
  "cap_warning",
1011
1199
  "cap_reached",
1012
1200
  "inbound",
@@ -1901,6 +2089,86 @@ class UnsubscribedEvent(MessageEventFields):
1901
2089
  details: UnsubscribedDetails
1902
2090
 
1903
2091
 
2092
+ @model
2093
+ class OpenedDetails(Answer):
2094
+ """What tracking knows of an open.
2095
+
2096
+ Attributes:
2097
+ automated: Whether a machine rather than a person did it.
2098
+ agent: The proxy or machine it came through, where Sendora knows it.
2099
+ reasons: Why it was labelled a machine's.
2100
+ client: The mail program, where Sendora knows it.
2101
+ device: The kind of device, where Sendora knows it.
2102
+ first: The recipient's first event of this kind.
2103
+ first_human: The recipient's first event of this kind that was
2104
+ likely a person's.
2105
+ """
2106
+
2107
+ automated: TrackingAutomated
2108
+ agent: TrackingAgent | None
2109
+ reasons: list[TrackingReason]
2110
+ client: TrackingClient | None
2111
+ device: TrackingDevice | None
2112
+ first: bool
2113
+ first_human: bool
2114
+
2115
+
2116
+ @model
2117
+ class OpenedEvent(MessageEventFields):
2118
+ """The pixel of a tracked copy loaded, for a person or a machine.
2119
+
2120
+ A webhook gets this event only if it names it.
2121
+
2122
+ Attributes:
2123
+ event: ``opened``.
2124
+ details: What tracking knows of it.
2125
+ """
2126
+
2127
+ event: Literal["opened"]
2128
+ details: OpenedDetails
2129
+
2130
+
2131
+ @model
2132
+ class ClickedDetails(Answer):
2133
+ """What tracking knows of a click.
2134
+
2135
+ Attributes:
2136
+ automated: Whether a machine rather than a person did it.
2137
+ agent: The proxy or machine it came through, where Sendora knows it.
2138
+ reasons: Why it was labelled a machine's.
2139
+ client: The mail program, where Sendora knows it.
2140
+ device: The kind of device, where Sendora knows it.
2141
+ first: The recipient's first event of this kind.
2142
+ first_human: The recipient's first event of this kind that was
2143
+ likely a person's.
2144
+ link: The link that was followed.
2145
+ """
2146
+
2147
+ automated: TrackingAutomated
2148
+ agent: TrackingAgent | None
2149
+ reasons: list[TrackingReason]
2150
+ client: TrackingClient | None
2151
+ device: TrackingDevice | None
2152
+ first: bool
2153
+ first_human: bool
2154
+ link: TrackedLink
2155
+
2156
+
2157
+ @model
2158
+ class ClickedEvent(MessageEventFields):
2159
+ """A tracked link was followed, by a person or a machine.
2160
+
2161
+ A webhook gets this event only if it names it.
2162
+
2163
+ Attributes:
2164
+ event: ``clicked``.
2165
+ details: What tracking knows of it.
2166
+ """
2167
+
2168
+ event: Literal["clicked"]
2169
+ details: ClickedDetails
2170
+
2171
+
1904
2172
  @model
1905
2173
  class CapEventFields(Answer):
1906
2174
  """What both usage events carry.
@@ -2134,6 +2402,8 @@ WebhookEvent = (
2134
2402
  | DeferredEvent
2135
2403
  | SpamComplaintEvent
2136
2404
  | UnsubscribedEvent
2405
+ | OpenedEvent
2406
+ | ClickedEvent
2137
2407
  | CapWarningEvent
2138
2408
  | CapReachedEvent
2139
2409
  | 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
File without changes