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

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)

Source distribution for mailcycle 0.1.0
File Size Uploaded
mailcycle-0.1.0.tar.gz 56.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mailcycle 0.1.0
File Interpreter ABI Platform
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

Release history Release notifications | RSS feed

0.5.0

2 release files

0.4.2

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

This release

0.1.0 This release

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