mailcycle
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.
Needs Python 3.10 or later.
pip install mailcycle # add [events] for the live event stream
Sign in
import os
from mailcycle import Mailcycle
# With the recovery phrase: everything the app can do, on any plan.
mc = Mailcycle.sign_in(os.environ["MAILCYCLE_PHRASE"])
# With an API key (Operator and up). Add the phrase to open mail and create
# addresses that receive it.
scripted = Mailcycle.with_api_key(
os.environ["MAILCYCLE_API_KEY"],
phrase=os.environ.get("MAILCYCLE_PHRASE"),
)
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.
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.
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.
Addresses that receive mail
address = mc.addresses.create(prefix="signup", label="Test run 42")
print(address.email_address) # signup-x7k2@…
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.
Reading mail
for message in mc.messages.list(address.id):
print(message.from_address, message.subject, message.text)
data = mc.messages.attachment(message, 0) # bytes, opened locally
mc.messages.mark_read(message.id)
messages.iterate(address.id) walks every page for you.
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.
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.
Waiting for mail
code = mc.messages.wait_for(address_id=address.id, subject="verification", timeout=60)
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.
Sending
mc.messages.send(
from_address=address.email_address,
to="someone@example.com",
subject="Hello",
text="Hi",
)
mc.messages.send(
from_address=address.email_address,
to=message.reply_to,
subject=f"Re: {message.subject}",
text="Thanks",
reply_to=message, # fills in the threading headers
)
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.
from pathlib import Path
from mailcycle import OutgoingAttachment
mc.messages.send(
from_address=address.email_address,
to="someone@example.com",
subject="Your invoice",
text="Attached.",
html='<img src="cid:logo"><p>Attached.</p>',
attachments=[
OutgoingAttachment("invoice.pdf", Path("invoice.pdf").read_bytes(), mime_type="application/pdf"),
OutgoingAttachment("logo.png", Path("logo.png").read_bytes(), mime_type="image/png", content_id="logo"),
],
)
A session can send on every plan; an API key on Scale and up. Limits are in the rate limits.
Usage and activity
usage = mc.usage()
print(usage["addresses"]["used"], "of", usage["addresses"]["limit"], "addresses")
for entry in mc.activity(50):
print(entry.created_at, entry.kind)
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.
Events
with mc.events.stream() as events:
for event in events:
print(event.type, event.payload)
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]'.
To check a webhook came from Mailcycle:
from mailcycle import verify_webhook_signature
verify_webhook_signature(secret, raw_body, request.headers.get("Mailcycle-Signature"))
Pass the body exactly as it arrived, before any parsing.
Errors
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.
How it is checked
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.
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.
Licence
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 and the Terms of Service.
Release files for mailcycle 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mailcycle-0.1.0.tar.gz | 56.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mailcycle-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 110.1 kB
Release files / mailcycle-0.1.0.tar.gz
| Download URL | mailcycle-0.1.0.tar.gz |
|---|---|
| Size | 56.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
025cb4da4ebe7f48d44782063d6f084284d32237abe740105d4a7f3800a978f5
|
|
BLAKE2b-256 checksum How to use checksums |
a7c7c6676850486be6b97cf1a6dc90428f54e22d35a0a7621c19df5c650482fc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Release files / mailcycle-0.1.0-py3-none-any.whl
| Download URL | mailcycle-0.1.0-py3-none-any.whl |
|---|---|
| Size | 53.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f641ad4f0f044bcc59591ce677512b2fc263d3cb7a09d1f13df6813744a56970
|
|
BLAKE2b-256 checksum How to use checksums |
02c535a29e68bf1c80f4fdfa45eb86e6c124771ebe871f0db7d3c8e9aee2d7b4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|