@wtfalch/postmaster 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Tallis Falch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,61 @@
1
+ import type { Instance } from './instance.js';
2
+ export interface Applied {
3
+ /** For the audit row's `targetType`. */
4
+ readonly targetType: 'mailbox' | 'mail_alias' | 'mail_app_password';
5
+ /** For its `targetId`: the address or the credential, never a secret. */
6
+ readonly targetId: string;
7
+ readonly before: unknown;
8
+ readonly after: unknown;
9
+ /** What to tell the person who pressed the button. */
10
+ readonly message: string;
11
+ }
12
+ /**
13
+ * Give a person a mailbox. The address is checked against the server rather
14
+ * than against the page: two admins pressing "create" for the same person at
15
+ * once would otherwise make two accounts, and the second would be the one
16
+ * mail stopped arriving at.
17
+ */
18
+ export declare function createMailbox(instance: Instance, input: {
19
+ local: string;
20
+ domainId: string;
21
+ apex: string;
22
+ description?: string | null;
23
+ external: boolean;
24
+ }): Promise<Applied>;
25
+ export declare function addAlias(instance: Instance, input: {
26
+ accountId: string;
27
+ name: string;
28
+ domainId: string;
29
+ apex: string;
30
+ }): Promise<Applied>;
31
+ export declare function removeAlias(instance: Instance, input: {
32
+ accountId: string;
33
+ name: string;
34
+ domainId: string;
35
+ apex: string;
36
+ }): Promise<Applied>;
37
+ /**
38
+ * Stop one app password working. The secret is never read, here or anywhere
39
+ * else: the entry is identified by its id and the surviving entries go back
40
+ * with the masked secrets the server sent, which it reads as "leave these
41
+ * alone".
42
+ *
43
+ * **Never on the account this management app signs in as.** `withoutAppPassword`
44
+ * refuses an `ApiKey`, which is what D3's credential is once `mail-configure`
45
+ * has minted one — but an instance configured with an *app password* on the
46
+ * administrator instead (a laptop, or an instance provisioned before slice 3)
47
+ * holds a credential that guard cannot tell from a person's phone.
48
+ * The administrator is on the default domain, so the page lists it with a
49
+ * revoke button beside every app password it has, one of which is the one
50
+ * serving the request. Pressing it would answer "that app password no
51
+ * longer works" and then fail every subsequent request with `unauthorized`
52
+ * until somebody edits the Coolify environment. So the whole account is off
53
+ * limits here; its credentials belong to the bootstrap and to this app,
54
+ * never to a mail client, and the web admin is where they are managed.
55
+ */
56
+ export declare function revokeAppPassword(instance: Instance, input: {
57
+ accountId: string;
58
+ credentialId: string;
59
+ domainId: string;
60
+ apex: string;
61
+ }): Promise<Applied>;
package/dist/apply.js ADDED
@@ -0,0 +1,191 @@
1
+ import { MailWriteRefused, newAccount, withAlias, withoutAlias, withoutAppPassword, } from './writes.js';
2
+ /**
3
+ * The four writes `/email` makes, each as one round trip against the mail
4
+ * instance's management API.
5
+ *
6
+ * **Every one re-reads the account first, and none trusts the page.** A
7
+ * `List<T>` write replaces the whole list, so computing the new list from
8
+ * what the browser last rendered would delete anything added since — an
9
+ * alias a colleague added a minute ago, an app password somebody minted on
10
+ * their phone. The page supplies only what to change (an account id, an
11
+ * alias name, a credential id); what it is changed *from* is read here, on
12
+ * this request. That is also why the refusals in `./writes.ts` are worth
13
+ * having: with a fresh read, "this mailbox has no alias x" means the alias
14
+ * is really gone, not that the page was stale.
15
+ *
16
+ * **What that read does not close is a same-instant race, and it cannot be
17
+ * closed here.** Two writes to one mailbox's list that overlap between the
18
+ * read and the write will still lose one, because the second `/set` replaces
19
+ * the list the first wrote. JMAP's answer is `ifInState`, and Stalwart 0.16
20
+ * does not offer it: `x:Account/get` returns `accountId`, `list` and
21
+ * `notFound` with no state token, and `x:Account/set` rejects an `ifInState`
22
+ * argument outright as `notRequest` (checked 2026-09-07). There is nothing
23
+ * to be conditional on.
24
+ *
25
+ * So the write cannot be made safe, and what happens instead is that it is
26
+ * made *loud*: `confirmSurvivors` re-reads after every list write and says so
27
+ * when something the caller never touched has gone. That does not undo the
28
+ * loss — nothing here can — but it turns a silent deletion into a sentence
29
+ * naming what disappeared, which is the difference between finding out now
30
+ * and finding out when somebody's mail stops arriving.
31
+ *
32
+ * **Every account is checked against the domain the caller resolved.** The
33
+ * credential this app holds is the instance administrator's, so it
34
+ * reaches every account on every domain the instance serves — and instance
35
+ * one serves two. The account id arrives from the browser, so without this
36
+ * check a `mail:admin` could name an account the page never showed them,
37
+ * on a domain this app does not manage, and the audit row would name
38
+ * an address on the wrong domain because it is composed from the resolved
39
+ * apex.
40
+ *
41
+ * Each returns the `before`/`after` pair an audit row records, as plain
42
+ * JSON, plus a sentence for the person who pressed the button.
43
+ */
44
+ /**
45
+ * After a list write, whether everything the caller meant to keep is still
46
+ * there. Compared by name rather than by deep equality: the server
47
+ * normalises what it stores, and a false alarm on every write would train
48
+ * the reader to ignore a true one.
49
+ *
50
+ * Returns a sentence to append when something is missing, and `''` when all
51
+ * is well.
52
+ */
53
+ function missingSince(expected, actual) {
54
+ const gone = expected.filter((name) => !actual.includes(name));
55
+ if (gone.length === 0)
56
+ return '';
57
+ return ` Note: ${gone.join(', ')} ${gone.length === 1 ? 'was' : 'were'} also on this mailbox a moment ago and ${gone.length === 1 ? 'is' : 'are'} not now — somebody else was editing it at the same time, and this write replaced what they had just set. Check the mailbox before relying on it.`;
58
+ }
59
+ /** The alias names on an account, as the server has them right now. */
60
+ async function aliasNames(instance, accountId) {
61
+ const [fresh] = await instance.client.get('Account', [accountId]);
62
+ return Object.values(fresh?.aliases ?? {}).map((a) => a.name);
63
+ }
64
+ /** The credential ids on an account, as the server has them right now. */
65
+ async function credentialIds(instance, accountId) {
66
+ const [fresh] = await instance.client.get('Account', [accountId]);
67
+ return Object.values(fresh?.credentials ?? {})
68
+ .map((c) => c.credentialId)
69
+ .filter((id) => typeof id === 'string');
70
+ }
71
+ /**
72
+ * The account as the server has it right now, confined to the domain the
73
+ * caller resolved. A mismatched domain gets the same answer as a missing
74
+ * account, deliberately: telling a caller "that exists, but not here" turns
75
+ * the id into an oracle for every mailbox on the instance.
76
+ */
77
+ async function readAccount(instance, accountId, domainId) {
78
+ const [account] = await instance.client.get('Account', [accountId]);
79
+ if (!account || account.domainId !== domainId) {
80
+ throw new MailWriteRefused('that mailbox is not on this domain any more');
81
+ }
82
+ return account;
83
+ }
84
+ /**
85
+ * Whether this account is the one this app itself signs in as.
86
+ *
87
+ * `MAIL_ADMIN_USER` is a whole address and an account's `name` is a local
88
+ * part, so the comparison is made on the address. Used to keep this app
89
+ * from revoking its own credential (see `revokeAppPassword`).
90
+ */
91
+ function isOurOwnAccount(instance, account, apex) {
92
+ // With an API key there is no account to compare: a Bearer credential
93
+ // carries no account name, and `withoutAppPassword`'s refusal to touch an
94
+ // `ApiKey` is what keeps this app from revoking itself. The comparison
95
+ // matters on the app-password path, where the credential is an app
96
+ // password on the administrator and indistinguishable from a person's.
97
+ if (instance.user === null)
98
+ return false;
99
+ return `${account.name}@${apex}`.toLowerCase() === instance.user.toLowerCase();
100
+ }
101
+ /**
102
+ * Give a person a mailbox. The address is checked against the server rather
103
+ * than against the page: two admins pressing "create" for the same person at
104
+ * once would otherwise make two accounts, and the second would be the one
105
+ * mail stopped arriving at.
106
+ */
107
+ export async function createMailbox(instance, input) {
108
+ // Throws before any read when the request is one this app does not make
109
+ // (a whole address, an empty name, a domain that keeps its own passwords).
110
+ const value = newAccount(input);
111
+ const local = value.name;
112
+ const address = `${local}@${input.apex}`;
113
+ const existing = (await instance.client.list('Account')).find((a) => a.domainId === input.domainId && a.name.toLowerCase() === local);
114
+ if (existing) {
115
+ throw new MailWriteRefused(`${address} already has a mailbox`);
116
+ }
117
+ const id = await instance.client.create('Account', value);
118
+ return {
119
+ targetType: 'mailbox',
120
+ targetId: address,
121
+ before: null,
122
+ after: { id, name: local, domainId: input.domainId, description: input.description ?? null },
123
+ message: `${address} now has a mailbox. They sign in through the issuer and make an app password for their mail client.`,
124
+ };
125
+ }
126
+ export async function addAlias(instance, input) {
127
+ const account = await readAccount(instance, input.accountId, input.domainId);
128
+ const aliases = withAlias(account, { name: input.name, domainId: input.domainId });
129
+ await instance.client.update('Account', account.id, { aliases });
130
+ const added = Object.values(aliases).at(-1)?.name;
131
+ const lost = missingSince(Object.values(aliases).map((a) => a.name), await aliasNames(instance, account.id));
132
+ return {
133
+ targetType: 'mail_alias',
134
+ targetId: `${added}@${input.apex}`,
135
+ before: null,
136
+ after: { alias: added, mailbox: `${account.name}@${input.apex}` },
137
+ message: `${added}@${input.apex} is now delivered to ${account.name}@${input.apex}.${lost}`,
138
+ };
139
+ }
140
+ export async function removeAlias(instance, input) {
141
+ const account = await readAccount(instance, input.accountId, input.domainId);
142
+ const aliases = withoutAlias(account, input.name);
143
+ await instance.client.update('Account', account.id, { aliases });
144
+ const lost = missingSince(Object.values(aliases).map((a) => a.name), await aliasNames(instance, account.id));
145
+ return {
146
+ targetType: 'mail_alias',
147
+ targetId: `${input.name}@${input.apex}`,
148
+ before: { alias: input.name, mailbox: `${account.name}@${input.apex}` },
149
+ after: null,
150
+ message: `${input.name}@${input.apex} is no longer delivered anywhere. Mail sent to it will be rejected.${lost}`,
151
+ };
152
+ }
153
+ /**
154
+ * Stop one app password working. The secret is never read, here or anywhere
155
+ * else: the entry is identified by its id and the surviving entries go back
156
+ * with the masked secrets the server sent, which it reads as "leave these
157
+ * alone".
158
+ *
159
+ * **Never on the account this management app signs in as.** `withoutAppPassword`
160
+ * refuses an `ApiKey`, which is what D3's credential is once `mail-configure`
161
+ * has minted one — but an instance configured with an *app password* on the
162
+ * administrator instead (a laptop, or an instance provisioned before slice 3)
163
+ * holds a credential that guard cannot tell from a person's phone.
164
+ * The administrator is on the default domain, so the page lists it with a
165
+ * revoke button beside every app password it has, one of which is the one
166
+ * serving the request. Pressing it would answer "that app password no
167
+ * longer works" and then fail every subsequent request with `unauthorized`
168
+ * until somebody edits the Coolify environment. So the whole account is off
169
+ * limits here; its credentials belong to the bootstrap and to this app,
170
+ * never to a mail client, and the web admin is where they are managed.
171
+ */
172
+ export async function revokeAppPassword(instance, input) {
173
+ const account = await readAccount(instance, input.accountId, input.domainId);
174
+ if (isOurOwnAccount(instance, account, input.apex)) {
175
+ throw new MailWriteRefused(`${instance.user} is the account this management app signs in as, and one of its app passwords is serving this request. Manage its credentials in the web admin.`);
176
+ }
177
+ const before = Object.values(account.credentials ?? {}).find((c) => c.credentialId === input.credentialId);
178
+ const credentials = withoutAppPassword(account, input.credentialId);
179
+ await instance.client.update('Account', account.id, { credentials });
180
+ const description = typeof before?.description === 'string' ? before.description : null;
181
+ const lost = missingSince(Object.values(credentials)
182
+ .map((c) => c.credentialId)
183
+ .filter((id) => typeof id === 'string'), await credentialIds(instance, account.id));
184
+ return {
185
+ targetType: 'mail_app_password',
186
+ targetId: input.credentialId,
187
+ before: { mailbox: `${account.name}@${input.apex}`, description },
188
+ after: null,
189
+ message: `That app password no longer works${description ? ` (${description})` : ''}. Any mail client using it will be asked to sign in again.${lost}`,
190
+ };
191
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Minimal client for Stalwart's management API, which is JMAP: every
3
+ * configuration object (Domain, AcmeProvider, MtaRoute, SystemSettings, …) is
4
+ * read and written with `x:<Object>/get|query|set` under the
5
+ * `urn:stalwart:jmap` capability. There is no REST surface for these in 0.16;
6
+ * the web admin and `stalwart-cli` speak exactly this.
7
+ *
8
+ * Shapes that differ from the docs and were confirmed on 0.16.20 (see
9
+ * docs/plans/email/phase-1-runbook.md, "What happened"):
10
+ * - a `List<T>` field is a map keyed by index: `{"0": …}`, never an array;
11
+ * - a `Map<T>` / `Set<T>` field is `{"value": true}`;
12
+ * - a `SecretKey` from the environment is
13
+ * `{"@type":"EnvironmentVariable","variableName":"…"}`;
14
+ * - singletons are addressed with the id `singleton`.
15
+ *
16
+ * Authentication is Basic for a password or an app password, and **Bearer
17
+ * for an API key** — confirmed on 0.16.20, 2026-09-07: an `ApiKey` secret
18
+ * presented as Basic is refused with 401 whatever username accompanies it,
19
+ * and the same secret as `Authorization: Bearer` is accepted. That is why
20
+ * `StalwartAuth` is a union rather than a pair of strings. Until
21
+ * `Bootstrap/set` has run, the only account is the recovery admin from
22
+ * `STALWART_RECOVERY_ADMIN`; after it, the administrator it generated.
23
+ */
24
+ /**
25
+ * How to authenticate. An account's password or app password goes as Basic
26
+ * with the account name beside it; an API key goes as Bearer and carries no
27
+ * account name at all, which is why `apiKey` is alone in its variant.
28
+ */
29
+ export type StalwartAuth = {
30
+ user: string;
31
+ password: string;
32
+ } | {
33
+ apiKey: string;
34
+ };
35
+ /** API-key secrets are `API_`-prefixed on 0.16, which is how a caller can tell one from an app password. */
36
+ export declare const API_KEY_PREFIX = "API_";
37
+ /** Which scheme a secret wants, from its own shape, for a caller holding one opaque string. */
38
+ export declare function authFor(secret: string, user?: string): StalwartAuth;
39
+ export declare class StalwartError extends Error {
40
+ readonly method: string;
41
+ readonly type: string;
42
+ constructor(method: string, type: string, description?: string);
43
+ }
44
+ export declare class StalwartClient {
45
+ #private;
46
+ private base;
47
+ /** @param baseUrl e.g. `https://email.example.com`, no trailing slash needed. */
48
+ constructor(baseUrl: string, auth: StalwartAuth);
49
+ private headers;
50
+ /** One method call; throws `StalwartError` on a JMAP-level error. */
51
+ call<T = Record<string, unknown>>(method: string, args: Record<string, unknown>): Promise<T>;
52
+ /**
53
+ * Whether the server answers at all, and whether it is still in bootstrap
54
+ * mode. In bootstrap mode JMAP accepts only the `Bootstrap` object, so a
55
+ * `SystemSettings/get` is refused; that refusal is the signal.
56
+ */
57
+ probe(): Promise<'down' | 'bootstrap' | 'ready'>;
58
+ waitFor(state: 'bootstrap' | 'ready', timeoutMs?: number): Promise<void>;
59
+ get<T extends {
60
+ id: string;
61
+ }>(object: string, ids: string[]): Promise<T[]>;
62
+ getSingleton<T>(object: string): Promise<T>;
63
+ /** Every object of a type. Filters are matched client-side by the caller. */
64
+ list<T extends {
65
+ id: string;
66
+ }>(object: string): Promise<T[]>;
67
+ create(object: string, value: Record<string, unknown>): Promise<string>;
68
+ update(object: string, id: string, patch: Record<string, unknown>): Promise<void>;
69
+ /**
70
+ * Mint an API key for the authenticated account through the self-service
71
+ * API, and return its secret, which is in the response and nowhere else.
72
+ *
73
+ * **`Inherit`, not a narrower set, and that is a finding rather than a
74
+ * shortcut.** `permissions` does take a `{'@type':'Replace', permissions:
75
+ * {…}}` form, and an empty one really does restrict — such a key is
76
+ * `forbidden` on every management method. But of some sixty candidate
77
+ * permission names tried against 0.16.20 on 2026-09-07, only
78
+ * `authenticate` and `impersonate` were accepted, and a key holding both
79
+ * is still `forbidden` on `x:Account/query`, `x:Domain/query`,
80
+ * `x:SystemSettings/get` and `x:ApiKey/query`. There is no name in this
81
+ * API's vocabulary that grants management access, so a scoped key cannot
82
+ * manage principals at all. `Inherit` is the only value that works.
83
+ *
84
+ * The key is therefore as powerful as the account it belongs to — the same
85
+ * power as its app password — and what it buys is not least privilege but
86
+ * two other things: it is typed `ApiKey`, which the `/email` section's revoke
87
+ * refuses to touch, and it goes as Bearer, so it carries no account name
88
+ * and cannot be used to sign in to mail as a person.
89
+ */
90
+ createApiKey(description: string): Promise<string>;
91
+ /**
92
+ * Mint an app password for the authenticated account through the
93
+ * self-service API. The secret is in the response and nowhere else. An app
94
+ * password is verified locally whatever directory the domain uses, which
95
+ * is what keeps the bootstrap signed in after the domain moves to OIDC
96
+ * (docs/plans/email/phase-4.md).
97
+ */
98
+ createAppPassword(description: string): Promise<string>;
99
+ /**
100
+ * Find-or-create by a caller-supplied match, then bring the found object
101
+ * to the wanted value. Returns the id either way, which is how later
102
+ * objects reference earlier ones.
103
+ */
104
+ upsert<T extends {
105
+ id: string;
106
+ }>(object: string, matches: (existing: T) => boolean, value: Record<string, unknown>,
107
+ /**
108
+ * What to send when the object exists. Defaults to `value`; callers pass a
109
+ * subset when some of `value` is immutable once created (an `MtaRoute`'s
110
+ * `name`, which the server refuses to see again even unchanged).
111
+ */
112
+ patch?: Record<string, unknown>): Promise<{
113
+ id: string;
114
+ created: boolean;
115
+ }>;
116
+ }
117
+ /**
118
+ * One entry of an Account's `credentials` list as `x:Account/get` returns
119
+ * it: every secret masked as `****`, the id the server assigned. A `List<T>`
120
+ * is a map keyed by index (see the header).
121
+ */
122
+ export interface Credential {
123
+ '@type': 'Password' | 'AppPassword' | 'ApiKey';
124
+ credentialId?: string;
125
+ secret?: string;
126
+ [property: string]: unknown;
127
+ }
package/dist/client.js ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Minimal client for Stalwart's management API, which is JMAP: every
3
+ * configuration object (Domain, AcmeProvider, MtaRoute, SystemSettings, …) is
4
+ * read and written with `x:<Object>/get|query|set` under the
5
+ * `urn:stalwart:jmap` capability. There is no REST surface for these in 0.16;
6
+ * the web admin and `stalwart-cli` speak exactly this.
7
+ *
8
+ * Shapes that differ from the docs and were confirmed on 0.16.20 (see
9
+ * docs/plans/email/phase-1-runbook.md, "What happened"):
10
+ * - a `List<T>` field is a map keyed by index: `{"0": …}`, never an array;
11
+ * - a `Map<T>` / `Set<T>` field is `{"value": true}`;
12
+ * - a `SecretKey` from the environment is
13
+ * `{"@type":"EnvironmentVariable","variableName":"…"}`;
14
+ * - singletons are addressed with the id `singleton`.
15
+ *
16
+ * Authentication is Basic for a password or an app password, and **Bearer
17
+ * for an API key** — confirmed on 0.16.20, 2026-09-07: an `ApiKey` secret
18
+ * presented as Basic is refused with 401 whatever username accompanies it,
19
+ * and the same secret as `Authorization: Bearer` is accepted. That is why
20
+ * `StalwartAuth` is a union rather than a pair of strings. Until
21
+ * `Bootstrap/set` has run, the only account is the recovery admin from
22
+ * `STALWART_RECOVERY_ADMIN`; after it, the administrator it generated.
23
+ */
24
+ const USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
25
+ /** API-key secrets are `API_`-prefixed on 0.16, which is how a caller can tell one from an app password. */
26
+ export const API_KEY_PREFIX = 'API_';
27
+ /** Which scheme a secret wants, from its own shape, for a caller holding one opaque string. */
28
+ export function authFor(secret, user) {
29
+ if (secret.startsWith(API_KEY_PREFIX))
30
+ return { apiKey: secret };
31
+ if (!user) {
32
+ throw new Error('this secret is not an API key, so it is an account credential and needs the account it belongs to');
33
+ }
34
+ return { user, password: secret };
35
+ }
36
+ export class StalwartError extends Error {
37
+ method;
38
+ type;
39
+ constructor(method, type, description) {
40
+ super(`stalwart ${method} → ${type}${description ? `: ${description}` : ''}`);
41
+ this.method = method;
42
+ this.type = type;
43
+ }
44
+ }
45
+ export class StalwartClient {
46
+ base;
47
+ /**
48
+ * The finished `Authorization` header, computed once. A `#private` field,
49
+ * not a TypeScript `private` one: `private` is erased at compile time, so
50
+ * an instance holding the credential as an ordinary property serialises it
51
+ * — `JSON.stringify(client)` would put the secret in whatever logged it.
52
+ * A `#` field is genuinely inaccessible from outside and is skipped by
53
+ * `JSON.stringify`, so the only way to the credential is to send a request
54
+ * with it. The plaintext password is never stored.
55
+ */
56
+ #authorization;
57
+ /** @param baseUrl e.g. `https://email.example.com`, no trailing slash needed. */
58
+ constructor(baseUrl, auth) {
59
+ this.base = baseUrl.replace(/\/+$/, '');
60
+ if ('apiKey' in auth) {
61
+ this.#authorization = `Bearer ${auth.apiKey}`;
62
+ }
63
+ else {
64
+ const basic = Buffer.from(`${auth.user}:${auth.password}`, 'utf8').toString('base64');
65
+ this.#authorization = `Basic ${basic}`;
66
+ }
67
+ }
68
+ headers() {
69
+ return {
70
+ Authorization: this.#authorization,
71
+ 'Content-Type': 'application/json',
72
+ Accept: 'application/json',
73
+ };
74
+ }
75
+ /** One method call; throws `StalwartError` on a JMAP-level error. */
76
+ async call(method, args) {
77
+ const r = await fetch(`${this.base}/jmap`, {
78
+ method: 'POST',
79
+ headers: this.headers(),
80
+ body: JSON.stringify({ using: USING, methodCalls: [[method, args, 'c1']] }),
81
+ });
82
+ if (r.status === 401)
83
+ throw new StalwartError(method, 'unauthorized', 'wrong user or password');
84
+ if (!r.ok)
85
+ throw new StalwartError(method, `http ${r.status}`, (await r.text()).slice(0, 300));
86
+ const json = (await r.json());
87
+ const [name, payload] = json.methodResponses[0] ?? [];
88
+ if (name === 'error') {
89
+ throw new StalwartError(method, String(payload?.type ?? 'unknown'), payload?.description);
90
+ }
91
+ return payload;
92
+ }
93
+ /**
94
+ * Whether the server answers at all, and whether it is still in bootstrap
95
+ * mode. In bootstrap mode JMAP accepts only the `Bootstrap` object, so a
96
+ * `SystemSettings/get` is refused; that refusal is the signal.
97
+ */
98
+ async probe() {
99
+ let r;
100
+ try {
101
+ r = await fetch(`${this.base}/`, { redirect: 'manual' });
102
+ }
103
+ catch {
104
+ return 'down';
105
+ }
106
+ if (r.status >= 500)
107
+ return 'down';
108
+ try {
109
+ await this.call('x:SystemSettings/get', { ids: ['singleton'] });
110
+ return 'ready';
111
+ }
112
+ catch (e) {
113
+ if (e instanceof StalwartError && e.type === 'unauthorized')
114
+ throw e;
115
+ return 'bootstrap';
116
+ }
117
+ }
118
+ async waitFor(state, timeoutMs = 120_000) {
119
+ const started = Date.now();
120
+ while (Date.now() - started < timeoutMs) {
121
+ if ((await this.probe()) === state)
122
+ return;
123
+ await new Promise((res) => setTimeout(res, 5_000));
124
+ }
125
+ throw new Error(`stalwart at ${this.base} did not reach "${state}" within ${timeoutMs / 1000}s`);
126
+ }
127
+ async get(object, ids) {
128
+ const res = await this.call(`x:${object}/get`, { ids });
129
+ return res.list ?? [];
130
+ }
131
+ async getSingleton(object) {
132
+ const [one] = await this.get(object, ['singleton']);
133
+ if (!one)
134
+ throw new StalwartError(`x:${object}/get`, 'notFound', 'singleton');
135
+ return one;
136
+ }
137
+ /** Every object of a type. Filters are matched client-side by the caller. */
138
+ async list(object) {
139
+ const q = await this.call(`x:${object}/query`, {});
140
+ if (!q.ids?.length)
141
+ return [];
142
+ return this.get(object, q.ids);
143
+ }
144
+ async create(object, value) {
145
+ const res = await this.call(`x:${object}/set`, { create: { new: value } });
146
+ const failure = res.notCreated?.new;
147
+ if (failure) {
148
+ throw new StalwartError(`x:${object}/set`, failure.type, `${failure.description ?? ''}${failure.properties ? ` (${failure.properties.join(', ')})` : ''}`);
149
+ }
150
+ const id = res.created?.new?.id;
151
+ if (!id)
152
+ throw new StalwartError(`x:${object}/set`, 'noId', 'create returned no id');
153
+ return id;
154
+ }
155
+ async update(object, id, patch) {
156
+ const res = await this.call(`x:${object}/set`, { update: { [id]: patch } });
157
+ const failure = res.notUpdated?.[id];
158
+ if (failure) {
159
+ throw new StalwartError(`x:${object}/set`, failure.type, `${failure.description ?? ''}${failure.properties ? ` (${failure.properties.join(', ')})` : ''}`);
160
+ }
161
+ }
162
+ /**
163
+ * Mint an API key for the authenticated account through the self-service
164
+ * API, and return its secret, which is in the response and nowhere else.
165
+ *
166
+ * **`Inherit`, not a narrower set, and that is a finding rather than a
167
+ * shortcut.** `permissions` does take a `{'@type':'Replace', permissions:
168
+ * {…}}` form, and an empty one really does restrict — such a key is
169
+ * `forbidden` on every management method. But of some sixty candidate
170
+ * permission names tried against 0.16.20 on 2026-09-07, only
171
+ * `authenticate` and `impersonate` were accepted, and a key holding both
172
+ * is still `forbidden` on `x:Account/query`, `x:Domain/query`,
173
+ * `x:SystemSettings/get` and `x:ApiKey/query`. There is no name in this
174
+ * API's vocabulary that grants management access, so a scoped key cannot
175
+ * manage principals at all. `Inherit` is the only value that works.
176
+ *
177
+ * The key is therefore as powerful as the account it belongs to — the same
178
+ * power as its app password — and what it buys is not least privilege but
179
+ * two other things: it is typed `ApiKey`, which the `/email` section's revoke
180
+ * refuses to touch, and it goes as Bearer, so it carries no account name
181
+ * and cannot be used to sign in to mail as a person.
182
+ */
183
+ async createApiKey(description) {
184
+ const res = await this.call('x:ApiKey/set', {
185
+ create: { new: { description, permissions: { '@type': 'Inherit' } } },
186
+ });
187
+ const failure = res.notCreated?.new;
188
+ if (failure)
189
+ throw new StalwartError('x:ApiKey/set', failure.type, failure.description);
190
+ const secret = res.created?.new?.secret;
191
+ if (!secret)
192
+ throw new StalwartError('x:ApiKey/set', 'noSecret', 'create returned no secret');
193
+ return secret;
194
+ }
195
+ /**
196
+ * Mint an app password for the authenticated account through the
197
+ * self-service API. The secret is in the response and nowhere else. An app
198
+ * password is verified locally whatever directory the domain uses, which
199
+ * is what keeps the bootstrap signed in after the domain moves to OIDC
200
+ * (docs/plans/email/phase-4.md).
201
+ */
202
+ async createAppPassword(description) {
203
+ const res = await this.call('x:AppPassword/set', {
204
+ create: { new: { description, permissions: { '@type': 'Inherit' } } },
205
+ });
206
+ const failure = res.notCreated?.new;
207
+ if (failure) {
208
+ throw new StalwartError('x:AppPassword/set', failure.type, failure.description);
209
+ }
210
+ const secret = res.created?.new?.secret;
211
+ if (!secret)
212
+ throw new StalwartError('x:AppPassword/set', 'noSecret', 'create returned no secret');
213
+ return secret;
214
+ }
215
+ /**
216
+ * Find-or-create by a caller-supplied match, then bring the found object
217
+ * to the wanted value. Returns the id either way, which is how later
218
+ * objects reference earlier ones.
219
+ */
220
+ async upsert(object, matches, value,
221
+ /**
222
+ * What to send when the object exists. Defaults to `value`; callers pass a
223
+ * subset when some of `value` is immutable once created (an `MtaRoute`'s
224
+ * `name`, which the server refuses to see again even unchanged).
225
+ */
226
+ patch = value) {
227
+ const found = (await this.list(object)).find(matches);
228
+ if (found) {
229
+ if (Object.keys(patch).length > 0)
230
+ await this.update(object, found.id, patch);
231
+ return { id: found.id, created: false };
232
+ }
233
+ return { id: await this.create(object, value), created: true };
234
+ }
235
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Administer a Stalwart mail server over its JMAP management API: the
3
+ * domain, the mailboxes, their aliases, their app passwords and their
4
+ * quotas.
5
+ *
6
+ * **Not a JMAP mail client.** This speaks the `urn:stalwart:jmap` management
7
+ * extension — `x:Account`, `x:Domain`, `x:ApiKey` and their neighbours —
8
+ * which is a different surface from RFC 8620 and 8621. Reading and writing
9
+ * messages is `@wtfalch/mail`, and the two are kept apart on purpose: this
10
+ * one runs on a server holding a credential that, on 0.16, cannot be scoped
11
+ * and is as powerful as the account it belongs to, and that must never be
12
+ * one import away from something a browser bundles.
13
+ *
14
+ * The main entry is pure Node so a provisioning script can use it. The
15
+ * components live behind `@wtfalch/postmaster/react`.
16
+ */
17
+ export { API_KEY_PREFIX, StalwartClient, StalwartError, authFor, type Credential, type StalwartAuth, } from './client.js';
18
+ export { type Instance, openInstance } from './instance.js';
19
+ export { loadOverview } from './load.js';
20
+ export { applyObjects, mailObjects, ref, type MailObjectsInput, type PlannedObject, } from './objects.js';
21
+ export { planMailboxes, withPassword } from './mailboxes.js';
22
+ export { overview, pickDomain, type AccountView, type CredentialSummary, type DomainView, type Managed, type Overview, type OverviewInput, type PeopleView, type Person, type WireAccount, type WireAlias, type WireDomain, type WireSystemSettings, } from './overview.js';
23
+ export { MailWriteRefused, newAccount, withAlias, withoutAlias, withoutAppPassword, } from './writes.js';
24
+ export { addAlias, createMailbox, removeAlias, revokeAppPassword, type Applied, } from './apply.js';