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.
- {sendora-2.2.0 → sendora-2.4.0}/CHANGELOG.md +13 -0
- {sendora-2.2.0 → sendora-2.4.0}/PKG-INFO +31 -5
- {sendora-2.2.0 → sendora-2.4.0}/README.md +30 -4
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/__init__.py +4 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_client.py +5 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_version.py +1 -1
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_webhooks.py +4 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/__init__.py +3 -0
- sendora-2.4.0/src/sendora/resources/tracking_opt_outs.py +226 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/webhooks.py +4 -2
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/skills/sendora/SKILL.md +5 -1
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/types.py +130 -1
- {sendora-2.2.0 → sendora-2.4.0}/.gitignore +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/LICENSE +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/pyproject.toml +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_account.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_answers.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_errors.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_json.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_keys.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_model.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_options.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_pages.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_retry.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_transport.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/_wire.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/py.typed +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/broadcasts.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/domains.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/email.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/erasures.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/inbound.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/inbound_domains.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/messages.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/server.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/servers.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/streams.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/suppressions.py +0 -0
- {sendora-2.2.0 → sendora-2.4.0}/src/sendora/resources/tokens.py +0 -0
- {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.
|
|
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`); `
|
|
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`); `
|
|
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)
|
|
@@ -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
|
|
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
|
|
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`,
|
|
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
|
|
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
|
|
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
|