@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 +21 -0
- package/dist/apply.d.ts +61 -0
- package/dist/apply.js +191 -0
- package/dist/client.d.ts +127 -0
- package/dist/client.js +235 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +24 -0
- package/dist/instance.d.ts +36 -0
- package/dist/instance.js +20 -0
- package/dist/load.d.ts +11 -0
- package/dist/load.js +33 -0
- package/dist/mailboxes.d.ts +23 -0
- package/dist/mailboxes.js +35 -0
- package/dist/objects.d.ts +47 -0
- package/dist/objects.js +167 -0
- package/dist/overview.d.ts +177 -0
- package/dist/overview.js +112 -0
- package/dist/react/controls.d.ts +18 -0
- package/dist/react/controls.js +71 -0
- package/dist/react/index.d.ts +13 -0
- package/dist/react/index.js +12 -0
- package/dist/react/panel.d.ts +42 -0
- package/dist/react/panel.js +104 -0
- package/dist/react/types.d.ts +10 -0
- package/dist/react/types.js +1 -0
- package/dist/writes.d.ts +62 -0
- package/dist/writes.js +131 -0
- package/package.json +49 -0
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.
|
package/dist/apply.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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';
|