@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.
Maintainers
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
srcdociframe with noallow-scripts, noallow-formsand noallow-top-navigation: nothing in it runs, submits or replaces the page. Links open a tab of their own. It hasallow-same-origin, deliberately, so the view can size the frame to the message; that is safe only because nothing in it can run, andFRAME_SANDBOXis pinned by a test that fails ifallow-scriptsjoins it. - A content policy of its own.
default-src 'none', inline styles and nothing else,form-actionandbase-uriclosed, no fonts, and images fromdata:only. - A sanitiser. An allowlist of elements and attributes, walked over an
inert
DOMParserdocument, for what the other two do not stop — a<meta>refresh, a<base>, aping, a relative address that would fetch from this application. CSS is read back through the CSSOM rather than matched with a pattern, sou\72l(is caught as theurl(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 codeIt 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.
