npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 locally

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 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 one

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

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.