mailcycle-sdk
v0.8.0
Published
TypeScript client for the Mailcycle API. Create addresses that receive mail, read and decrypt it on your machine, send, and listen for events.
Maintainers
Readme
mailcycle-sdk
A TypeScript 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 Node 20 or later, or any runtime with fetch and WebCrypto: Deno, Bun, Cloudflare Workers, browsers. No dependencies.
Sign in
import { Mailcycle } from 'mailcycle-sdk';
// With the recovery phrase: everything the app can do, on any plan.
const mc = await Mailcycle.signIn({ phrase: process.env.MAILCYCLE_PHRASE! });
// With an API key (Operator and up). Add the phrase to open mail and create addresses that receive it.
const scripted = await Mailcycle.withApiKey({
apiKey: process.env.MAILCYCLE_API_KEY!,
phrase: process.env.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.
Each signIn opens a new 30-day session. A script that runs often can keep mc.sessionToken somewhere private and pick it up with Mailcycle.resume({ phrase, sessionToken }), and mc.signOut() ends a session it no longer needs.
Mailcycle.createAccount() makes a new account and returns its phrase. Keep it: nothing can recover the account without it.
Addresses that receive mail
const address = await mc.addresses.create({ prefix: 'signup', label: 'Test run 42' });
console.log(address.emailAddress); // 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. addresses.list(), update() and delete() do the rest.
For a name like maya.holt, pass a style (person, business, team or neutral) and the server rolls one, or call addresses.rollNames({ style }) for up to 8 free names and pass one as localPart. Rolled names work on every plan.
Reading mail
const { messages } = await mc.messages.list(address.id);
for (const m of messages) console.log(m.from, m.subject, m.text);
const file = await mc.messages.attachment(messages[0], 0); // Uint8Array, 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 an error.
Pages come newest first. Pass nextCursor back as cursor for the next page, as it came: it is opaque. An attachment with stored: false had no body, and fetching it fails with 404.
html is the HTML as the sender wrote it, remote images and all. safeHtml has remote images, stylesheets and embeds removed, the same way the app does it, and is the one to render.
Waiting for mail
const code = await mc.messages.waitFor({ inboxId: address.id, subject: 'verification', timeoutMs: 60_000 });This listens on the event stream and resolves with the next matching message, opened. Where the stream is not available (no WebSocket), it checks the address every five seconds instead, so inboxId is needed then. If nothing arrives in time, it rejects 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
await mc.messages.send({ from: address.emailAddress, to: '[email protected]', subject: 'Hello', text: 'Hi' });
await mc.messages.send({ from: address.emailAddress, to: m.replyTo, subject: `Re: ${m.subject}`, text: 'Thanks', replyTo: m });send returns messageId, the Message-ID header it went out with, and sentCopyId, the Sent copy's id, which messages.get takes. sentCopyId is null when the address keeps no copy.
A session can send from Personal up; an API key on Scale and up. Free receives only. Limits are in the rate limits.
Usage and events
const usage = await mc.usage();
console.log(usage.addresses.used, 'of', usage.addresses.limit, 'addresses');
console.log(usage.messagesReceived.used, 'received this period');
for (const event of await mc.activity(50)) console.log(event.createdAt, event.kind);
const page = await mc.activityPage({ limit: 100 });
const older = await mc.activityPage({ limit: 100, cursor: page.nextCursor });
const everything = await mc.allActivity();usage() reads back the counters the API enforces its limits on, so it says what a request would be refused on before it is. activity() is the account's own event log: ids and times, never content. Events are kept for 90 days; activityPage() pages back through them and allActivity() reads them all.
Events
const stream = mc.events.stream((event) => console.log(event.type, event.payload), {
onReconnect: () => console.log('back; refetch what you show'),
});
// later
stream.close();The same events as webhooks, the moment they happen. Events carry ids, never content. On Node 20, pass a WebSocket class as the WebSocket option; Node 22 and later have one built in.
To check a webhook came from Mailcycle, use verifyWebhookSignature(secret, rawBody, request.headers.get('Mailcycle-Signature')).
API keys
const { apiKey, token } = await mc.apiKeys.create('Nightly build');
// Store `token` now. It is not shown again.
for (const key of await mc.apiKeys.list()) console.log(key.id, key.name, key.lastUsedAt);
await mc.apiKeys.revoke(apiKey.id);Keys are managed from a session, not from another key. The name is sealed on your machine, as the app seals it. A name that will not open with this phrase comes back as null.
Webhooks
const { webhook, secret } = await mc.webhooks.create({
url: 'https://example.com/mailcycle',
events: ['message.received'],
name: 'Receipts',
});
// Store `secret` now. It is not shown again.
const { webhooks, eventTypes } = await mc.webhooks.list();
await mc.webhooks.update(webhook.id, { enabled: false });
const newSecret = await mc.webhooks.rotateSecret(webhook.id);
await mc.webhooks.test(webhook.id);
const { deliveries, nextCursor } = await mc.webhooks.deliveries(webhook.id, { status: 'failed' });
await mc.webhooks.redeliver(webhook.id, deliveries[0].id);
await mc.webhooks.delete(webhook.id);Operator and up. Creating, changing, rotating and deleting need a session; an API key can list, test, read deliveries and redeliver. The name is sealed like an API key's. enabled: true switches a webhook back on and clears its failure record.
Sessions
for (const s of await mc.sessions.list()) console.log(s.id, s.createdAt, s.current);
await mc.sessions.revoke(id);
const ended = await mc.sessions.revokeOthers(); // every session but this onemc.signOut() ends this client's session. On an API key client it does nothing: revoke the key instead.
The account
const sub = await mc.subscription(); // plan, renewal date, balance, and any pause
const plans = await mc.plans(); // plan_free, plan_personal, plan_operator, plan_scale, …
await mc.deleteAccount();deleteAccount() erases the account and everything in it, at once. Its addresses are retired and never issued again. There is no undo. It needs a session, and confirms with the phrase by answering a fresh challenge.
Pairing devices
// The 8 digits the device shows, or the text of its QR code.
const device = await mc.devices.pair('MC1:12345678:…', { name: 'Front desk' });
await mc.devices.assign(device.id, address.id); // then hands it keys that include the address
await mc.devices.unassign(device.id, address.id); // then hands it keys without it
await mc.devices.handOverKeys(device.id); // again, for everything it holdsThis is the account's side of pairing, done by a script instead of the app. It needs a session and the phrase.
A new device holds no address until one is assigned. After each assign and unassign the device is handed a fresh set of keys for exactly what it holds, as the app does. They are sealed under the device's wrap secret on your machine, and the server cannot open them.
There are two ways to pair, and device.keyTransport says which was used.
- The QR text (
MC1:<code>:<secret>) carries the device's own wrap secret. It never passes through Mailcycle. This isoptical. - The 8 digits carry no secret. The SDK makes one, seals it to a key the device published, and keeps it sealed in the device's profile. The server relays that key, so it could put its own in its place at pairing and read the keys handed over after. It cannot read anything without doing that. This is
relayed. Use the QR text where you can.
A device whose build publishes no key cannot be paired from the digits, because nothing could hand it keys. pair refuses it with no_device_key. Pair it in the app by scanning its QR code, or pass the QR text.
Errors
Every API failure throws a MailcycleError with the HTTP status and the API's code, such as plan_required or session_required. See errors.
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.
