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