Skip to content

TypeScript SDK

mailcycle-sdk is a typed 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 runs on Node 20 or later, and anywhere with fetch and WebCrypto: Deno, Bun, Cloudflare Workers and browsers. It has no dependencies.

Terminal window
npm install mailcycle-sdk
import { Mailcycle } from 'mailcycle-sdk';
const mc = await Mailcycle.signIn({ phrase: process.env.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:

const mc = await Mailcycle.withApiKey({
apiKey: process.env.MAILCYCLE_API_KEY!,
phrase: process.env.MAILCYCLE_PHRASE, // optional: needed to open mail and create addresses that receive it
});

Without the phrase, a key client manages addresses, devices and metadata, and gets messages back unopened. With it, the phrase is checked against the key’s account first.

Each signIn opens a 30-day session. A script that runs often can keep mc.sessionToken somewhere private and pick it up with Mailcycle.resume({ phrase, sessionToken }).

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 before it is sent. See Email addresses for domains, custom names and retention.

To get a name like maya.holt instead, roll a few and pick one, or let the server roll one:

const names = await mc.addresses.rollNames({ style: 'person' }); // ['maya.holt', …]
await mc.addresses.create({ localPart: names[0] });
await mc.addresses.create({ style: 'business' }); // e.g. harbor-supply42@…

The styles are person, business, team and neutral. rollNames returns up to 8 names that are free right now; someone may take one before you use it. A rolled name works on every plan. style is ignored when you pass localPart or prefix.

const message = await mc.messages.waitFor({ inboxId: address.id, subject: 'verify', timeoutMs: 60_000 });
const link = message.text.match(/https:\/\/\S+/)?.[0];

waitFor listens on the event stream and resolves with the next matching message, already opened. Filter by inboxId, from and subject. Where the stream cannot be opened, it checks the address every five seconds instead. 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.

const { messages, nextCursor } = await mc.messages.list(address.id, { folder: 'inbox', limit: 50 });
const bytes = await mc.messages.attachment(messages[0], 0);

Each message has from, fromName, replyTo, subject, text, html, safeHtml, messageId and attachments. An attachment the HTML shows inline has a contentId. One with stored: false had no body, and fetching it fails with 404. safeHtml has remote images, stylesheets and embeds removed, as the apps show it; render that one. A message that will not open comes back with opened: false and empty content.

Pass nextCursor back as cursor for the next page, as it came. It is opaque.

markRead, star, move, delete and emptyTrash work as in Reading mail.

await mc.messages.send({ from: address.emailAddress, to: 'someone@example.com', subject: 'Hello', text: 'Hi' });
await mc.messages.send({ from: address.emailAddress, to: message.replyTo, subject: `Re: ${message.subject}`, text: 'Thanks', replyTo: message });

Passing replyTo threads the reply. send returns messageId, the Message-ID header, and sentCopyId, the id of the Sent copy, or null when the address keeps none. Who can send, and how much, is in Sending mail.

Files go in attachments as bytes, up to 10 and 3.5 MB between them. A contentId shows one inline, where the HTML has <img src="cid:…">:

await mc.messages.send({
from: address.emailAddress,
to: 'someone@example.com',
subject: 'Invoice',
text: 'Attached.',
html: '<img src="cid:logo"><p>Attached.</p>',
attachments: [
{ filename: 'invoice.pdf', mimeType: 'application/pdf', content: pdfBytes },
{ filename: 'logo.png', mimeType: 'image/png', content: logoBytes, contentId: 'logo' },
],
});
const usage = await mc.usage(); // what the plan allows, and what is used
const events = await mc.activity(); // the account's own event log
const page = await mc.activityPage({ limit: 100 });
const older = await mc.activityPage({ limit: 100, cursor: page.nextCursor });
const everything = await mc.allActivity(); // the last 90 days

See Usage.

const stream = mc.events.stream((event) => console.log(event.type, event.payload));

The SDK reconnects after a drop and calls onReconnect, since events are not replayed. On Node 20, pass a WebSocket class as the WebSocket option.

To check a webhook came from Mailcycle:

import { verifyWebhookSignature } from 'mailcycle-sdk';
const ok = await verifyWebhookSignature(secret, rawBody, request.headers.get('Mailcycle-Signature'));
const { apiKey, token } = await mc.apiKeys.create('Nightly build'); // store the token now
const keys = await mc.apiKeys.list();
await mc.apiKeys.revoke(apiKey.id);

Session only. The name is sealed on your machine, as the apps seal it. A name that will not open with this phrase comes back as null. See API keys.

const { webhook, secret } = await mc.webhooks.create({
url: 'https://example.com/mailcycle',
events: ['message.received'],
name: 'Receipts',
}); // store the secret now
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. The name is sealed like an API key’s. See Webhooks.

const sessions = await mc.sessions.list(); // the current one has current: true
await mc.sessions.revoke(sessions[1].id);
const ended = await mc.sessions.revokeOthers();

Session only. mc.signOut() ends this client’s session, and does nothing on an API key client.

await mc.deleteAccount();

This erases the account and everything in it, at once. Its addresses are retired and never issued again. There is no undo. Session only. mc.subscription() and mc.plans() read the plan, balance and the plan list.

// 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 holds

This 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 is optical.
  • 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.

Every API failure throws a MailcycleError with the HTTP status and the API’s code. See Errors. The SDK adds a few of its own, with status 0: invalid_phrase, phrase_required, phrase_mismatch, session_required, unauthenticated, no_domain, timeout, stream_unavailable, no_websocket and network_error, and for pairing invalid_code, invalid_wrap, no_device_key and profile_unreadable. Handing keys to a device paired without keeping its wrap secret fails with no_wrap.

The phrase, the seed derived from it and every key derived from the seed. What is sent is what the apps send: the account id, the proof, public keys, sealed labels and names, and a device’s keys sealed to that device.

© Northlab Studios Ltd, trading as Mailcycle. Registered in England and Wales, company no. 17462734. Registered office: 167-169 Great Portland Street, London W1W 5PF.