sendora 2.3.0__tar.gz → 2.5.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.3.0 → sendora-2.5.0}/CHANGELOG.md +12 -0
- {sendora-2.3.0 → sendora-2.5.0}/PKG-INFO +35 -4
- {sendora-2.3.0 → sendora-2.5.0}/README.md +34 -3
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/__init__.py +2 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/_client.py +5 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/_version.py +1 -1
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/_webhooks.py +2 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/resources/__init__.py +3 -0
- sendora-2.5.0/src/sendora/resources/tracking_opt_outs.py +226 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/skills/sendora/SKILL.md +5 -1
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/types.py +60 -0
- {sendora-2.3.0 → sendora-2.5.0}/.gitignore +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/LICENSE +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/pyproject.toml +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/_account.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/_answers.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/_errors.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/_json.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/_keys.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/_model.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/_options.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/_pages.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/_retry.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/_transport.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/_wire.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/py.typed +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/resources/broadcasts.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/resources/domains.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/resources/email.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/resources/erasures.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/resources/inbound.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/resources/inbound_domains.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/resources/messages.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/resources/server.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/resources/servers.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/resources/streams.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/resources/suppressions.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/resources/tokens.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/resources/tracking_domains.py +0 -0
- {sendora-2.3.0 → sendora-2.5.0}/src/sendora/resources/webhooks.py +0 -0
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.5.0
|
|
4
|
+
|
|
5
|
+
- A new event, `TrackingOptedOutEvent`, for `tracking_opted_out`: a recipient
|
|
6
|
+
asked not to be tracked, through the link that `{{ tracking_opt_out_url }}`
|
|
7
|
+
puts in your mail. `webhooks.create` without `events` takes it.
|
|
8
|
+
|
|
9
|
+
## 2.4.0
|
|
10
|
+
|
|
11
|
+
- `sendora.tracking_opt_outs` manages a server's not-tracked list: `list`,
|
|
12
|
+
`list_all`, `add` and `delete`. An entry names its `address`, which is
|
|
13
|
+
`None` once an erasure has left only a keyed hash, and its `source`.
|
|
14
|
+
|
|
3
15
|
## 2.3.0
|
|
4
16
|
|
|
5
17
|
- Two events, `OpenedEvent` and `ClickedEvent`, for `opened` and `clicked`:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sendora
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.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,35 @@ 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` when only a keyed hash of it is left, after an erasure or when the
|
|
266
|
+
recipient asked from mail that had left the log. `delete` still finds that entry
|
|
267
|
+
by its address, and nothing removes it without the address.
|
|
268
|
+
|
|
269
|
+
To let a recipient ask, write `{{ tracking_opt_out_url }}` in the text or the
|
|
270
|
+
HTML of a message on any stream. Each recipient then gets a copy with their own
|
|
271
|
+
link to a page in Swedish and English. Its button lists the address with the
|
|
272
|
+
`source` `recipient`, and sends the `tracking_opted_out` event.
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
sendora.tracking_opt_outs.add(address="anna@example.com")
|
|
276
|
+
```
|
|
277
|
+
|
|
249
278
|
## Tokens
|
|
250
279
|
|
|
251
280
|
The server's own keys, from its own key. Every key of a server has the
|
|
@@ -312,7 +341,9 @@ Events: `delivered`, `bounced`, `deferred`, `spam_complaint` and
|
|
|
312
341
|
`details` from the receiver, or for `unsubscribed` the `source` (`link` or
|
|
313
342
|
`one_click`); `opened` and `clicked` about a tracked copy, sent only to a
|
|
314
343
|
webhook that names them, with the same fields and `details` that say whether
|
|
315
|
-
a machine did it and, for `clicked`, give the `link`; `
|
|
344
|
+
a machine did it and, for `clicked`, give the `link`; `tracking_opted_out`
|
|
345
|
+
about a recipient who asked not to be tracked, with the same fields and no
|
|
346
|
+
`details`; `cap_warning` and `cap_reached` about usage, with `scope`,
|
|
316
347
|
`cap`, `used` and the period; `inbound` about a message received on an
|
|
317
348
|
inbound stream, with `inbound_message_id`, `envelope_recipient`, the
|
|
318
349
|
sizes, the `authentication` verdicts and, for a webhook with
|
|
@@ -532,7 +563,7 @@ except SendoraError as error:
|
|
|
532
563
|
| `account_not_active` | 403 | The account is not approved to send, or not active for an inbound stream. |
|
|
533
564
|
| `spam_complaint_locked` | 403 | Only support can lift a spam-complaint suppression. |
|
|
534
565
|
| `unsubscribe_locked` | 403 | Only support can lift a recipient's own unsubscribe. |
|
|
535
|
-
| `not_found` | 404 | No such id on this server, or no such suppressed address.
|
|
566
|
+
| `not_found` | 404 | No such id on this server, or no such suppressed or not-tracked address. |
|
|
536
567
|
| `domain_exists` | 409 | The account already has the domain; `existing_id` names it. |
|
|
537
568
|
| `domain_reserved` | 400 | The name is Sendora's own; no account can receive on it. |
|
|
538
569
|
| `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,35 @@ 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` when only a keyed hash of it is left, after an erasure or when the
|
|
244
|
+
recipient asked from mail that had left the log. `delete` still finds that entry
|
|
245
|
+
by its address, and nothing removes it without the address.
|
|
246
|
+
|
|
247
|
+
To let a recipient ask, write `{{ tracking_opt_out_url }}` in the text or the
|
|
248
|
+
HTML of a message on any stream. Each recipient then gets a copy with their own
|
|
249
|
+
link to a page in Swedish and English. Its button lists the address with the
|
|
250
|
+
`source` `recipient`, and sends the `tracking_opted_out` event.
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
sendora.tracking_opt_outs.add(address="anna@example.com")
|
|
254
|
+
```
|
|
255
|
+
|
|
227
256
|
## Tokens
|
|
228
257
|
|
|
229
258
|
The server's own keys, from its own key. Every key of a server has the
|
|
@@ -290,7 +319,9 @@ Events: `delivered`, `bounced`, `deferred`, `spam_complaint` and
|
|
|
290
319
|
`details` from the receiver, or for `unsubscribed` the `source` (`link` or
|
|
291
320
|
`one_click`); `opened` and `clicked` about a tracked copy, sent only to a
|
|
292
321
|
webhook that names them, with the same fields and `details` that say whether
|
|
293
|
-
a machine did it and, for `clicked`, give the `link`; `
|
|
322
|
+
a machine did it and, for `clicked`, give the `link`; `tracking_opted_out`
|
|
323
|
+
about a recipient who asked not to be tracked, with the same fields and no
|
|
324
|
+
`details`; `cap_warning` and `cap_reached` about usage, with `scope`,
|
|
294
325
|
`cap`, `used` and the period; `inbound` about a message received on an
|
|
295
326
|
inbound stream, with `inbound_message_id`, `envelope_recipient`, the
|
|
296
327
|
sizes, the `authentication` verdicts and, for a webhook with
|
|
@@ -510,7 +541,7 @@ except SendoraError as error:
|
|
|
510
541
|
| `account_not_active` | 403 | The account is not approved to send, or not active for an inbound stream. |
|
|
511
542
|
| `spam_complaint_locked` | 403 | Only support can lift a spam-complaint suppression. |
|
|
512
543
|
| `unsubscribe_locked` | 403 | Only support can lift a recipient's own unsubscribe. |
|
|
513
|
-
| `not_found` | 404 | No such id on this server, or no such suppressed address.
|
|
544
|
+
| `not_found` | 404 | No such id on this server, or no such suppressed or not-tracked address. |
|
|
514
545
|
| `domain_exists` | 409 | The account already has the domain; `existing_id` names it. |
|
|
515
546
|
| `domain_reserved` | 400 | The name is Sendora's own; no account can receive on it. |
|
|
516
547
|
| `tracking_domain_exists` | 409 | The account already has the host, or one on that sending domain; `existing_id` names it. |
|
|
@@ -42,6 +42,7 @@ from .types import (
|
|
|
42
42
|
OpenedEvent,
|
|
43
43
|
SendoraErrorCode,
|
|
44
44
|
SpamComplaintEvent,
|
|
45
|
+
TrackingOptedOutEvent,
|
|
45
46
|
UnknownEvent,
|
|
46
47
|
UnsubscribedEvent,
|
|
47
48
|
WebhookEvent,
|
|
@@ -79,6 +80,7 @@ __all__ = [
|
|
|
79
80
|
"SendoraError",
|
|
80
81
|
"SendoraErrorCode",
|
|
81
82
|
"SpamComplaintEvent",
|
|
83
|
+
"TrackingOptedOutEvent",
|
|
82
84
|
"UnknownEvent",
|
|
83
85
|
"UnprocessableEntityError",
|
|
84
86
|
"UnsubscribedEvent",
|
|
@@ -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)
|
|
@@ -24,6 +24,7 @@ from .types import (
|
|
|
24
24
|
InboundEventContent,
|
|
25
25
|
OpenedEvent,
|
|
26
26
|
SpamComplaintEvent,
|
|
27
|
+
TrackingOptedOutEvent,
|
|
27
28
|
UnknownEvent,
|
|
28
29
|
UnsubscribedEvent,
|
|
29
30
|
WebhookEvent,
|
|
@@ -173,6 +174,7 @@ _EVENTS: dict[str, Callable[[dict[str, object]], WebhookEvent]] = {
|
|
|
173
174
|
"unsubscribed": lambda payload: decode(UnsubscribedEvent, payload),
|
|
174
175
|
"opened": lambda payload: decode(OpenedEvent, payload),
|
|
175
176
|
"clicked": lambda payload: decode(ClickedEvent, payload),
|
|
177
|
+
"tracking_opted_out": lambda payload: decode(TrackingOptedOutEvent, payload),
|
|
176
178
|
"cap_warning": lambda payload: decode(CapWarningEvent, payload),
|
|
177
179
|
"cap_reached": lambda payload: decode(CapReachedEvent, payload),
|
|
178
180
|
"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
|
|
@@ -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
|
|
@@ -143,6 +143,10 @@ __all__ = [
|
|
|
143
143
|
"TrackingDomain",
|
|
144
144
|
"TrackingDomainList",
|
|
145
145
|
"TrackingDomainState",
|
|
146
|
+
"TrackingOptOut",
|
|
147
|
+
"TrackingOptOutPage",
|
|
148
|
+
"TrackingOptOutSource",
|
|
149
|
+
"TrackingOptedOutEvent",
|
|
146
150
|
"TrackingReason",
|
|
147
151
|
"TrackingSkipped",
|
|
148
152
|
"TxtRecord",
|
|
@@ -1045,6 +1049,45 @@ class SuppressionPage(Answer):
|
|
|
1045
1049
|
next: str | None
|
|
1046
1050
|
|
|
1047
1051
|
|
|
1052
|
+
TrackingOptOutSource = Literal["manual", "recipient"]
|
|
1053
|
+
"""How an address came onto a server's not-tracked list."""
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
@model
|
|
1057
|
+
class TrackingOptOut(Answer):
|
|
1058
|
+
"""An address that the server never tracks.
|
|
1059
|
+
|
|
1060
|
+
Attributes:
|
|
1061
|
+
address: The address, lower-cased. ``None`` when only a keyed hash is
|
|
1062
|
+
left: after an erasure, or when the recipient asked from mail that
|
|
1063
|
+
had left the log.
|
|
1064
|
+
source: ``manual`` if added through the API or the dashboard,
|
|
1065
|
+
``recipient`` if the recipient asked for it. Remove a recipient's
|
|
1066
|
+
own entry only after new consent.
|
|
1067
|
+
message_id: The message that a recipient asked from. ``None`` on a
|
|
1068
|
+
manual entry, and once that message or the address has gone.
|
|
1069
|
+
created_at: When it was added.
|
|
1070
|
+
"""
|
|
1071
|
+
|
|
1072
|
+
address: str | None
|
|
1073
|
+
source: TrackingOptOutSource
|
|
1074
|
+
message_id: str | None
|
|
1075
|
+
created_at: datetime
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
@model
|
|
1079
|
+
class TrackingOptOutPage(Answer):
|
|
1080
|
+
"""One page of a server's not-tracked list, newest first.
|
|
1081
|
+
|
|
1082
|
+
Attributes:
|
|
1083
|
+
tracking_opt_outs: The page's entries.
|
|
1084
|
+
next: Pass as ``after`` for the next page; ``None`` on the last.
|
|
1085
|
+
"""
|
|
1086
|
+
|
|
1087
|
+
tracking_opt_outs: list[TrackingOptOut]
|
|
1088
|
+
next: str | None
|
|
1089
|
+
|
|
1090
|
+
|
|
1048
1091
|
@model
|
|
1049
1092
|
class Token(Answer):
|
|
1050
1093
|
"""A key of a server; its value is shown only when it is created.
|
|
@@ -1195,6 +1238,7 @@ WebhookEventName = Literal[
|
|
|
1195
1238
|
"unsubscribed",
|
|
1196
1239
|
"opened",
|
|
1197
1240
|
"clicked",
|
|
1241
|
+
"tracking_opted_out",
|
|
1198
1242
|
"cap_warning",
|
|
1199
1243
|
"cap_reached",
|
|
1200
1244
|
"inbound",
|
|
@@ -2169,6 +2213,21 @@ class ClickedEvent(MessageEventFields):
|
|
|
2169
2213
|
details: ClickedDetails
|
|
2170
2214
|
|
|
2171
2215
|
|
|
2216
|
+
@model
|
|
2217
|
+
class TrackingOptedOutEvent(MessageEventFields):
|
|
2218
|
+
"""The recipient asked not to be tracked.
|
|
2219
|
+
|
|
2220
|
+
They used the page that ``{{ tracking_opt_out_url }}`` links to, so the
|
|
2221
|
+
address is on the server's not-tracked list. A webhook that leaves out
|
|
2222
|
+
``events`` gets this event.
|
|
2223
|
+
|
|
2224
|
+
Attributes:
|
|
2225
|
+
event: ``tracking_opted_out``.
|
|
2226
|
+
"""
|
|
2227
|
+
|
|
2228
|
+
event: Literal["tracking_opted_out"]
|
|
2229
|
+
|
|
2230
|
+
|
|
2172
2231
|
@model
|
|
2173
2232
|
class CapEventFields(Answer):
|
|
2174
2233
|
"""What both usage events carry.
|
|
@@ -2404,6 +2463,7 @@ WebhookEvent = (
|
|
|
2404
2463
|
| UnsubscribedEvent
|
|
2405
2464
|
| OpenedEvent
|
|
2406
2465
|
| ClickedEvent
|
|
2466
|
+
| TrackingOptedOutEvent
|
|
2407
2467
|
| CapWarningEvent
|
|
2408
2468
|
| CapReachedEvent
|
|
2409
2469
|
| 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
|
|
File without changes
|