@wtfalch/email 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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/dist/mailbox/client.d.ts +85 -0
  3. package/dist/mailbox/client.js +201 -0
  4. package/dist/mailbox/drafts.d.ts +52 -0
  5. package/dist/mailbox/drafts.js +134 -0
  6. package/dist/mailbox/errors.d.ts +45 -0
  7. package/dist/mailbox/errors.js +88 -0
  8. package/dist/mailbox/fake/index.d.ts +34 -0
  9. package/dist/mailbox/fake/index.js +85 -0
  10. package/dist/mailbox/fake/mailbox.d.ts +65 -0
  11. package/dist/mailbox/fake/mailbox.js +402 -0
  12. package/dist/mailbox/fake/sample.d.ts +11 -0
  13. package/dist/mailbox/fake/sample.js +85 -0
  14. package/dist/mailbox/identities.d.ts +4 -0
  15. package/dist/mailbox/identities.js +18 -0
  16. package/dist/mailbox/index.d.ts +27 -0
  17. package/dist/mailbox/index.js +17 -0
  18. package/dist/mailbox/mail.css +451 -0
  19. package/dist/mailbox/mailboxes.d.ts +33 -0
  20. package/dist/mailbox/mailboxes.js +107 -0
  21. package/dist/mailbox/push.d.ts +40 -0
  22. package/dist/mailbox/push.js +127 -0
  23. package/dist/mailbox/react/Composer.d.ts +37 -0
  24. package/dist/mailbox/react/Composer.js +64 -0
  25. package/dist/mailbox/react/Mail.d.ts +8 -0
  26. package/dist/mailbox/react/Mail.js +149 -0
  27. package/dist/mailbox/react/MailboxTree.d.ts +14 -0
  28. package/dist/mailbox/react/MailboxTree.js +52 -0
  29. package/dist/mailbox/react/ThreadList.d.ts +37 -0
  30. package/dist/mailbox/react/ThreadList.js +41 -0
  31. package/dist/mailbox/react/ThreadView.d.ts +33 -0
  32. package/dist/mailbox/react/ThreadView.js +80 -0
  33. package/dist/mailbox/react/context.d.ts +11 -0
  34. package/dist/mailbox/react/context.js +28 -0
  35. package/dist/mailbox/react/hooks.d.ts +46 -0
  36. package/dist/mailbox/react/hooks.js +127 -0
  37. package/dist/mailbox/react/index.d.ts +24 -0
  38. package/dist/mailbox/react/index.js +18 -0
  39. package/dist/mailbox/search.d.ts +20 -0
  40. package/dist/mailbox/search.js +18 -0
  41. package/dist/mailbox/submit.d.ts +35 -0
  42. package/dist/mailbox/submit.js +150 -0
  43. package/dist/mailbox/thread.d.ts +61 -0
  44. package/dist/mailbox/thread.js +153 -0
  45. package/dist/mailbox/threads.d.ts +44 -0
  46. package/dist/mailbox/threads.js +156 -0
  47. package/dist/mailbox/types.d.ts +233 -0
  48. package/dist/mailbox/types.js +8 -0
  49. package/dist/mailbox/uri.d.ts +17 -0
  50. package/dist/mailbox/uri.js +26 -0
  51. package/dist/postmaster/apply.d.ts +62 -0
  52. package/dist/postmaster/apply.js +192 -0
  53. package/dist/postmaster/client.d.ts +127 -0
  54. package/dist/postmaster/client.js +235 -0
  55. package/dist/postmaster/index.d.ts +33 -0
  56. package/dist/postmaster/index.js +33 -0
  57. package/dist/postmaster/instance.d.ts +37 -0
  58. package/dist/postmaster/instance.js +21 -0
  59. package/dist/postmaster/load.d.ts +12 -0
  60. package/dist/postmaster/load.js +34 -0
  61. package/dist/postmaster/mailboxes.d.ts +23 -0
  62. package/dist/postmaster/mailboxes.js +35 -0
  63. package/dist/postmaster/objects.d.ts +47 -0
  64. package/dist/postmaster/objects.js +167 -0
  65. package/dist/postmaster/overview.d.ts +177 -0
  66. package/dist/postmaster/overview.js +112 -0
  67. package/dist/postmaster/react/controls.d.ts +18 -0
  68. package/dist/postmaster/react/controls.js +71 -0
  69. package/dist/postmaster/react/index.d.ts +13 -0
  70. package/dist/postmaster/react/index.js +12 -0
  71. package/dist/postmaster/react/panel.d.ts +42 -0
  72. package/dist/postmaster/react/panel.js +104 -0
  73. package/dist/postmaster/react/types.d.ts +10 -0
  74. package/dist/postmaster/react/types.js +1 -0
  75. package/dist/postmaster/writes.d.ts +62 -0
  76. package/dist/postmaster/writes.js +131 -0
  77. package/package.json +91 -0
@@ -0,0 +1,62 @@
1
+ import 'server-only';
2
+ import type { Instance } from './instance.js';
3
+ export interface Applied {
4
+ /** For the audit row's `targetType`. */
5
+ readonly targetType: 'mailbox' | 'mail_alias' | 'mail_app_password';
6
+ /** For its `targetId`: the address or the credential, never a secret. */
7
+ readonly targetId: string;
8
+ readonly before: unknown;
9
+ readonly after: unknown;
10
+ /** What to tell the person who pressed the button. */
11
+ readonly message: string;
12
+ }
13
+ /**
14
+ * Give a person a mailbox. The address is checked against the server rather
15
+ * than against the page: two admins pressing "create" for the same person at
16
+ * once would otherwise make two accounts, and the second would be the one
17
+ * mail stopped arriving at.
18
+ */
19
+ export declare function createMailbox(instance: Instance, input: {
20
+ local: string;
21
+ domainId: string;
22
+ apex: string;
23
+ description?: string | null;
24
+ external: boolean;
25
+ }): Promise<Applied>;
26
+ export declare function addAlias(instance: Instance, input: {
27
+ accountId: string;
28
+ name: string;
29
+ domainId: string;
30
+ apex: string;
31
+ }): Promise<Applied>;
32
+ export declare function removeAlias(instance: Instance, input: {
33
+ accountId: string;
34
+ name: string;
35
+ domainId: string;
36
+ apex: string;
37
+ }): Promise<Applied>;
38
+ /**
39
+ * Stop one app password working. The secret is never read, here or anywhere
40
+ * else: the entry is identified by its id and the surviving entries go back
41
+ * with the masked secrets the server sent, which it reads as "leave these
42
+ * alone".
43
+ *
44
+ * **Never on the account this management app signs in as.** `withoutAppPassword`
45
+ * refuses an `ApiKey`, which is what D3's credential is once `mail-configure`
46
+ * has minted one — but an instance configured with an *app password* on the
47
+ * administrator instead (a laptop, or an instance provisioned before slice 3)
48
+ * holds a credential that guard cannot tell from a person's phone.
49
+ * The administrator is on the default domain, so the page lists it with a
50
+ * revoke button beside every app password it has, one of which is the one
51
+ * serving the request. Pressing it would answer "that app password no
52
+ * longer works" and then fail every subsequent request with `unauthorized`
53
+ * until somebody edits the Coolify environment. So the whole account is off
54
+ * limits here; its credentials belong to the bootstrap and to this app,
55
+ * never to a mail client, and the web admin is where they are managed.
56
+ */
57
+ export declare function revokeAppPassword(instance: Instance, input: {
58
+ accountId: string;
59
+ credentialId: string;
60
+ domainId: string;
61
+ apex: string;
62
+ }): Promise<Applied>;
@@ -0,0 +1,192 @@
1
+ import 'server-only';
2
+ import { MailWriteRefused, newAccount, withAlias, withoutAlias, withoutAppPassword, } from './writes.js';
3
+ /**
4
+ * The four writes `/email` makes, each as one round trip against the mail
5
+ * instance's management API.
6
+ *
7
+ * **Every one re-reads the account first, and none trusts the page.** A
8
+ * `List<T>` write replaces the whole list, so computing the new list from
9
+ * what the browser last rendered would delete anything added since — an
10
+ * alias a colleague added a minute ago, an app password somebody minted on
11
+ * their phone. The page supplies only what to change (an account id, an
12
+ * alias name, a credential id); what it is changed *from* is read here, on
13
+ * this request. That is also why the refusals in `./writes.ts` are worth
14
+ * having: with a fresh read, "this mailbox has no alias x" means the alias
15
+ * is really gone, not that the page was stale.
16
+ *
17
+ * **What that read does not close is a same-instant race, and it cannot be
18
+ * closed here.** Two writes to one mailbox's list that overlap between the
19
+ * read and the write will still lose one, because the second `/set` replaces
20
+ * the list the first wrote. JMAP's answer is `ifInState`, and Stalwart 0.16
21
+ * does not offer it: `x:Account/get` returns `accountId`, `list` and
22
+ * `notFound` with no state token, and `x:Account/set` rejects an `ifInState`
23
+ * argument outright as `notRequest` (checked 2026-09-07). There is nothing
24
+ * to be conditional on.
25
+ *
26
+ * So the write cannot be made safe, and what happens instead is that it is
27
+ * made *loud*: `confirmSurvivors` re-reads after every list write and says so
28
+ * when something the caller never touched has gone. That does not undo the
29
+ * loss — nothing here can — but it turns a silent deletion into a sentence
30
+ * naming what disappeared, which is the difference between finding out now
31
+ * and finding out when somebody's mail stops arriving.
32
+ *
33
+ * **Every account is checked against the domain the caller resolved.** The
34
+ * credential this app holds is the instance administrator's, so it
35
+ * reaches every account on every domain the instance serves — and instance
36
+ * one serves two. The account id arrives from the browser, so without this
37
+ * check a `mail:admin` could name an account the page never showed them,
38
+ * on a domain this app does not manage, and the audit row would name
39
+ * an address on the wrong domain because it is composed from the resolved
40
+ * apex.
41
+ *
42
+ * Each returns the `before`/`after` pair an audit row records, as plain
43
+ * JSON, plus a sentence for the person who pressed the button.
44
+ */
45
+ /**
46
+ * After a list write, whether everything the caller meant to keep is still
47
+ * there. Compared by name rather than by deep equality: the server
48
+ * normalises what it stores, and a false alarm on every write would train
49
+ * the reader to ignore a true one.
50
+ *
51
+ * Returns a sentence to append when something is missing, and `''` when all
52
+ * is well.
53
+ */
54
+ function missingSince(expected, actual) {
55
+ const gone = expected.filter((name) => !actual.includes(name));
56
+ if (gone.length === 0)
57
+ return '';
58
+ 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.`;
59
+ }
60
+ /** The alias names on an account, as the server has them right now. */
61
+ async function aliasNames(instance, accountId) {
62
+ const [fresh] = await instance.client.get('Account', [accountId]);
63
+ return Object.values(fresh?.aliases ?? {}).map((a) => a.name);
64
+ }
65
+ /** The credential ids on an account, as the server has them right now. */
66
+ async function credentialIds(instance, accountId) {
67
+ const [fresh] = await instance.client.get('Account', [accountId]);
68
+ return Object.values(fresh?.credentials ?? {})
69
+ .map((c) => c.credentialId)
70
+ .filter((id) => typeof id === 'string');
71
+ }
72
+ /**
73
+ * The account as the server has it right now, confined to the domain the
74
+ * caller resolved. A mismatched domain gets the same answer as a missing
75
+ * account, deliberately: telling a caller "that exists, but not here" turns
76
+ * the id into an oracle for every mailbox on the instance.
77
+ */
78
+ async function readAccount(instance, accountId, domainId) {
79
+ const [account] = await instance.client.get('Account', [accountId]);
80
+ if (!account || account.domainId !== domainId) {
81
+ throw new MailWriteRefused('that mailbox is not on this domain any more');
82
+ }
83
+ return account;
84
+ }
85
+ /**
86
+ * Whether this account is the one this app itself signs in as.
87
+ *
88
+ * `MAIL_ADMIN_USER` is a whole address and an account's `name` is a local
89
+ * part, so the comparison is made on the address. Used to keep this app
90
+ * from revoking its own credential (see `revokeAppPassword`).
91
+ */
92
+ function isOurOwnAccount(instance, account, apex) {
93
+ // With an API key there is no account to compare: a Bearer credential
94
+ // carries no account name, and `withoutAppPassword`'s refusal to touch an
95
+ // `ApiKey` is what keeps this app from revoking itself. The comparison
96
+ // matters on the app-password path, where the credential is an app
97
+ // password on the administrator and indistinguishable from a person's.
98
+ if (instance.user === null)
99
+ return false;
100
+ return `${account.name}@${apex}`.toLowerCase() === instance.user.toLowerCase();
101
+ }
102
+ /**
103
+ * Give a person a mailbox. The address is checked against the server rather
104
+ * than against the page: two admins pressing "create" for the same person at
105
+ * once would otherwise make two accounts, and the second would be the one
106
+ * mail stopped arriving at.
107
+ */
108
+ export async function createMailbox(instance, input) {
109
+ // Throws before any read when the request is one this app does not make
110
+ // (a whole address, an empty name, a domain that keeps its own passwords).
111
+ const value = newAccount(input);
112
+ const local = value.name;
113
+ const address = `${local}@${input.apex}`;
114
+ const existing = (await instance.client.list('Account')).find((a) => a.domainId === input.domainId && a.name.toLowerCase() === local);
115
+ if (existing) {
116
+ throw new MailWriteRefused(`${address} already has a mailbox`);
117
+ }
118
+ const id = await instance.client.create('Account', value);
119
+ return {
120
+ targetType: 'mailbox',
121
+ targetId: address,
122
+ before: null,
123
+ after: { id, name: local, domainId: input.domainId, description: input.description ?? null },
124
+ message: `${address} now has a mailbox. They sign in through the issuer and make an app password for their mail client.`,
125
+ };
126
+ }
127
+ export async function addAlias(instance, input) {
128
+ const account = await readAccount(instance, input.accountId, input.domainId);
129
+ const aliases = withAlias(account, { name: input.name, domainId: input.domainId });
130
+ await instance.client.update('Account', account.id, { aliases });
131
+ const added = Object.values(aliases).at(-1)?.name;
132
+ const lost = missingSince(Object.values(aliases).map((a) => a.name), await aliasNames(instance, account.id));
133
+ return {
134
+ targetType: 'mail_alias',
135
+ targetId: `${added}@${input.apex}`,
136
+ before: null,
137
+ after: { alias: added, mailbox: `${account.name}@${input.apex}` },
138
+ message: `${added}@${input.apex} is now delivered to ${account.name}@${input.apex}.${lost}`,
139
+ };
140
+ }
141
+ export async function removeAlias(instance, input) {
142
+ const account = await readAccount(instance, input.accountId, input.domainId);
143
+ const aliases = withoutAlias(account, input.name);
144
+ await instance.client.update('Account', account.id, { aliases });
145
+ const lost = missingSince(Object.values(aliases).map((a) => a.name), await aliasNames(instance, account.id));
146
+ return {
147
+ targetType: 'mail_alias',
148
+ targetId: `${input.name}@${input.apex}`,
149
+ before: { alias: input.name, mailbox: `${account.name}@${input.apex}` },
150
+ after: null,
151
+ message: `${input.name}@${input.apex} is no longer delivered anywhere. Mail sent to it will be rejected.${lost}`,
152
+ };
153
+ }
154
+ /**
155
+ * Stop one app password working. The secret is never read, here or anywhere
156
+ * else: the entry is identified by its id and the surviving entries go back
157
+ * with the masked secrets the server sent, which it reads as "leave these
158
+ * alone".
159
+ *
160
+ * **Never on the account this management app signs in as.** `withoutAppPassword`
161
+ * refuses an `ApiKey`, which is what D3's credential is once `mail-configure`
162
+ * has minted one — but an instance configured with an *app password* on the
163
+ * administrator instead (a laptop, or an instance provisioned before slice 3)
164
+ * holds a credential that guard cannot tell from a person's phone.
165
+ * The administrator is on the default domain, so the page lists it with a
166
+ * revoke button beside every app password it has, one of which is the one
167
+ * serving the request. Pressing it would answer "that app password no
168
+ * longer works" and then fail every subsequent request with `unauthorized`
169
+ * until somebody edits the Coolify environment. So the whole account is off
170
+ * limits here; its credentials belong to the bootstrap and to this app,
171
+ * never to a mail client, and the web admin is where they are managed.
172
+ */
173
+ export async function revokeAppPassword(instance, input) {
174
+ const account = await readAccount(instance, input.accountId, input.domainId);
175
+ if (isOurOwnAccount(instance, account, input.apex)) {
176
+ 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.`);
177
+ }
178
+ const before = Object.values(account.credentials ?? {}).find((c) => c.credentialId === input.credentialId);
179
+ const credentials = withoutAppPassword(account, input.credentialId);
180
+ await instance.client.update('Account', account.id, { credentials });
181
+ const description = typeof before?.description === 'string' ? before.description : null;
182
+ const lost = missingSince(Object.values(credentials)
183
+ .map((c) => c.credentialId)
184
+ .filter((id) => typeof id === 'string'), await credentialIds(instance, account.id));
185
+ return {
186
+ targetType: 'mail_app_password',
187
+ targetId: input.credentialId,
188
+ before: { mailbox: `${account.name}@${input.apex}`, description },
189
+ after: null,
190
+ message: `That app password no longer works${description ? ` (${description})` : ''}. Any mail client using it will be asked to sign in again.${lost}`,
191
+ };
192
+ }
@@ -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
+ }
@@ -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,33 @@
1
+ /**
2
+ * `server-only`, and it is load-bearing rather than decorative. This module
3
+ * reaches a mail server with an administrator credential that, on Stalwart
4
+ * 0.16, cannot be scoped and is as powerful as the whole account. Nothing
5
+ * about living in its own entry stops a client component importing it —
6
+ * bundlers follow imports, not entry names — so the guard that actually
7
+ * fails such a build is this line.
8
+ */
9
+ import 'server-only';
10
+ /**
11
+ * Administer a Stalwart mail server over its JMAP management API: the
12
+ * domain, the mailboxes, their aliases, their app passwords and their
13
+ * quotas.
14
+ *
15
+ * **Not a JMAP mail client.** This speaks the `urn:stalwart:jmap` management
16
+ * extension — `x:Account`, `x:Domain`, `x:ApiKey` and their neighbours —
17
+ * which is a different surface from RFC 8620 and 8621. Reading and writing
18
+ * messages is `@wtfalch/mail`, and the two are kept apart on purpose: this
19
+ * one runs on a server holding a credential that, on 0.16, cannot be scoped
20
+ * and is as powerful as the account it belongs to, and that must never be
21
+ * one import away from something a browser bundles.
22
+ *
23
+ * The main entry is pure Node so a provisioning script can use it. The
24
+ * components live behind `@wtfalch/postmaster/react`.
25
+ */
26
+ export { API_KEY_PREFIX, StalwartClient, StalwartError, authFor, type Credential, type StalwartAuth, } from './client.js';
27
+ export { type Instance, openInstance } from './instance.js';
28
+ export { loadOverview } from './load.js';
29
+ export { applyObjects, mailObjects, ref, type MailObjectsInput, type PlannedObject, } from './objects.js';
30
+ export { planMailboxes, withPassword } from './mailboxes.js';
31
+ 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';
32
+ export { MailWriteRefused, newAccount, withAlias, withoutAlias, withoutAppPassword, } from './writes.js';
33
+ export { addAlias, createMailbox, removeAlias, revokeAppPassword, type Applied, } from './apply.js';