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

@wtfalch/email

v0.14.0

Published

The wtfalch estate: reading a mailbox over JMAP, and administering the Stalwart server it lives on. Two entries with no code in common.

Readme

@wtfalch/email

The estate's mail, in two entries with no code in common:

@wtfalch/email/mailbox      reading and writing messages   (RFC 8620/8621)
@wtfalch/email/postmaster   administering the server       (Stalwart's `x:` extension)

They share a subject, a scope and a repository, and nothing else. There is deliberately no root entry: an entry named for a seam that does not exist is worse than no entry at all.

/mailbox is for the person who has an address — inbox, threads, compose — running in their browser as them, able to touch only their own mail. /postmaster is for whoever runs the server — who gets a mailbox, what name is on it, who holds a key — running server-side with an administrator credential, and never touching the contents of anybody's messages.

The post office holds the analogy: one is the letters, the other is the pigeonholes.


@wtfalch/email/mailbox

Read and write mail over JMAP: session, mailbox tree, threads, bodies and attachments, search, identities, submission and push. RFC 8620 and 8621, over jmap-jam, so it is not tied to Stalwart the way its neighbour is.

import { MailClient, mailboxes, findRole, listThreads, thread } from '@wtfalch/email/mailbox';

const client = new MailClient({
  sessionUrl: 'https://email.example.com/.well-known/jmap',
  bearerToken: accessToken,   // the signed-in person's, from the issuer
});

const tree = await mailboxes(client);
const page = await listThreads(client, findRole(tree, 'inbox').id, { limit: 50 });

A login usually reaches more than one mailbox. client.accounts() lists them, the person's own first: a shared mailbox such as hr@ is an ordinary account the server puts in the same session with isPersonal: false, because the account is a member of it. A client stays on the account it was built with, so opening another one is new MailClient({ …, accountId }) with the id from that list — which is what keeps one client's cached session honest.

One folder can also be shared on its own, finer than a whole account: shareMailbox(client, mailboxId, accountId, rights) grants another account some of RFC 8621 §2's ten rights (mayReadItems, mayAddItems, …) on that one mailbox, and unshareMailbox takes them back. Both patch Mailbox/set at shareWith/<accountId> rather than sending the whole shareWith map, so granting or revoking one account's access never touches another's, and needs no read first. mailboxes() returns each node's own myRights (what the signed-in login may do there) and shareWith (who else can, and with what), both always present, every right explicit — RFC 8621 requires myRights, and the wire has been seen to fill in every unrequested right on shareWith as false too, which buildTree also does for a caller-built value that leaves some out.

A page of threads is one round trip: the query, the newest message per thread, the threads' member ids and the members are four method calls chained by JMAP result reference in a single request.

replyDraft and forwardDraft are pure — give them a message and they compute the recipients, the subject prefix, the In-Reply-To and References chain and the quoted body, with no client and no network, so a composer can call them on every keystroke. send creates the draft, submits it, and files it in Sent; a delay in seconds holds it there first, and cancel unsends it while undoStatus is still 'pending' — the only honest sign the window is still open, since a server not set up for held delivery answers 'final' regardless. queued lists everything still held, soonest first, for a "scheduled" view that survives a reload: it filters its query on undoStatus rather than reading the field off an unfiltered one, since that filter is what makes the server recheck each submission against the mail queue and demote one that already went, instead of answering a stale 'pending'. Every row it returns is safe to hand straight to cancel. saveDraft stores a draft in Drafts so it outlives the compose sheet, and given the id it returned last time, replaces that draft instead of adding one (messages are immutable, so the id changes each save). send(…, { draftId }) sends and removes the saved draft. Both refuse an id that is not a $draft, since replacing is a destroy. attach uploads a file and gives back what a draft carries; the upload is refused before any of it is sent when the file is over the session's maxSizeUpload, which client.session() reports alongside maxSizeAttachmentsPerEmail.

setRead, setFlagged, moveThread, moveToRole and removeFromMailbox change mail rather than read it, and every one of them takes a thread. RFC 8621 has no Thread/set — a thread is derived from its messages — so each resolves the members and patches all of them in one Email/set, because a conversation half-archived or half-read is not a thing anybody asked for. They resolve when the server has accepted the change; nothing here is optimistic, since the count is the one number people trust.

await setRead(client, threadId, true);
await moveToRole(client, threadId, 'archive', { mailboxes: tree });

setLabel puts a label on a thread without moving it: the keyword $label:<name>, lowercased, since keywords are case-insensitive. A name a keyword cannot hold (a space, any of (){]%*"\) is refused, not rewritten. ThreadSummary.labels carries the labels on any message in the thread. listLabels reads the newest 500 messages (scan to change it), because JMAP can search for a keyword but cannot list them.

snoozeThread moves a thread into a top-level Snoozed mailbox, made on first use, and stamps it $snoozed:<unix seconds>. JMAP has no snooze, so nothing brings it back on its own: the host runs wakeSnoozed(client, now) on a timer, and each thread whose time has come goes back to the Inbox, unread.

Note that a stock Stalwart account has no Archive mailbox — it creates Inbox, Drafts, Sent Items, Junk Mail and Deleted Items — so moveToRole(…, 'archive') throws there rather than doing nothing, and a caller should offer the action only where findRole(tree, 'archive') finds one.

The React

@wtfalch/email/mailbox/react draws it, on @wtfalch/design >=0.16.0. Mail is the whole client — mailboxes, list, message, composer, search, a toolbar, and a command palette on Cmd-K — and the only piece that holds state or fetches. MailboxTree, ThreadList, ThreadView and Composer take their data as props and know nothing about a client, so an application with its own layout takes the four and skips the shell. HtmlBody, which ThreadView draws a message's HTML with, is exported on its own too. Import @wtfalch/email/mailbox/mail.css after the design stylesheet.

Three layouts, and the middle one is not decoration. Three panes above 1150px, an icon rail and two panes above 860px, one pane at a time below it. That last is why usePanes is a hook and not a media query: at one column the list and the message are the same space, so which of them is showing is state the shell has to hold.

The keyboard is the point. Arrows and j/k move through the list, which is a single tab stop rather than fifty; c writes, r/a/f answer, e/#/u/s file, / searches and ? lists them all. They are ignored while a field has focus, or c could never be typed into a subject.

Opening a conversation marks it read, which is what opening means — and only when there is something unread in it, because a set that changes nothing still moves the account's state and wakes every other client's push connection.

A file is on the draft only once it has uploaded. Attach opens the file picker, and a file dropped anywhere on the composer is taken too. Until its upload lands it sits in the list with a spinner and Send waits, because a message that went while a file was on its way arrives without it. One that failed says why and can be tried again; one over the server's limits is refused before a byte is sent. Either stays in the list, and Send keeps waiting, until it is retried or removed. Composer takes the upload as an onAttach prop, since it holds no client, and Mail supplies attach.

HTML is drawn in a frame it cannot climb out of. Mail arrives from strangers, and a client that injects their markup into its own document is one script tag from being read by them. So HtmlBody never does. Three walls, each of which holds if the other two fall:

  • A sandboxed frame. A srcdoc iframe with no allow-scripts, no allow-forms and no allow-top-navigation: nothing in it runs, submits or replaces the page. Links open a tab of their own. It has allow-same-origin, deliberately, so the view can size the frame to the message; that is safe only because nothing in it can run, and FRAME_SANDBOX is pinned by a test that fails if allow-scripts joins it.
  • A content policy of its own. default-src 'none', inline styles and nothing else, form-action and base-uri closed, no fonts, and images from data: only.
  • A sanitiser. An allowlist of elements and attributes, walked over an inert DOMParser document, for what the other two do not stop — a <meta> refresh, a <base>, a ping, a relative address that would fetch from this application. CSS is read back through the CSSOM rather than matched with a pattern, so u\72l( is caught as the url( it is.

Remote images wait to be asked for. An image on the sender's server tells the sender the message was opened, when and from where, so they are withheld and the reader is told how many, with a button that shows them for that message. Inline cid: pictures are part of the message and are drawn — fetched through fetchInline, behind the same credential as everything else.

Plain text first, where there is a choice. A message with both bodies is shown as its text, with the formatted version a button away. HTML is drawn on white in either theme, because it was written for white.

A srcdoc frame inherits the embedding page's content policy as well as carrying its own, so a host whose policy has no style-src 'unsafe-inline' will draw mail unstyled, and one whose img-src has no data: will draw it without pictures.

Mailboxes are drawn in reading order, not the server's. Stalwart gives all five sortOrder: 0, so the tie-break by name puts Deleted Items first and Inbox third. mailboxes() still reports what the server said.

A mailbox with no server

@wtfalch/email/mailbox/fake is an inbox in memory, for stories, for a development build and for tests.

const { client, mailbox, dispose } = fakeMail();
const tree = await mailboxes(client);   // the real function, over the real wire code

It fakes the transport, not the API. Stubbing the exported functions would mean every story exercises the stub while the four-call chain, the result references, the body resolution and the null-handling never run. It answers JMAP instead. It replaces the global fetch, because jmap-jam calls the bare global and takes no way to pass another one; the replacement delegates anything not addressed to it, and dispose() puts it back.

What it knows that the RFCs do not

RFC 8621 §7.5's onSuccessUpdateEmail collides. The server returns the implicit Email/set under the same method call id as the EmailSubmission/set that triggered it, so a client reading a multi-call response into a map by id loses the submission's own result. jmap-jam does exactly that, so send submits through the single-call path — and creates the draft in a separate request, which also leaves a failed send in Drafts rather than losing it.

The event source is authenticated, and the browser EventSource cannot send a header. push reads the stream with fetch and parses the server-sent events itself.

Session URLs are templates that need encoding. RFC 8620 §6.2 requires it, and jmap-jam's expander does not: an attachment called Q3 report (final).pdf otherwise builds a URL addressing the wrong thing.

Stalwart sends null, not an omitted property, for an absent header or display name. src/mailbox/fixtures/instance-one.json is recorded from a live 0.16.20 instance and pins it.

jmap-jam is kept internal. It ships raw TypeScript with .ts import specifiers, which only compiles under allowImportingTsExtensions; nothing of it reaches this package's public types, so a consumer does not inherit the flag.

Per-mailbox sharing is real, and it is the plain Mailbox object, not a separate capability. shareWith/myRights are confirmed on the schema at stalwartlabs/stalwart tag v0.16.20 (crates/jmap-proto/src/object/mailbox.rs:31-53) and exercised end to end — grant, partial patch, revoke, and the server's own fill-in of every unrequested right as false on read-back — by the sibling AddressBook object's ACL suite, tests/src/jmap/contacts/acl.rs, which shares the same shareWith/myRights machinery. Not yet confirmed against a live instance the way instance-one.json's fixture is: nothing in this package wrote a shareWith grant before now.

@wtfalch/email/postmaster

Administer a Stalwart mail server: its domain, its mailboxes, their aliases, their app passwords and their quotas, over the JMAP management API.

This is not a JMAP mail client. It speaks the urn:stalwart:jmap management extension — x:Account, x:Domain, x:ApiKey and their neighbours — which is a different surface from RFC 8620 and 8621 entirely.

The credential is why this entry must never reach a browser. /mailbox runs in a browser holding the signed-in person's token. This runs on a server holding an administrative one. A minted key can be scoped — that is what DASHBOARD_PERMISSIONS is for — but nothing obliges a caller to hand this entry a scoped one, and an account password or an 'inherit' key is as powerful as the account itself.

Neither a package boundary nor an entry name enforces that: bundlers follow imports. What does is the browser condition in the exports map, which resolves this entry to a module that throws with an explanatory message. It was server-only for one commit, which was wrong — that throws in any environment without React's react-server condition, plain Node included, so it blocked pnpm bootstrap and mail:sync, which are exactly the callers this entry is Node-safe for. The browser condition tells the illegitimate caller from the legitimate one; server-only could not.

import { openInstance, overview } from '@wtfalch/email/postmaster';   // pure Node
import { DomainCard, Mailboxes, People } from '@wtfalch/email/postmaster/react';

The first entry has no React in it, so a provisioning script can use it. The components are server components except the two interactive leaves, and they take data and already-bound actions: nothing here fetches, gates or decides. The consuming app reads the instance, asks its own authorisation, binds its own Server Actions and passes the results in — the division @wtfalch/threads draws, and what keeps a package free of anybody's session.

openInstance refuses with a reason, not a sentence. It throws InstanceRefused, whose reason is 'not-https' or 'account-missing'. Branch on that. Both refusals were once a plain Error, so consumers told them apart by matching words in the message, and rewording a sentence here quietly changed which error a dashboard showed.

try {
  openInstance({ url, secret, user });
} catch (e) {
  if (e instanceof InstanceRefused && e.reason === 'account-missing') …
}

@wtfalch/design is an optional peer dependency, needed only for /react.

What it knows that the docs do not

Every one of these was established against a live 0.16.20 instance, and each cost an afternoon.

An API key authenticates as Authorization: Bearer, never Basic. The same secret sent as Basic is refused with 401 whatever username accompanies it — the account name, the local part, the key itself, none. authFor picks the scheme from the secret's own shape, since a key is API_-prefixed.

An API key is scoped with the sys* permission names. permissions takes {'@type': 'Replace', permissions: {…}}, a Set, so {sysDomainGet: true}. The names live in enums.Permission, and the management ones read sys<Object><Verb>. An earlier round of sixty guessed names accepted only authenticate and impersonate, which is what made a scoped key look impossible; the guesses were wrong, not the feature.

createApiKey therefore mints a scoped key by default. DASHBOARD_PERMISSIONS is the set verified on 0.16.20: authenticate plus sysAccountQuery, sysAccountGet, sysAccountCreate, sysAccountUpdate, sysDomainQuery, sysDomainGet, sysSystemSettingsGet, sysMtaExtensionsGet, sysMtaExtensionsUpdate and sysActionCreate. It serves every /email page, and it is forbidden on Email/query for every account on the instance — including its own, so the key administers mail and cannot read any. Pass 'inherit' for a key as powerful as the account; prefer not to.

sysActionCreate is the widest of these: Stalwart has one permission for every action, so the key that can reload settings can also pause the mail queue. It is there because a changed setting does nothing until a ReloadSettings action runs. A key minted before a name joined the set does not gain it; mint a new one.

A server that is not holding mail says nothing. With MtaExtensions.futureRelease unset, Stalwart sends a held submission at once and reports it as held. Provisioning sets it (FUTURE_RELEASE, 30 days); an instance provisioned before it did stays off. holdLimit(instance) returns the limit, or null when the server will not hold at all, and enableHolding(instance) sets it and reloads.

An API key can never create a credential. It authenticates, and both x:ApiKey/set and x:AppPassword/set answer forbidden whatever permissions it holds, so a key cannot mint its own successor. Rotating one needs the account's password or app password. On a domain with an external directory the account password stops being verified locally as well, which leaves an app password the only credential that can still write — and the admin UI will not add one from the account form, answering "Secondary credentials cannot be set directly". Provision the replacement before revoking the one in use.

A List<T> write replaces the whole list. Adding one alias means sending back every alias that survives. A read-modify-write that drops an entry does not fail; it deletes somebody's alias, or every app password on their phone. withAlias, withoutAlias and withoutAppPassword exist so that arithmetic is decided by a unit test rather than at the end of a request.

A shared mailbox is a Group account. hr@ that a team reads together is an ordinary account whose @type is Group: same delivery, same aliases, no password, because nobody ever signs in as it. Who reads it is written on the people — an account's memberGroupIds names the groups it belongs to, and the server turns each one into a second account on that person's own JMAP session, which is how /mailbox's client.accounts() sees it. createGroupMailbox makes one, addToGroup and removeFromGroup say who reads it, and withMembership/withoutMembership do the arithmetic. A membership map is a set on the wire ({"<id>": true}) and is replaced whole, exactly like a List<T>. addToGroup refuses a group id that turns out to be a person's own mailbox: the server would store that happily and it would give nobody access to anything.

There is no conditional write. JMAP's answer to the above is ifInState, and these objects do not offer it: x:Account/get returns accountId, list and notFound with no state token, and x:Account/set rejects an ifInState argument outright as notRequest. So two overlapping writes to one mailbox still lose one, and what apply.ts does instead is notice: it re-reads afterwards and names anything the caller never touched that has gone.

Wire shapes are not what the docs show. A List<T> is a map keyed by index ({"0": …}), a Map<T> is {"value": true}, a SecretKey from the environment is {"@type":"EnvironmentVariable","variableName":"…"}, and singletons are addressed with the id singleton. An administrator's roles is {'@type': 'Admin'}. usedDiskQuota is a plain byte count; quotas is an object whose key for a set quota has never been observed, so overview() reports no limit rather than inventing a number.

Refusals

The write helpers refuse rather than doing something surprising: an alias given as a whole address (it would become an address at [email protected]@example.com), a duplicate (an unreachable second entry), revoking an account Password or an ApiKey through a call meant for app passwords, and creating a mailbox on a domain that keeps its own passwords, which needs a first password shown once and so points at a terminal tool instead.

The audit

createMailbox, createGroupMailbox, addToGroup, removeFromGroup, addAlias, removeAlias, revokeAppPassword, enableHolding, retryQueuedMessage and purgeQueuedMessage take an optional last argument, a PostmasterAudit: a write the host bound to its own ledger, the principal it resolved for this request, and the request id its log uses. Without one, nothing changes.

const applied = await addAlias(instance, { accountId, name, domainId, apex }, {
  write: (event) => ledger.record(event),     // the host's, bound to `mail`
  actor: { id: principal.id, display: principal.display },
  requestId,
});

A write records what happened, after the server has answered. The event carries the before/after pair the write computed — which alias, on which mailbox; which app password, by its description — so a host does not reassemble it from Applied. It is never a secret: target.id is an address or a credential id.

A refused write records nothing. It throws before write is called, and recording the attempt is the caller's, since only the caller knows who was refused and why. Nor can the two be atomic — one is a mail server, the other is the host's database — so a crash between them loses the row, and the request id is what joins it back to a log line.

The names are mail.*, declared in POSTMASTER_AUDIT_EVENTS so a host can merge them into its ledger's vocabulary. A host whose vocabulary is closed maps them instead: wtfalch-manage's database refuses any action outside its authorisation package's set, so its writer puts each back on the core event it already recorded these under, and signs the row with the principal it resolved rather than the one the event echoes.