Metadata-Version: 2.5
Name: mailcycle
Version: 0.6.0
Summary: Python client for the Mailcycle API. Create addresses that receive mail, read and decrypt it on your machine, send, and listen for events.
Project-URL: Homepage, https://mailcycle.email/
Project-URL: Documentation, https://docs.mailcycle.email/
Author-email: Northlab Studios Ltd <hello@mailcycle.email>
License: Apache-2.0
License-File: LICENSE
Keywords: email,encryption,end-to-end,mailcycle,privacy
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Email
Classifier: Topic :: Security :: Cryptography
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: cryptography>=42
Requires-Dist: httpx>=0.27
Provides-Extra: events
Requires-Dist: websockets>=13; extra == 'events'
Description-Content-Type: text/markdown

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

```sh
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

```python
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

```python
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

```python
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

```python
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

```python
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.

```python
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](https://docs.mailcycle.email/reference/rate-limits/).

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

## API keys

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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:

```python
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](https://docs.mailcycle.email/reference/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` (published at [docs.mailcycle.email/vectors.json](https://docs.mailcycle.email/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

Apache License 2.0. Copyright (c) 2026 Northlab Studios Ltd. You may use, modify and redistribute this package, including inside your own applications, under the terms in [LICENSE](LICENSE). Using the Mailcycle API itself is governed by the [Terms of Service](https://mailcycle.email/legal/terms/). Never ship an API key in software you distribute.
