sendora 2.1.0__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.1.0 → sendora-2.2.0}/CHANGELOG.md +10 -0
  2. {sendora-2.1.0 → sendora-2.2.0}/PKG-INFO +22 -17
  3. {sendora-2.1.0 → sendora-2.2.0}/README.md +21 -16
  4. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/_version.py +1 -1
  5. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/resources/broadcasts.py +15 -7
  6. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/resources/messages.py +2 -2
  7. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/types.py +184 -2
  8. {sendora-2.1.0 → sendora-2.2.0}/.gitignore +0 -0
  9. {sendora-2.1.0 → sendora-2.2.0}/LICENSE +0 -0
  10. {sendora-2.1.0 → sendora-2.2.0}/pyproject.toml +0 -0
  11. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/__init__.py +0 -0
  12. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/_account.py +0 -0
  13. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/_answers.py +0 -0
  14. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/_client.py +0 -0
  15. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/_errors.py +0 -0
  16. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/_json.py +0 -0
  17. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/_keys.py +0 -0
  18. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/_model.py +0 -0
  19. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/_options.py +0 -0
  20. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/_pages.py +0 -0
  21. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/_retry.py +0 -0
  22. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/_transport.py +0 -0
  23. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/_webhooks.py +0 -0
  24. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/_wire.py +0 -0
  25. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/py.typed +0 -0
  26. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/resources/__init__.py +0 -0
  27. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/resources/domains.py +0 -0
  28. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/resources/email.py +0 -0
  29. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/resources/erasures.py +0 -0
  30. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/resources/inbound.py +0 -0
  31. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/resources/inbound_domains.py +0 -0
  32. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/resources/server.py +0 -0
  33. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/resources/servers.py +0 -0
  34. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/resources/streams.py +0 -0
  35. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/resources/suppressions.py +0 -0
  36. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/resources/tokens.py +0 -0
  37. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/resources/tracking_domains.py +0 -0
  38. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/resources/webhooks.py +0 -0
  39. {sendora-2.1.0 → sendora-2.2.0}/src/sendora/skills/sendora/SKILL.md +0 -0
@@ -1,5 +1,15 @@
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
+
3
13
  ## 2.1.0
4
14
 
5
15
  - `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.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
@@ -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(
@@ -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(
@@ -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.2.0"
@@ -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.
@@ -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,14 +126,21 @@ __all__ = [
122
126
  "SuppressionReason",
123
127
  "Token",
124
128
  "TokenList",
129
+ "TrackedLink",
130
+ "TrackingAgent",
131
+ "TrackingAutomated",
125
132
  "TrackingCaaCheck",
126
133
  "TrackingCaaCheckResult",
127
134
  "TrackingCaaRecord",
135
+ "TrackingClient",
128
136
  "TrackingCnameCheck",
129
137
  "TrackingCnameRecord",
138
+ "TrackingDevice",
130
139
  "TrackingDomain",
131
140
  "TrackingDomainList",
132
141
  "TrackingDomainState",
142
+ "TrackingReason",
143
+ "TrackingSkipped",
133
144
  "TxtRecord",
134
145
  "UnknownEvent",
135
146
  "UnsubscribedDetails",
@@ -472,10 +483,58 @@ MessageEventType = Literal[
472
483
  "DeferredInjectionRebind",
473
484
  "Rejection",
474
485
  "Delayed",
486
+ "Open",
487
+ "Click",
475
488
  ]
476
489
  """A step of a message's timeline: ``Reception`` when the mail server took
477
490
  it, ``Delivery``, ``TransientFailure``, ``Bounce``, ``OOB`` for a bounce
478
- 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."""
479
538
 
480
539
 
481
540
  @model
@@ -486,11 +545,25 @@ class MessageRecipient(Answer):
486
545
  address: The address.
487
546
  kind: Whether it was in ``to``, ``cc`` or ``bcc``.
488
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.
489
557
  """
490
558
 
491
559
  address: str
492
560
  kind: RecipientKind
493
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
494
567
 
495
568
 
496
569
  @model
@@ -542,6 +615,47 @@ class MessageAttachment(Answer):
542
615
  size: int
543
616
 
544
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
+
545
659
  @model
546
660
  class MessageEvent(Answer):
547
661
  """One step of a message's timeline.
@@ -555,6 +669,8 @@ class MessageEvent(Answer):
555
669
  details: The receiver's response text.
556
670
  classification: How a bounce was classified, such as
557
671
  ``InvalidRecipient``.
672
+ tracking: What tracking knows of an ``Open`` or a ``Click``; ``None``
673
+ on any other event.
558
674
  """
559
675
 
560
676
  recipient: str | None
@@ -563,6 +679,28 @@ class MessageEvent(Answer):
563
679
  code: int | None
564
680
  details: str | None
565
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
566
704
 
567
705
 
568
706
  @model
@@ -570,10 +708,13 @@ class MessageDetail(Message):
570
708
  """A message with its attachments described and its timeline.
571
709
 
572
710
  Attributes:
711
+ tracking: Whether opens and clicks are recorded now.
573
712
  attachments: Each attachment, described.
574
- 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.
575
715
  """
576
716
 
717
+ tracking: MessageTracking
577
718
  attachments: list[MessageAttachment]
578
719
  events: list[MessageEvent]
579
720
 
@@ -751,6 +892,47 @@ class Broadcast(Answer):
751
892
  completed_at: datetime | None
752
893
 
753
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
+
754
936
  @model
755
937
  class BroadcastPage(Answer):
756
938
  """One page of the server's broadcasts, newest first.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes