Python SDK
mailcycle is a client for the API that does the encryption the Mailcycle apps do, on your machine. With it a script can create addresses that receive mail, open what arrives, and send. The recovery phrase never leaves the machine the script runs on.
It needs Python 3.10 or later.
pip install mailcycle # add [events] for the live event streamSign in
Section titled “Sign in”import osfrom mailcycle import Mailcycle
mc = Mailcycle.sign_in(os.environ["MAILCYCLE_PHRASE"])This is the same handshake the apps use: nothing secret crosses the wire, and it works on every plan.
With an API key instead, on Operator and up:
mc = Mailcycle.with_api_key( os.environ["MAILCYCLE_API_KEY"], phrase=os.environ.get("MAILCYCLE_PHRASE"), # optional)Without the phrase a client can manage addresses, devices and metadata, but cannot open mail or create an address that receives it, because the address’s key is derived from the phrase. With both, the phrase is checked against the key’s account before anything is opened.
Each sign_in opens a 30-day session. Keep mc.session_token somewhere private and pick it up with Mailcycle.resume(token, phrase) rather than signing in on every run.
Addresses
Section titled “Addresses”address = mc.addresses.create(prefix="signup", label="Test run 42")print(address.email_address)
for existing in mc.addresses.list(): print(existing.email_address, existing.label)The public half of the address’s key goes up with the request so the mail server can seal what arrives; the private half stays here. The label is sealed on your machine, and the server stores it as ciphertext.
To name an address, pass style (person, business, team or neutral) and the server rolls a name like maya.holt or harbor-supply42. To choose one first, roll a few and pass the one you want as local_part:
names = mc.addresses.roll_names("person") # up to 8 names free right nowaddress = mc.addresses.create(local_part=names[0])roll_names uses the account’s first domain unless you pass domain=, as create does. A rolled name works on every plan; any other local_part needs Scale or above. If you pass more than one, local_part wins over prefix, and prefix over style.
Reading mail
Section titled “Reading mail”for message in mc.messages.iterate(address.id): print(message.from_address, message.subject) print(message.text)
data = mc.messages.attachment(message, 0) # bytes, opened locallyA 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.
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.
message.html is the HTML as the sender wrote it. message.safe_html has every remote image, stylesheet and embed removed, as the apps do it, and is the one to render; message.blocked lists what was taken out.
Waiting for a message
Section titled “Waiting for a message”code = mc.messages.wait_for(address_id=address.id, subject="verification", timeout=60)Listens on the event stream where it is available and polls the address otherwise, and returns the message opened. If nothing matching 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
Section titled “Sending”result = mc.messages.send( from_address=address.email_address, to="someone@example.com", subject="Hello", text="Hi",)print(result.message_id) # the Message-ID headerprint(result.sent_copy_id) # the Sent copy, for messages.get(); None if the address keeps nonePass reply_to=message to thread a reply. A session can send from Personal up; an API key on Scale and up. Free receives only.
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 Pathfrom 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"), ],)API keys
Section titled “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, and one that will not open comes back as None. Only a session from sign_in can list, make or revoke keys. See API keys.
Webhooks
Section titled “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)mc.webhooks.test(created.webhook.id)
for delivery in mc.webhooks.deliveries(created.webhook.id): print(delivery.event_type, delivery.status)Operator and up. webhooks.list() returns the webhooks and event_types, every type one can ask for; ["*"] asks for all of them. update() changes only what you pass. redeliver(id, delivery_id) sends an event again with the same event id. The name is sealed like an API key’s. Adding, changing, rotating and deleting need a session; an API key can list, test, read deliveries and redeliver. See Webhooks.
Pairing devices
Section titled “Pairing devices”device = mc.devices.pair("1234 5678", name="Front desk") # the 8 digitsdevice = 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 keysmc.devices.unassign(device.id, address.id) # and loses it againThis 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 and the account
Section titled “Sessions and the account”for session in mc.sessions.list(): print(session.id, session.current)
mc.sessions.revoke_others() # everywhere but heremc.delete_account() # everything, at once, with no undosessions.revoke(id) ends one session. sign_out() ends this client’s session, and does nothing for a client built with an API key. delete_account() erases the account and its mail immediately, and its addresses are never issued again. All of these need a session. mc.subscription() returns the plan and balance, and mc.plans() lists every plan.
Events and webhooks
Section titled “Events and webhooks”with mc.events.stream() as events: for event in events: print(event.type, event.payload)Needs pip install 'mailcycle[events]'. Events carry ids, never content, and are not replayed after a drop, so on_reconnect= is where to refetch what you are showing.
To check a webhook came from Mailcycle, pass the body exactly as it arrived:
from mailcycle import verify_webhook_signature
verify_webhook_signature(secret, raw_body, request.headers.get("Mailcycle-Signature"))Errors
Section titled “Errors”Everything raises a subclass of MailcycleError. An API failure is an APIError carrying the HTTP status and the API’s own code, which is what to match on.
from mailcycle import APIError
try: mc.messages.send(from_address=address.email_address, to="x@example.com", subject="Hi", text="Hi")except APIError as error: if error.code == "rate_limited": ...How the encryption is checked
Section titled “How the encryption is checked”It is checked against the published test vectors, including the refusals.
© Northlab Studios Ltd, trading as Mailcycle. Registered in England and Wales, company no. 17462734. Registered office: 167-169 Great Portland Street, London W1W 5PF.