mailcycle 0.1.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.
- mailcycle-0.1.0/.gitignore +7 -0
- mailcycle-0.1.0/LICENSE +35 -0
- mailcycle-0.1.0/PKG-INFO +213 -0
- mailcycle-0.1.0/README.md +151 -0
- mailcycle-0.1.0/live/README.md +37 -0
- mailcycle-0.1.0/pyproject.toml +53 -0
- mailcycle-0.1.0/src/mailcycle/__init__.py +81 -0
- mailcycle-0.1.0/src/mailcycle/_encoding.py +57 -0
- mailcycle-0.1.0/src/mailcycle/client.py +700 -0
- mailcycle-0.1.0/src/mailcycle/crypto/__init__.py +30 -0
- mailcycle-0.1.0/src/mailcycle/crypto/asymmetric.py +111 -0
- mailcycle-0.1.0/src/mailcycle/crypto/authproof.py +60 -0
- mailcycle-0.1.0/src/mailcycle/crypto/cipher.py +133 -0
- mailcycle-0.1.0/src/mailcycle/crypto/identity.py +122 -0
- mailcycle-0.1.0/src/mailcycle/crypto/mnemonic.py +127 -0
- mailcycle-0.1.0/src/mailcycle/crypto/primitives.py +93 -0
- mailcycle-0.1.0/src/mailcycle/crypto/sealedbox.py +120 -0
- mailcycle-0.1.0/src/mailcycle/crypto/wordlist.py +275 -0
- mailcycle-0.1.0/src/mailcycle/errors.py +62 -0
- mailcycle-0.1.0/src/mailcycle/events.py +271 -0
- mailcycle-0.1.0/src/mailcycle/http.py +115 -0
- mailcycle-0.1.0/src/mailcycle/keys.py +92 -0
- mailcycle-0.1.0/src/mailcycle/mail.py +128 -0
- mailcycle-0.1.0/src/mailcycle/models.py +177 -0
- mailcycle-0.1.0/src/mailcycle/py.typed +0 -0
- mailcycle-0.1.0/src/mailcycle/trackers.py +260 -0
- mailcycle-0.1.0/src/mailcycle/webhooks.py +64 -0
- mailcycle-0.1.0/tests/conftest.py +10 -0
- mailcycle-0.1.0/tests/test_client.py +516 -0
- mailcycle-0.1.0/tests/test_cross_implementation.py +92 -0
- mailcycle-0.1.0/tests/test_events.py +264 -0
- mailcycle-0.1.0/tests/test_trackers.py +127 -0
- mailcycle-0.1.0/tests/test_vectors.py +305 -0
mailcycle-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Copyright (c) 2026 Northlab Studios Ltd. All rights reserved.
|
|
2
|
+
|
|
3
|
+
mailcycle for Python is proprietary software owned by Northlab Studios Ltd, a company
|
|
4
|
+
registered in England and Wales ("Northlab Studios"). It is not open source.
|
|
5
|
+
|
|
6
|
+
1. Licence. Northlab Studios grants you a limited, non-exclusive,
|
|
7
|
+
non-transferable, revocable licence to install and run this software, in
|
|
8
|
+
unmodified form, solely to call the Mailcycle API for a Mailcycle account you
|
|
9
|
+
are authorised to use, while you hold such an account, and in accordance with
|
|
10
|
+
the Mailcycle Terms of Service at https://mailcycle.email/legal/terms/.
|
|
11
|
+
|
|
12
|
+
2. Restrictions. Except as expressly permitted above or by applicable law that
|
|
13
|
+
cannot be excluded, you may not copy, modify, adapt, translate, merge,
|
|
14
|
+
publish, distribute, sublicense, sell, rent or lease the software or any
|
|
15
|
+
part of it, create derivative works from it, or decompile, disassemble or
|
|
16
|
+
reverse engineer it.
|
|
17
|
+
|
|
18
|
+
3. Ownership. The software is licensed, not sold. Northlab Studios and its
|
|
19
|
+
licensors keep all right, title and interest in it, including all
|
|
20
|
+
intellectual property rights. No rights are granted except as set out in
|
|
21
|
+
section 1.
|
|
22
|
+
|
|
23
|
+
4. Termination. This licence ends automatically if you breach it or when you
|
|
24
|
+
stop holding a Mailcycle account. On termination you must stop using the
|
|
25
|
+
software and delete every copy of it.
|
|
26
|
+
|
|
27
|
+
5. No warranty. To the extent permitted by law, the software is provided "as
|
|
28
|
+
is", without warranty of any kind, and Northlab Studios is not liable for
|
|
29
|
+
any claim, damages or other liability arising from it or its use, except as
|
|
30
|
+
set out in the Mailcycle Terms of Service.
|
|
31
|
+
|
|
32
|
+
6. Governing law. This licence is governed by the laws of England and Wales,
|
|
33
|
+
and the courts of England and Wales have exclusive jurisdiction.
|
|
34
|
+
|
|
35
|
+
Contact: hello@mailcycle.email
|
mailcycle-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mailcycle
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Mailcycle API. Create addresses that receive mail, read and decrypt it on your machine, send, and listen for events.
|
|
5
|
+
Project-URL: Homepage, https://mailcycle.email/
|
|
6
|
+
Project-URL: Documentation, https://docs.mailcycle.email/
|
|
7
|
+
Author-email: Northlab Studios Ltd <hello@mailcycle.email>
|
|
8
|
+
License: Copyright (c) 2026 Northlab Studios Ltd. All rights reserved.
|
|
9
|
+
|
|
10
|
+
mailcycle for Python is proprietary software owned by Northlab Studios Ltd, a company
|
|
11
|
+
registered in England and Wales ("Northlab Studios"). It is not open source.
|
|
12
|
+
|
|
13
|
+
1. Licence. Northlab Studios grants you a limited, non-exclusive,
|
|
14
|
+
non-transferable, revocable licence to install and run this software, in
|
|
15
|
+
unmodified form, solely to call the Mailcycle API for a Mailcycle account you
|
|
16
|
+
are authorised to use, while you hold such an account, and in accordance with
|
|
17
|
+
the Mailcycle Terms of Service at https://mailcycle.email/legal/terms/.
|
|
18
|
+
|
|
19
|
+
2. Restrictions. Except as expressly permitted above or by applicable law that
|
|
20
|
+
cannot be excluded, you may not copy, modify, adapt, translate, merge,
|
|
21
|
+
publish, distribute, sublicense, sell, rent or lease the software or any
|
|
22
|
+
part of it, create derivative works from it, or decompile, disassemble or
|
|
23
|
+
reverse engineer it.
|
|
24
|
+
|
|
25
|
+
3. Ownership. The software is licensed, not sold. Northlab Studios and its
|
|
26
|
+
licensors keep all right, title and interest in it, including all
|
|
27
|
+
intellectual property rights. No rights are granted except as set out in
|
|
28
|
+
section 1.
|
|
29
|
+
|
|
30
|
+
4. Termination. This licence ends automatically if you breach it or when you
|
|
31
|
+
stop holding a Mailcycle account. On termination you must stop using the
|
|
32
|
+
software and delete every copy of it.
|
|
33
|
+
|
|
34
|
+
5. No warranty. To the extent permitted by law, the software is provided "as
|
|
35
|
+
is", without warranty of any kind, and Northlab Studios is not liable for
|
|
36
|
+
any claim, damages or other liability arising from it or its use, except as
|
|
37
|
+
set out in the Mailcycle Terms of Service.
|
|
38
|
+
|
|
39
|
+
6. Governing law. This licence is governed by the laws of England and Wales,
|
|
40
|
+
and the courts of England and Wales have exclusive jurisdiction.
|
|
41
|
+
|
|
42
|
+
Contact: hello@mailcycle.email
|
|
43
|
+
License-File: LICENSE
|
|
44
|
+
Keywords: email,encryption,end-to-end,mailcycle,privacy
|
|
45
|
+
Classifier: Development Status :: 4 - Beta
|
|
46
|
+
Classifier: Intended Audience :: Developers
|
|
47
|
+
Classifier: License :: Other/Proprietary License
|
|
48
|
+
Classifier: Programming Language :: Python :: 3
|
|
49
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
50
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
51
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
52
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
53
|
+
Classifier: Topic :: Communications :: Email
|
|
54
|
+
Classifier: Topic :: Security :: Cryptography
|
|
55
|
+
Classifier: Typing :: Typed
|
|
56
|
+
Requires-Python: >=3.10
|
|
57
|
+
Requires-Dist: cryptography>=42
|
|
58
|
+
Requires-Dist: httpx>=0.27
|
|
59
|
+
Provides-Extra: events
|
|
60
|
+
Requires-Dist: websockets>=13; extra == 'events'
|
|
61
|
+
Description-Content-Type: text/markdown
|
|
62
|
+
|
|
63
|
+
# mailcycle
|
|
64
|
+
|
|
65
|
+
A Python client for the Mailcycle API that does the encryption on your machine. Create addresses that receive mail, read what arrives, send, and listen for events. Mailcycle stores mail sealed to keys derived from your recovery phrase, and this package derives them locally; the phrase never leaves your machine.
|
|
66
|
+
|
|
67
|
+
Needs Python 3.10 or later.
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
pip install mailcycle # add [events] for the live event stream
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Sign in
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import os
|
|
77
|
+
from mailcycle import Mailcycle
|
|
78
|
+
|
|
79
|
+
# With the recovery phrase: everything the app can do, on any plan.
|
|
80
|
+
mc = Mailcycle.sign_in(os.environ["MAILCYCLE_PHRASE"])
|
|
81
|
+
|
|
82
|
+
# With an API key (Operator and up). Add the phrase to open mail and create
|
|
83
|
+
# addresses that receive it.
|
|
84
|
+
scripted = Mailcycle.with_api_key(
|
|
85
|
+
os.environ["MAILCYCLE_API_KEY"],
|
|
86
|
+
phrase=os.environ.get("MAILCYCLE_PHRASE"),
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Signing in uses the same handshake as the app: the server sends a challenge and the client proves it holds the key, so nothing secret crosses the wire. With an API key and a phrase, the phrase is checked against the key's account before anything is opened.
|
|
91
|
+
|
|
92
|
+
Deriving the keys runs the phrase through 120,000 PBKDF2 iterations, which takes a few tens of milliseconds here because Python's `hashlib` does it natively. The app pays far more for the same bytes, on its own JavaScript. Each `sign_in` opens a new 30-day session: a script that runs often can keep `mc.session_token` somewhere private and pick it up with `Mailcycle.resume(token, phrase)`, and `mc.sign_out()` ends a session it no longer needs.
|
|
93
|
+
|
|
94
|
+
`Mailcycle.create_account()` makes a new account and returns the client and its phrase. Keep the phrase. Nothing can recover the account without it, and nobody at Mailcycle can help.
|
|
95
|
+
|
|
96
|
+
## Addresses that receive mail
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
address = mc.addresses.create(prefix="signup", label="Test run 42")
|
|
100
|
+
print(address.email_address) # signup-x7k2@…
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The address's public key is derived from the phrase and sent with it, so the mail server can seal what arrives. The label is sealed on your machine, and the server stores it as ciphertext. `addresses.list()`, `update()` and `delete()` do the rest.
|
|
104
|
+
|
|
105
|
+
## Reading mail
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
for message in mc.messages.list(address.id):
|
|
109
|
+
print(message.from_address, message.subject, message.text)
|
|
110
|
+
|
|
111
|
+
data = mc.messages.attachment(message, 0) # bytes, opened locally
|
|
112
|
+
mc.messages.mark_read(message.id)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`messages.iterate(address.id)` walks every page for you.
|
|
116
|
+
|
|
117
|
+
A message that will not open, because there is no phrase or it is the wrong one, comes back with `opened=False` and empty content rather than raising, so one unreadable message does not hide the rest of a page.
|
|
118
|
+
|
|
119
|
+
`message.html` is the HTML as the sender wrote it, remote images and all. `message.safe_html` has everything remote removed, the same way the app does it, and is the one to render; `message.blocked` says what was taken out and who it would have reported to.
|
|
120
|
+
|
|
121
|
+
## Waiting for mail
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
code = mc.messages.wait_for(address_id=address.id, subject="verification", timeout=60)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
This listens on the event stream and returns the next matching message, opened. Where the stream is not available (an API key below Scale, or no `websockets` installed) it checks the address every five seconds instead, so `address_id` is needed then. If nothing arrives in time it raises an `APIError` with `code="timeout"`. Mail received from `since` on counts, even if it landed before the stream was up; it defaults to now, so to wait for a reply to something you are about to send, take the time before sending.
|
|
128
|
+
|
|
129
|
+
## Sending
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
mc.messages.send(
|
|
133
|
+
from_address=address.email_address,
|
|
134
|
+
to="someone@example.com",
|
|
135
|
+
subject="Hello",
|
|
136
|
+
text="Hi",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
mc.messages.send(
|
|
140
|
+
from_address=address.email_address,
|
|
141
|
+
to=message.reply_to,
|
|
142
|
+
subject=f"Re: {message.subject}",
|
|
143
|
+
text="Thanks",
|
|
144
|
+
reply_to=message, # fills in the threading headers
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Attachments take raw bytes and the client encodes them. A message can carry up to ten files, 3.5 MB between them. Give a file a `content_id` to show it inline, where the HTML says `cid:` and that id. Program and script files, like `.exe` and `.js`, are refused.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from pathlib import Path
|
|
152
|
+
from mailcycle import OutgoingAttachment
|
|
153
|
+
|
|
154
|
+
mc.messages.send(
|
|
155
|
+
from_address=address.email_address,
|
|
156
|
+
to="someone@example.com",
|
|
157
|
+
subject="Your invoice",
|
|
158
|
+
text="Attached.",
|
|
159
|
+
html='<img src="cid:logo"><p>Attached.</p>',
|
|
160
|
+
attachments=[
|
|
161
|
+
OutgoingAttachment("invoice.pdf", Path("invoice.pdf").read_bytes(), mime_type="application/pdf"),
|
|
162
|
+
OutgoingAttachment("logo.png", Path("logo.png").read_bytes(), mime_type="image/png", content_id="logo"),
|
|
163
|
+
],
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
A session can send on every plan; an API key on Scale and up. Limits are in the [rate limits](https://docs.mailcycle.email/reference/rate-limits/).
|
|
168
|
+
|
|
169
|
+
## Usage and activity
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
usage = mc.usage()
|
|
173
|
+
print(usage["addresses"]["used"], "of", usage["addresses"]["limit"], "addresses")
|
|
174
|
+
|
|
175
|
+
for entry in mc.activity(50):
|
|
176
|
+
print(entry.created_at, entry.kind)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`usage()` reads back the counters the API enforces its limits on, so it says what a request would be refused on before it is refused. `activity()` is the account's own event log: ids and times, never content.
|
|
180
|
+
|
|
181
|
+
## Events
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
with mc.events.stream() as events:
|
|
185
|
+
for event in events:
|
|
186
|
+
print(event.type, event.payload)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The same events as webhooks, the moment they happen, and they carry ids rather than content. The stream reconnects after a drop, and events that happen while it is down are not replayed, so `on_reconnect=` is where to refetch whatever you are showing. Needs `pip install 'mailcycle[events]'`.
|
|
190
|
+
|
|
191
|
+
To check a webhook came from Mailcycle:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from mailcycle import verify_webhook_signature
|
|
195
|
+
|
|
196
|
+
verify_webhook_signature(secret, raw_body, request.headers.get("Mailcycle-Signature"))
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Pass the body exactly as it arrived, before any parsing.
|
|
200
|
+
|
|
201
|
+
## Errors
|
|
202
|
+
|
|
203
|
+
Everything raises a subclass of `MailcycleError`. An API failure is an `APIError` carrying the HTTP `status` and the API's own `code`, such as `plan_required` or `session_required`, which is what to match on rather than the message. See [errors](https://docs.mailcycle.email/reference/errors/).
|
|
204
|
+
|
|
205
|
+
## How it is checked
|
|
206
|
+
|
|
207
|
+
The encryption here is a second implementation of Mailcycle's scheme, and a second implementation is a liability unless it is proven rather than trusted. `tests/test_vectors.py` runs it against `packages/crypto-vectors/vectors.json`, the frozen vectors the app and the mail server are checked against: the same keys from the same phrase, the same boxes opened, the same proof and the same signature. The negative cases are checked too, because each is something a hostile server can try: the wrong additional data, another address's key, a box relabelled as the other version, an attachment served under the wrong object key.
|
|
208
|
+
|
|
209
|
+
Two further suites cover what the vectors cannot. `test_cross_implementation.py` has this package and the app seal for each other, since the additional data a record is bound to is the client's choice rather than the format's. `test_trackers.py` runs both tracker strippers over the same markup and requires identical output, because a tracker one reader blocks and another fetches is a privacy promise broken rather than a test going red.
|
|
210
|
+
|
|
211
|
+
## Licence
|
|
212
|
+
|
|
213
|
+
Proprietary. Copyright (c) 2026 Northlab Studios Ltd. All rights reserved. You may run this package, unmodified, to call the Mailcycle API for an account you're authorised to use. You may not copy, modify, redistribute or reverse engineer it. See [LICENSE](LICENSE) and the [Terms of Service](https://mailcycle.email/legal/terms/).
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# mailcycle
|
|
2
|
+
|
|
3
|
+
A Python client for the Mailcycle API that does the encryption on your machine. Create addresses that receive mail, read what arrives, send, and listen for events. Mailcycle stores mail sealed to keys derived from your recovery phrase, and this package derives them locally; the phrase never leaves your machine.
|
|
4
|
+
|
|
5
|
+
Needs Python 3.10 or later.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install mailcycle # add [events] for the live event stream
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Sign in
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
from mailcycle import Mailcycle
|
|
16
|
+
|
|
17
|
+
# With the recovery phrase: everything the app can do, on any plan.
|
|
18
|
+
mc = Mailcycle.sign_in(os.environ["MAILCYCLE_PHRASE"])
|
|
19
|
+
|
|
20
|
+
# With an API key (Operator and up). Add the phrase to open mail and create
|
|
21
|
+
# addresses that receive it.
|
|
22
|
+
scripted = Mailcycle.with_api_key(
|
|
23
|
+
os.environ["MAILCYCLE_API_KEY"],
|
|
24
|
+
phrase=os.environ.get("MAILCYCLE_PHRASE"),
|
|
25
|
+
)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Signing in uses the same handshake as the app: the server sends a challenge and the client proves it holds the key, so nothing secret crosses the wire. With an API key and a phrase, the phrase is checked against the key's account before anything is opened.
|
|
29
|
+
|
|
30
|
+
Deriving the keys runs the phrase through 120,000 PBKDF2 iterations, which takes a few tens of milliseconds here because Python's `hashlib` does it natively. The app pays far more for the same bytes, on its own JavaScript. Each `sign_in` opens a new 30-day session: a script that runs often can keep `mc.session_token` somewhere private and pick it up with `Mailcycle.resume(token, phrase)`, and `mc.sign_out()` ends a session it no longer needs.
|
|
31
|
+
|
|
32
|
+
`Mailcycle.create_account()` makes a new account and returns the client and its phrase. Keep the phrase. Nothing can recover the account without it, and nobody at Mailcycle can help.
|
|
33
|
+
|
|
34
|
+
## Addresses that receive mail
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
address = mc.addresses.create(prefix="signup", label="Test run 42")
|
|
38
|
+
print(address.email_address) # signup-x7k2@…
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The address's public key is derived from the phrase and sent with it, so the mail server can seal what arrives. The label is sealed on your machine, and the server stores it as ciphertext. `addresses.list()`, `update()` and `delete()` do the rest.
|
|
42
|
+
|
|
43
|
+
## Reading mail
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
for message in mc.messages.list(address.id):
|
|
47
|
+
print(message.from_address, message.subject, message.text)
|
|
48
|
+
|
|
49
|
+
data = mc.messages.attachment(message, 0) # bytes, opened locally
|
|
50
|
+
mc.messages.mark_read(message.id)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`messages.iterate(address.id)` walks every page for you.
|
|
54
|
+
|
|
55
|
+
A message that will not open, because there is no phrase or it is the wrong one, comes back with `opened=False` and empty content rather than raising, so one unreadable message does not hide the rest of a page.
|
|
56
|
+
|
|
57
|
+
`message.html` is the HTML as the sender wrote it, remote images and all. `message.safe_html` has everything remote removed, the same way the app does it, and is the one to render; `message.blocked` says what was taken out and who it would have reported to.
|
|
58
|
+
|
|
59
|
+
## Waiting for mail
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
code = mc.messages.wait_for(address_id=address.id, subject="verification", timeout=60)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
This listens on the event stream and returns the next matching message, opened. Where the stream is not available (an API key below Scale, or no `websockets` installed) it checks the address every five seconds instead, so `address_id` is needed then. If nothing arrives in time it raises an `APIError` with `code="timeout"`. Mail received from `since` on counts, even if it landed before the stream was up; it defaults to now, so to wait for a reply to something you are about to send, take the time before sending.
|
|
66
|
+
|
|
67
|
+
## Sending
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
mc.messages.send(
|
|
71
|
+
from_address=address.email_address,
|
|
72
|
+
to="someone@example.com",
|
|
73
|
+
subject="Hello",
|
|
74
|
+
text="Hi",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
mc.messages.send(
|
|
78
|
+
from_address=address.email_address,
|
|
79
|
+
to=message.reply_to,
|
|
80
|
+
subject=f"Re: {message.subject}",
|
|
81
|
+
text="Thanks",
|
|
82
|
+
reply_to=message, # fills in the threading headers
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Attachments take raw bytes and the client encodes them. A message can carry up to ten files, 3.5 MB between them. Give a file a `content_id` to show it inline, where the HTML says `cid:` and that id. Program and script files, like `.exe` and `.js`, are refused.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from pathlib import Path
|
|
90
|
+
from mailcycle import OutgoingAttachment
|
|
91
|
+
|
|
92
|
+
mc.messages.send(
|
|
93
|
+
from_address=address.email_address,
|
|
94
|
+
to="someone@example.com",
|
|
95
|
+
subject="Your invoice",
|
|
96
|
+
text="Attached.",
|
|
97
|
+
html='<img src="cid:logo"><p>Attached.</p>',
|
|
98
|
+
attachments=[
|
|
99
|
+
OutgoingAttachment("invoice.pdf", Path("invoice.pdf").read_bytes(), mime_type="application/pdf"),
|
|
100
|
+
OutgoingAttachment("logo.png", Path("logo.png").read_bytes(), mime_type="image/png", content_id="logo"),
|
|
101
|
+
],
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
A session can send on every plan; an API key on Scale and up. Limits are in the [rate limits](https://docs.mailcycle.email/reference/rate-limits/).
|
|
106
|
+
|
|
107
|
+
## Usage and activity
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
usage = mc.usage()
|
|
111
|
+
print(usage["addresses"]["used"], "of", usage["addresses"]["limit"], "addresses")
|
|
112
|
+
|
|
113
|
+
for entry in mc.activity(50):
|
|
114
|
+
print(entry.created_at, entry.kind)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`usage()` reads back the counters the API enforces its limits on, so it says what a request would be refused on before it is refused. `activity()` is the account's own event log: ids and times, never content.
|
|
118
|
+
|
|
119
|
+
## Events
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
with mc.events.stream() as events:
|
|
123
|
+
for event in events:
|
|
124
|
+
print(event.type, event.payload)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The same events as webhooks, the moment they happen, and they carry ids rather than content. The stream reconnects after a drop, and events that happen while it is down are not replayed, so `on_reconnect=` is where to refetch whatever you are showing. Needs `pip install 'mailcycle[events]'`.
|
|
128
|
+
|
|
129
|
+
To check a webhook came from Mailcycle:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from mailcycle import verify_webhook_signature
|
|
133
|
+
|
|
134
|
+
verify_webhook_signature(secret, raw_body, request.headers.get("Mailcycle-Signature"))
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Pass the body exactly as it arrived, before any parsing.
|
|
138
|
+
|
|
139
|
+
## Errors
|
|
140
|
+
|
|
141
|
+
Everything raises a subclass of `MailcycleError`. An API failure is an `APIError` carrying the HTTP `status` and the API's own `code`, such as `plan_required` or `session_required`, which is what to match on rather than the message. See [errors](https://docs.mailcycle.email/reference/errors/).
|
|
142
|
+
|
|
143
|
+
## How it is checked
|
|
144
|
+
|
|
145
|
+
The encryption here is a second implementation of Mailcycle's scheme, and a second implementation is a liability unless it is proven rather than trusted. `tests/test_vectors.py` runs it against `packages/crypto-vectors/vectors.json`, the frozen vectors the app and the mail server are checked against: the same keys from the same phrase, the same boxes opened, the same proof and the same signature. The negative cases are checked too, because each is something a hostile server can try: the wrong additional data, another address's key, a box relabelled as the other version, an attachment served under the wrong object key.
|
|
146
|
+
|
|
147
|
+
Two further suites cover what the vectors cannot. `test_cross_implementation.py` has this package and the app seal for each other, since the additional data a record is bound to is the client's choice rather than the format's. `test_trackers.py` runs both tracker strippers over the same markup and requires identical output, because a tracker one reader blocks and another fetches is a privacy promise broken rather than a test going red.
|
|
148
|
+
|
|
149
|
+
## Licence
|
|
150
|
+
|
|
151
|
+
Proprietary. Copyright (c) 2026 Northlab Studios Ltd. All rights reserved. You may run this package, unmodified, to call the Mailcycle API for an account you're authorised to use. You may not copy, modify, redistribute or reverse engineer it. See [LICENSE](LICENSE) and the [Terms of Service](https://mailcycle.email/legal/terms/).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Live check
|
|
2
|
+
|
|
3
|
+
`live_check.py` runs this SDK against the real API, the way the TypeScript SDK was checked before it shipped (REMAINING.md, section 11). Run it before every release to PyPI, and again whenever the format or the API moves.
|
|
4
|
+
|
|
5
|
+
It is not part of the package: the wheel ships `src/mailcycle` only, and the sdist lists what it includes, which leaves `live/` out.
|
|
6
|
+
|
|
7
|
+
## Running it
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
cd packages/sdk-python && .venv/bin/pip install -e '.[events]' && .venv/bin/python live/live_check.py
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
It creates a throwaway account and prints its id. Put that account on the Scale plan in the admin console; the script checks every 10 seconds for up to 15 minutes and carries on once it sees the change. Set `MAILCYCLE_SKIP_WAIT=1` if the account is already on Scale.
|
|
14
|
+
|
|
15
|
+
`MAILCYCLE_API_URL` points it somewhere else. It defaults to `https://api.mailcycle.email`.
|
|
16
|
+
|
|
17
|
+
Each check prints one `PASS` or `FAIL` line, and the script exits non-zero if any fail. The account is deleted at the end, and also when a check fails or the run is interrupted. If that cleanup fails too, the last line says so with the account id, to delete by hand. It prints ids only, never the phrase, a token or message content.
|
|
18
|
+
|
|
19
|
+
## What it checks
|
|
20
|
+
|
|
21
|
+
1. The API it is pointed at.
|
|
22
|
+
2. Creating an account, then waiting for Scale.
|
|
23
|
+
3. Two addresses on a shared domain (`letterhold.com` when offered), both receiving mail, labels opening.
|
|
24
|
+
4. The session's event stream.
|
|
25
|
+
5. Mail from A to B through Email Sending and Email Routing, with a text attachment: `wait_for`, subject, text, sender, and the attachment opening to the same bytes.
|
|
26
|
+
6. The sealed Sent copy on A, attachment included.
|
|
27
|
+
7. Mark read and unread, star and unstar, move to junk and trash and restore, and a Sent copy refused a move to the inbox.
|
|
28
|
+
8. A second client signing in with the phrase alone reading the same message.
|
|
29
|
+
9. An API key made with the session: it lists addresses, reads labels and the message with the phrase, is refused key listing, webhook creation, pairing and account deletion with `session_required`, and holds the event stream.
|
|
30
|
+
10. Deleting both addresses, and the stream having carried `inbox.created`, `message.sent`, `message.received` and `inbox.deleted`, with ids only.
|
|
31
|
+
11. Deleting the account, after which signing in, the old session and the key are all refused.
|
|
32
|
+
|
|
33
|
+
## What it does not check
|
|
34
|
+
|
|
35
|
+
An API key below Scale being closed with 4003 on the event stream. That needs the plan changed down mid-run, which the script does not do. Check it by hand: with a key open on the stream, move the account to Operator in the console and watch the stream end.
|
|
36
|
+
|
|
37
|
+
Some calls here have no SDK method yet (making an API key, the refusals in step 9, deleting the account), so the script makes them through the client's own HTTP layer.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mailcycle"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the Mailcycle API. Create addresses that receive mail, read and decrypt it on your machine, send, and listen for events."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "Northlab Studios Ltd", email = "hello@mailcycle.email" }]
|
|
13
|
+
keywords = ["email", "privacy", "encryption", "mailcycle", "end-to-end"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: Other/Proprietary License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Communications :: Email",
|
|
24
|
+
"Topic :: Security :: Cryptography",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"cryptography>=42",
|
|
29
|
+
"httpx>=0.27",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
events = ["websockets>=13"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://mailcycle.email/"
|
|
37
|
+
Documentation = "https://docs.mailcycle.email/"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/mailcycle"]
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.sdist]
|
|
43
|
+
include = ["src/mailcycle", "tests", "README.md", "LICENSE"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 130
|
|
50
|
+
target-version = "py310"
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = ["F", "E", "W", "I", "UP", "B"]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Mailcycle for Python.
|
|
2
|
+
|
|
3
|
+
Privacy-first email you can automate. Create addresses that receive mail, read
|
|
4
|
+
and decrypt it on your own machine, send, and listen for events.
|
|
5
|
+
|
|
6
|
+
from mailcycle import Mailcycle
|
|
7
|
+
|
|
8
|
+
client = Mailcycle.sign_in("your twelve word recovery phrase ...")
|
|
9
|
+
address = client.addresses.create(label="Sign-in codes")
|
|
10
|
+
message = client.messages.wait_for(address_id=address.id)
|
|
11
|
+
print(message.subject, message.text)
|
|
12
|
+
|
|
13
|
+
The recovery phrase never leaves this machine. Mail is sealed to a key derived
|
|
14
|
+
from it, so the API serves ciphertext it cannot read, and this package opens it
|
|
15
|
+
locally. Lose the phrase and the mail is gone; nobody at Mailcycle can help.
|
|
16
|
+
|
|
17
|
+
Documentation: https://docs.mailcycle.email/
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from .client import Mailcycle
|
|
23
|
+
from .crypto.mnemonic import generate_phrase, validate_phrase
|
|
24
|
+
from .errors import (
|
|
25
|
+
APIError,
|
|
26
|
+
AuthProofError,
|
|
27
|
+
Base64URLError,
|
|
28
|
+
DecryptionError,
|
|
29
|
+
MailcycleError,
|
|
30
|
+
PhraseError,
|
|
31
|
+
)
|
|
32
|
+
from .events import EventStream
|
|
33
|
+
from .keys import Keys
|
|
34
|
+
from .models import (
|
|
35
|
+
Account,
|
|
36
|
+
AccountEvent,
|
|
37
|
+
ActivityEntry,
|
|
38
|
+
Address,
|
|
39
|
+
Attachment,
|
|
40
|
+
Device,
|
|
41
|
+
Domain,
|
|
42
|
+
Message,
|
|
43
|
+
MessagePage,
|
|
44
|
+
OutgoingAttachment,
|
|
45
|
+
Plan,
|
|
46
|
+
)
|
|
47
|
+
from .trackers import BlockedTracker, strip_remote_content, summarize_blocked
|
|
48
|
+
from .webhooks import verify_webhook_signature, webhook_signature_header
|
|
49
|
+
|
|
50
|
+
__version__ = "0.1.0"
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"Mailcycle",
|
|
54
|
+
"Keys",
|
|
55
|
+
"EventStream",
|
|
56
|
+
"Account",
|
|
57
|
+
"AccountEvent",
|
|
58
|
+
"ActivityEntry",
|
|
59
|
+
"Address",
|
|
60
|
+
"Attachment",
|
|
61
|
+
"BlockedTracker",
|
|
62
|
+
"Device",
|
|
63
|
+
"Domain",
|
|
64
|
+
"Message",
|
|
65
|
+
"MessagePage",
|
|
66
|
+
"OutgoingAttachment",
|
|
67
|
+
"Plan",
|
|
68
|
+
"MailcycleError",
|
|
69
|
+
"APIError",
|
|
70
|
+
"AuthProofError",
|
|
71
|
+
"Base64URLError",
|
|
72
|
+
"DecryptionError",
|
|
73
|
+
"PhraseError",
|
|
74
|
+
"generate_phrase",
|
|
75
|
+
"validate_phrase",
|
|
76
|
+
"strip_remote_content",
|
|
77
|
+
"summarize_blocked",
|
|
78
|
+
"verify_webhook_signature",
|
|
79
|
+
"webhook_signature_header",
|
|
80
|
+
"__version__",
|
|
81
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""base64url without padding, decoded strictly.
|
|
2
|
+
|
|
3
|
+
The permissive decoders accept several spellings of the same bytes, which
|
|
4
|
+
matters here: these strings are compared, stored and fed to a MAC, and a
|
|
5
|
+
server picks how they are spelled. So unknown characters are an error, padding
|
|
6
|
+
is an error, a length that cannot come from whole bytes is an error, and the
|
|
7
|
+
unused low bits of the final character must be zero. This is
|
|
8
|
+
`fromBase64Url` in the app's `src/crypto/bytes.ts`, rule for rule.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
|
|
15
|
+
from .errors import Base64URLError
|
|
16
|
+
|
|
17
|
+
_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
|
18
|
+
_INDEX = {char: value for value, char in enumerate(_ALPHABET)}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def to_b64u(data: bytes) -> str:
|
|
22
|
+
"""Bytes to base64url, unpadded."""
|
|
23
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def from_b64u(value: str) -> bytes:
|
|
27
|
+
"""base64url to bytes, refusing anything non-canonical."""
|
|
28
|
+
if not isinstance(value, str):
|
|
29
|
+
raise Base64URLError("Expected base64url text.")
|
|
30
|
+
# One leftover character cannot encode any whole byte.
|
|
31
|
+
if len(value) % 4 == 1:
|
|
32
|
+
raise Base64URLError("Truncated base64url.")
|
|
33
|
+
|
|
34
|
+
out = bytearray()
|
|
35
|
+
buffer = 0
|
|
36
|
+
bits = 0
|
|
37
|
+
for position, char in enumerate(value):
|
|
38
|
+
index = _INDEX.get(char)
|
|
39
|
+
if index is None:
|
|
40
|
+
raise Base64URLError(f"Unexpected character at {position}.")
|
|
41
|
+
buffer = (buffer << 6) | index
|
|
42
|
+
bits += 6
|
|
43
|
+
if bits >= 8:
|
|
44
|
+
bits -= 8
|
|
45
|
+
out.append((buffer >> bits) & 0xFF)
|
|
46
|
+
buffer &= (1 << bits) - 1
|
|
47
|
+
|
|
48
|
+
# Whatever is left is padding bits, and in a canonical encoding they are
|
|
49
|
+
# zero. Non-zero means two strings would decode to the same bytes.
|
|
50
|
+
if bits and buffer:
|
|
51
|
+
raise Base64URLError("Non-canonical trailing bits.")
|
|
52
|
+
return bytes(out)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def be64(value: int) -> bytes:
|
|
56
|
+
"""Eight bytes, big-endian. The additional data's length, in a MAC input."""
|
|
57
|
+
return value.to_bytes(8, "big")
|