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.
Files changed (33) hide show
  1. mailcycle-0.1.0/.gitignore +7 -0
  2. mailcycle-0.1.0/LICENSE +35 -0
  3. mailcycle-0.1.0/PKG-INFO +213 -0
  4. mailcycle-0.1.0/README.md +151 -0
  5. mailcycle-0.1.0/live/README.md +37 -0
  6. mailcycle-0.1.0/pyproject.toml +53 -0
  7. mailcycle-0.1.0/src/mailcycle/__init__.py +81 -0
  8. mailcycle-0.1.0/src/mailcycle/_encoding.py +57 -0
  9. mailcycle-0.1.0/src/mailcycle/client.py +700 -0
  10. mailcycle-0.1.0/src/mailcycle/crypto/__init__.py +30 -0
  11. mailcycle-0.1.0/src/mailcycle/crypto/asymmetric.py +111 -0
  12. mailcycle-0.1.0/src/mailcycle/crypto/authproof.py +60 -0
  13. mailcycle-0.1.0/src/mailcycle/crypto/cipher.py +133 -0
  14. mailcycle-0.1.0/src/mailcycle/crypto/identity.py +122 -0
  15. mailcycle-0.1.0/src/mailcycle/crypto/mnemonic.py +127 -0
  16. mailcycle-0.1.0/src/mailcycle/crypto/primitives.py +93 -0
  17. mailcycle-0.1.0/src/mailcycle/crypto/sealedbox.py +120 -0
  18. mailcycle-0.1.0/src/mailcycle/crypto/wordlist.py +275 -0
  19. mailcycle-0.1.0/src/mailcycle/errors.py +62 -0
  20. mailcycle-0.1.0/src/mailcycle/events.py +271 -0
  21. mailcycle-0.1.0/src/mailcycle/http.py +115 -0
  22. mailcycle-0.1.0/src/mailcycle/keys.py +92 -0
  23. mailcycle-0.1.0/src/mailcycle/mail.py +128 -0
  24. mailcycle-0.1.0/src/mailcycle/models.py +177 -0
  25. mailcycle-0.1.0/src/mailcycle/py.typed +0 -0
  26. mailcycle-0.1.0/src/mailcycle/trackers.py +260 -0
  27. mailcycle-0.1.0/src/mailcycle/webhooks.py +64 -0
  28. mailcycle-0.1.0/tests/conftest.py +10 -0
  29. mailcycle-0.1.0/tests/test_client.py +516 -0
  30. mailcycle-0.1.0/tests/test_cross_implementation.py +92 -0
  31. mailcycle-0.1.0/tests/test_events.py +264 -0
  32. mailcycle-0.1.0/tests/test_trackers.py +127 -0
  33. mailcycle-0.1.0/tests/test_vectors.py +305 -0
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ dist/
3
+ *.egg-info/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
@@ -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
@@ -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")