Skip to main content

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

Upgrading to 0.2.0

messages.send() now returns a SendResult rather than a string. result.message_id is the Message-ID header the mail went out with, and result.sent_copy_id is the Sent copy's id, which messages.get() takes. Code that used the old return value as a string needs result.message_id.

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 page's next_cursor is opaque: pass it back as cursor and do not parse it.

Each attachment has a content_id when the HTML shows it inline, and stored, which is False for a part that had no body. Fetching one of those is a 404.

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 (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

result = mc.messages.send(
    from_address=address.email_address,
    to="someone@example.com",
    subject="Hello",
    text="Hi",
)
copy = mc.messages.get(result.sent_copy_id)   # the Sent copy

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 from Personal up; an API key on Scale and up. Free receives only. Limits are in the rate limits.

result.sent_copy_id is None when the address keeps no Sent copy.

API keys

created = mc.api_keys.create("CI runner")
print(created.token)          # shown once; store it now

for key in mc.api_keys.list():
    print(key.id, key.name, key.last_used_at)

mc.api_keys.revoke(created.api_key.id)

The name is sealed on your machine. A name that will not open comes back as None. Listing, making and revoking keys needs a session from sign_in, so a key cannot make more of itself.

Webhooks

created = mc.webhooks.create("https://example.com/hooks/mailcycle", ["message.received"], "Orders")
secret = created.secret       # shown once; store it now

mc.webhooks.update(created.webhook.id, enabled=False)
mc.webhooks.rotate_secret(created.webhook.id)   # a new secret, also shown once
mc.webhooks.test(created.webhook.id)

for delivery in mc.webhooks.deliveries(created.webhook.id):
    print(delivery.event_type, delivery.status, delivery.response_status)

Operator and up. webhooks.list() returns the webhooks and event_types, every type one can ask for; pass ["*"] for all of them. update() changes only what you pass, and enabled switches one off or back on. redeliver(id, delivery_id) sends an event again with the same event id. The name is sealed like an API key's; the URL cannot be, because the server calls it. Adding, changing, rotating and deleting need a session; an API key can list, test, read deliveries and redeliver.

Pairing devices

device = mc.devices.pair("1234 5678", name="Front desk")   # the 8 digits
device = mc.devices.pair(qr_text)                            # or the QR code's text

mc.devices.assign(device.id, address.id)     # the device gets the address and its keys
mc.devices.unassign(device.id, address.id)   # and loses it again

This is "Add to my account" in the app, done by a script. It needs the phrase and a session from sign_in. An API key cannot pair a device or change what it holds.

assign and unassign hand the device its keys again, sealed on your machine for every address it holds. hand_over_keys(device_id) does that on its own, for an address assigned in the app.

The QR code carries a secret the device made. It never passes through Mailcycle. The 8 digits carry no secret, so the account makes one and seals it to a key the device published. The server relays that key, so it could put its own in its place at pairing and read the keys that follow. It cannot do that on the QR path. Scan when you can. A device that published no key can only be paired from its QR code or in the app.

Sessions

for session in mc.sessions.list():
    print(session.id, session.created_at, session.current)

mc.sessions.revoke_others()   # everywhere but here

sessions.revoke(id) ends one. Session only. sign_out() ends this client's session, and does nothing for a client built with an API key.

Deleting the account

mc.delete_account()

This erases the account and everything in it at once, with no undo. Its addresses are never issued again. Session only. It confirms with the phrase by answering a fresh challenge.

mc.subscription() returns the plan, its status and renewal date, and the account balance. mc.plans() lists every plan.

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)

page = mc.activity_page(limit=100)
older = mc.activity_page(limit=100, cursor=page.next_cursor)

for entry in mc.iter_activity():  # every event the server still holds
    print(entry.id)

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 are kept for 90 days; activity_page() pages back through them and iter_activity() walks them all.

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.4.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mailcycle 0.4.2
File Size Uploaded
mailcycle-0.4.2.tar.gz 76.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mailcycle 0.4.2
File Interpreter ABI Platform
mailcycle-0.4.2-py3-none-any.whl Python 3 none any Details

Total release size: 139.0 kB

Release files / mailcycle-0.4.2.tar.gz

Download URL mailcycle-0.4.2.tar.gz
Size 76.1 kB
Tags Source
SHA-256 checksum
How to use checksums
84cdca8fd60f9292e068857e9fb7dd34d801f408bf193e1d51c95446598b9f18
BLAKE2b-256 checksum
How to use checksums
c04dc8f29d80d370dad5fc21e9ec8c7963c7576dd2cc923cbf636b09c1188d26
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.4.2-py3-none-any.whl

Download URL mailcycle-0.4.2-py3-none-any.whl
Size 62.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
2515fe70918bdd576e6a2e6cc179f1eb9d029942fdb25156c423dd0e1155d223
BLAKE2b-256 checksum
How to use checksums
95b464c2f9642152b9c0f61a5dab31b278dafdd3d5bbda088334eeb193e3e65c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.7

Release history Release notifications | RSS feed

0.5.0

2 release files

This release

0.4.2 This release

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page