@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/dist/index.js
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, } from './client.js';
|
|
18
|
+
export { openInstance } from './instance.js';
|
|
19
|
+
export { loadOverview } from './load.js';
|
|
20
|
+
export { applyObjects, mailObjects, ref, } from './objects.js';
|
|
21
|
+
export { planMailboxes, withPassword } from './mailboxes.js';
|
|
22
|
+
export { overview, pickDomain, } from './overview.js';
|
|
23
|
+
export { MailWriteRefused, newAccount, withAlias, withoutAlias, withoutAppPassword, } from './writes.js';
|
|
24
|
+
export { addAlias, createMailbox, removeAlias, revokeAppPassword, } from './apply.js';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { StalwartClient } from './client.js';
|
|
2
|
+
/**
|
|
3
|
+
* A mail instance and the credential this program acts on it with.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately not built from environment variables here: what those are
|
|
6
|
+
* called is the consuming app's business, and a package that read
|
|
7
|
+
* `process.env` would decide it for every consumer. The app reads its own
|
|
8
|
+
* names and calls `openInstance`; `@wtfalch/postmaster` only insists that
|
|
9
|
+
* the URL is https, because a Basic credential over http is the credential
|
|
10
|
+
* in clear.
|
|
11
|
+
*/
|
|
12
|
+
export interface Instance {
|
|
13
|
+
readonly url: string;
|
|
14
|
+
/**
|
|
15
|
+
* The account this program acts as, or `null` when an API key acts for
|
|
16
|
+
* itself. An API key authenticates as Bearer and carries no account name;
|
|
17
|
+
* a password or app password is an account credential and has nobody to be
|
|
18
|
+
* without one. `revokeAppPassword` uses this to recognise the account the
|
|
19
|
+
* caller signs in as, so that a program cannot revoke its own credential.
|
|
20
|
+
*/
|
|
21
|
+
readonly user: string | null;
|
|
22
|
+
readonly client: StalwartClient;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* An instance from a URL and one opaque secret, with the scheme chosen from
|
|
26
|
+
* the secret's own shape by `authFor`. Confirmed against Stalwart 0.16.20:
|
|
27
|
+
* an API key presented as Basic is refused with 401 whatever username
|
|
28
|
+
* accompanies it, and an account credential has nothing to authenticate as
|
|
29
|
+
* without its account.
|
|
30
|
+
*/
|
|
31
|
+
export declare function openInstance(input: {
|
|
32
|
+
url: string;
|
|
33
|
+
secret: string;
|
|
34
|
+
/** Required unless the secret is an API key. */
|
|
35
|
+
user?: string;
|
|
36
|
+
}): Instance;
|
package/dist/instance.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { StalwartClient, authFor } from './client.js';
|
|
2
|
+
/**
|
|
3
|
+
* An instance from a URL and one opaque secret, with the scheme chosen from
|
|
4
|
+
* the secret's own shape by `authFor`. Confirmed against Stalwart 0.16.20:
|
|
5
|
+
* an API key presented as Basic is refused with 401 whatever username
|
|
6
|
+
* accompanies it, and an account credential has nothing to authenticate as
|
|
7
|
+
* without its account.
|
|
8
|
+
*/
|
|
9
|
+
export function openInstance(input) {
|
|
10
|
+
const url = input.url.trim().replace(/\/+$/, '');
|
|
11
|
+
if (!/^https:\/\//.test(url)) {
|
|
12
|
+
throw new Error(`a mail instance URL must be https, got "${input.url}"`);
|
|
13
|
+
}
|
|
14
|
+
const auth = authFor(input.secret, input.user);
|
|
15
|
+
return {
|
|
16
|
+
url,
|
|
17
|
+
user: 'apiKey' in auth ? null : auth.user,
|
|
18
|
+
client: new StalwartClient(url, auth),
|
|
19
|
+
};
|
|
20
|
+
}
|
package/dist/load.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Instance } from './instance.js';
|
|
2
|
+
import { type Overview, type Person } from './overview.js';
|
|
3
|
+
/**
|
|
4
|
+
* Fetches what `overview()` reads: every domain and account on the instance,
|
|
5
|
+
* the system settings that say which domain is the instance's own, and the
|
|
6
|
+
* apex's MX records from the resolver this server uses. The MX lookup is
|
|
7
|
+
* the one fact here that does not come from the instance: what the world
|
|
8
|
+
* sees, beside what the instance believes. A resolver failure is not the
|
|
9
|
+
* page's failure; it renders as "not checked".
|
|
10
|
+
*/
|
|
11
|
+
export declare function loadOverview(instance: Instance, people: readonly Person[] | null, domainName?: string): Promise<Overview>;
|
package/dist/load.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { resolveMx } from 'node:dns/promises';
|
|
2
|
+
import { overview, } from './overview.js';
|
|
3
|
+
/**
|
|
4
|
+
* Fetches what `overview()` reads: every domain and account on the instance,
|
|
5
|
+
* the system settings that say which domain is the instance's own, and the
|
|
6
|
+
* apex's MX records from the resolver this server uses. The MX lookup is
|
|
7
|
+
* the one fact here that does not come from the instance: what the world
|
|
8
|
+
* sees, beside what the instance believes. A resolver failure is not the
|
|
9
|
+
* page's failure; it renders as "not checked".
|
|
10
|
+
*/
|
|
11
|
+
export async function loadOverview(instance, people, domainName) {
|
|
12
|
+
const { client } = instance;
|
|
13
|
+
const [domains, accounts, settings] = await Promise.all([
|
|
14
|
+
client.list('Domain'),
|
|
15
|
+
client.list('Account'),
|
|
16
|
+
client.getSingleton('SystemSettings'),
|
|
17
|
+
]);
|
|
18
|
+
const first = overview({ domains, accounts, settings, people, mx: null }, domainName);
|
|
19
|
+
const mx = await lookupMx(first.domain.name);
|
|
20
|
+
return mx ? overview({ domains, accounts, settings, people, mx }, domainName) : first;
|
|
21
|
+
}
|
|
22
|
+
async function lookupMx(apex) {
|
|
23
|
+
try {
|
|
24
|
+
const records = await Promise.race([
|
|
25
|
+
resolveMx(apex),
|
|
26
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3_000)),
|
|
27
|
+
]);
|
|
28
|
+
return records.sort((a, b) => a.priority - b.priority).map((r) => r.exchange);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Credential } from './client.js';
|
|
2
|
+
/** Pure: who should have a mailbox, and who has one that no longer matches a human. */
|
|
3
|
+
export declare function planMailboxes(humans: Array<{
|
|
4
|
+
userName: string;
|
|
5
|
+
email?: string;
|
|
6
|
+
}>, accounts: Array<{
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string | null;
|
|
9
|
+
}>): {
|
|
10
|
+
create: string[];
|
|
11
|
+
present: string[];
|
|
12
|
+
orphaned: string[];
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Pure: the `credentials` list to send so that `secret` becomes the
|
|
16
|
+
* account's password and every other credential (app passwords, API keys)
|
|
17
|
+
* survives. Confirmed against the server's principal mapping on 0.16: an
|
|
18
|
+
* entry sent back with a masked secret keeps the stored one; a Password
|
|
19
|
+
* entry with its `credentialId` and a plain secret is re-hashed; a Password
|
|
20
|
+
* entry without an id is appended, and only one may exist, so an existing
|
|
21
|
+
* one is replaced rather than added to.
|
|
22
|
+
*/
|
|
23
|
+
export declare function withPassword(credentials: Record<string, Credential> | undefined, secret: string): Record<string, Credential>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// ── mailboxes for people ────────────────────────────────────────────────
|
|
2
|
+
/** Pure: who should have a mailbox, and who has one that no longer matches a human. */
|
|
3
|
+
export function planMailboxes(humans, accounts) {
|
|
4
|
+
const wanted = new Set(humans.map((h) => (h.userName.split('@')[0] ?? h.userName).toLowerCase()));
|
|
5
|
+
const have = new Set(accounts.map((a) => a.name.toLowerCase()));
|
|
6
|
+
return {
|
|
7
|
+
create: [...wanted].filter((n) => !have.has(n)).sort(),
|
|
8
|
+
present: [...wanted].filter((n) => have.has(n)).sort(),
|
|
9
|
+
orphaned: [...have].filter((n) => !wanted.has(n)).sort(),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Pure: the `credentials` list to send so that `secret` becomes the
|
|
14
|
+
* account's password and every other credential (app passwords, API keys)
|
|
15
|
+
* survives. Confirmed against the server's principal mapping on 0.16: an
|
|
16
|
+
* entry sent back with a masked secret keeps the stored one; a Password
|
|
17
|
+
* entry with its `credentialId` and a plain secret is re-hashed; a Password
|
|
18
|
+
* entry without an id is appended, and only one may exist, so an existing
|
|
19
|
+
* one is replaced rather than added to.
|
|
20
|
+
*/
|
|
21
|
+
export function withPassword(credentials, secret) {
|
|
22
|
+
const out = { ...(credentials ?? {}) };
|
|
23
|
+
// Found by entry rather than by key, so the existing credential is in hand
|
|
24
|
+
// and the spread below cannot be over a `possibly undefined` lookup. The
|
|
25
|
+
// index signature says every read might miss; here it provably does not.
|
|
26
|
+
const existing = Object.entries(out).find(([, c]) => c['@type'] === 'Password');
|
|
27
|
+
if (existing) {
|
|
28
|
+
const [key, credential] = existing;
|
|
29
|
+
out[key] = { ...credential, secret };
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
const next = Object.keys(out).reduce((n, k) => Math.max(n, Number(k) + 1), 0);
|
|
33
|
+
out[String(next)] = { '@type': 'Password', secret };
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { StalwartClient } from './client.js';
|
|
2
|
+
export interface MailObjectsInput {
|
|
3
|
+
/** The email domain, e.g. `example.com`. */
|
|
4
|
+
apex: string;
|
|
5
|
+
/** The MX target and TLS name, e.g. `mx.example.com`. */
|
|
6
|
+
mailHost: string;
|
|
7
|
+
/** ACME account contact. */
|
|
8
|
+
contact: string;
|
|
9
|
+
/** Container env variable holding the zone-scoped Cloudflare DNS token. */
|
|
10
|
+
dnsTokenVar: string;
|
|
11
|
+
/** Container env variable holding the Resend API key used as SMTP password. */
|
|
12
|
+
resendTokenVar: string;
|
|
13
|
+
/**
|
|
14
|
+
* The project's identity provider, once the mail applications are
|
|
15
|
+
* registered there (docs/plans/email.md E3, phase-4.md slice 1): tokens
|
|
16
|
+
* from `issuerUrl` for `audience` (the ZITADEL project id) sign people in,
|
|
17
|
+
* on the domain and as the server's default. Absent, the domain keeps the
|
|
18
|
+
* internal directory and people have passwords.
|
|
19
|
+
*/
|
|
20
|
+
oidc?: {
|
|
21
|
+
issuerUrl: string;
|
|
22
|
+
audience: string;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** A value that names an object created earlier in the same plan. */
|
|
26
|
+
export declare const ref: (key: string) => {
|
|
27
|
+
$ref: string;
|
|
28
|
+
};
|
|
29
|
+
export interface PlannedObject {
|
|
30
|
+
key: string;
|
|
31
|
+
object: string;
|
|
32
|
+
/** Property values that identify an existing object of this type. */
|
|
33
|
+
match: Record<string, unknown>;
|
|
34
|
+
value: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The full configuration of an instance as data, in dependency order. Pure,
|
|
38
|
+
* so it is unit-tested; `applyObjects` resolves the `$ref`s against the
|
|
39
|
+
* server. What each object is for is in docs/plans/email.md (E3 to E5).
|
|
40
|
+
*/
|
|
41
|
+
export declare function mailObjects(i: MailObjectsInput): PlannedObject[];
|
|
42
|
+
/**
|
|
43
|
+
* Apply a plan: singletons are updated, `Action`s are created (they are
|
|
44
|
+
* fire-and-forget), everything else is upserted by its `match` properties.
|
|
45
|
+
* Returns the id of every object, keyed by plan key.
|
|
46
|
+
*/
|
|
47
|
+
export declare function applyObjects(client: StalwartClient, plan: PlannedObject[], log: (line: string) => void): Promise<Record<string, string>>;
|
package/dist/objects.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/** A value that names an object created earlier in the same plan. */
|
|
2
|
+
export const ref = (key) => ({ $ref: key });
|
|
3
|
+
const env = (variableName) => ({ '@type': 'EnvironmentVariable', variableName });
|
|
4
|
+
/**
|
|
5
|
+
* The full configuration of an instance as data, in dependency order. Pure,
|
|
6
|
+
* so it is unit-tested; `applyObjects` resolves the `$ref`s against the
|
|
7
|
+
* server. What each object is for is in docs/plans/email.md (E3 to E5).
|
|
8
|
+
*/
|
|
9
|
+
export function mailObjects(i) {
|
|
10
|
+
const oidcDescription = i.oidc ? `${i.oidc.issuerUrl} (${i.apex})` : undefined;
|
|
11
|
+
const directory = i.oidc
|
|
12
|
+
? [
|
|
13
|
+
{
|
|
14
|
+
key: 'oidc',
|
|
15
|
+
object: 'Directory',
|
|
16
|
+
match: { description: oidcDescription },
|
|
17
|
+
value: {
|
|
18
|
+
'@type': 'Oidc',
|
|
19
|
+
description: oidcDescription,
|
|
20
|
+
issuerUrl: i.oidc.issuerUrl,
|
|
21
|
+
requireAudience: i.oidc.audience,
|
|
22
|
+
requireScopes: { openid: true, email: true },
|
|
23
|
+
claimUsername: 'preferred_username',
|
|
24
|
+
usernameDomain: i.apex,
|
|
25
|
+
claimName: 'name',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
: [];
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
key: 'acme',
|
|
33
|
+
object: 'AcmeProvider',
|
|
34
|
+
match: { challengeType: 'Dns01' },
|
|
35
|
+
value: {
|
|
36
|
+
contact: { [i.contact]: true },
|
|
37
|
+
challengeType: 'Dns01',
|
|
38
|
+
renewBefore: 'R23',
|
|
39
|
+
maxRetries: 10,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
key: 'dns',
|
|
44
|
+
object: 'DnsServer',
|
|
45
|
+
match: { description: `Cloudflare ${i.apex}` },
|
|
46
|
+
value: {
|
|
47
|
+
'@type': 'Cloudflare',
|
|
48
|
+
description: `Cloudflare ${i.apex}`,
|
|
49
|
+
secret: env(i.dnsTokenVar),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
...directory,
|
|
53
|
+
{
|
|
54
|
+
key: 'domain',
|
|
55
|
+
object: 'Domain',
|
|
56
|
+
match: { name: i.apex },
|
|
57
|
+
// publishRecords is DKIM only, deliberately: the default list rewrites
|
|
58
|
+
// the apex SPF, DMARC and MX and adds zone-wide CAA records (phase 1,
|
|
59
|
+
// 2026-09-06). Those records are the bootstrap's to write.
|
|
60
|
+
value: {
|
|
61
|
+
name: i.apex,
|
|
62
|
+
certificateManagement: { '@type': 'Automatic', acmeProviderId: ref('acme') },
|
|
63
|
+
dnsManagement: {
|
|
64
|
+
'@type': 'Automatic',
|
|
65
|
+
dnsServerId: ref('dns'),
|
|
66
|
+
origin: i.apex,
|
|
67
|
+
publishRecords: { dkim: true },
|
|
68
|
+
},
|
|
69
|
+
dkimManagement: { '@type': 'Automatic' },
|
|
70
|
+
subAddressing: { '@type': 'Enabled' },
|
|
71
|
+
// Once set, a re-run without `oidc` leaves it: removing a directory
|
|
72
|
+
// is the operator's decision, not a missing flag's.
|
|
73
|
+
...(i.oidc ? { directoryId: ref('oidc') } : {}),
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
key: 'relay',
|
|
78
|
+
object: 'MtaRoute',
|
|
79
|
+
match: { name: 'resend' },
|
|
80
|
+
value: {
|
|
81
|
+
'@type': 'Relay',
|
|
82
|
+
name: 'resend',
|
|
83
|
+
description: 'Every outbound message relays through Resend (docs/plans/email.md, E4)',
|
|
84
|
+
address: 'smtp.resend.com',
|
|
85
|
+
port: 465,
|
|
86
|
+
protocol: 'smtp',
|
|
87
|
+
implicitTls: true,
|
|
88
|
+
authUsername: 'resend',
|
|
89
|
+
authSecret: env(i.resendTokenVar),
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
key: 'outbound',
|
|
94
|
+
object: 'MtaOutboundStrategy',
|
|
95
|
+
match: { id: 'singleton' },
|
|
96
|
+
value: { route: { else: "'resend'" } },
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
key: 'system',
|
|
100
|
+
object: 'SystemSettings',
|
|
101
|
+
match: { id: 'singleton' },
|
|
102
|
+
value: { defaultHostname: i.mailHost, defaultDomainId: ref('domain') },
|
|
103
|
+
},
|
|
104
|
+
...(i.oidc
|
|
105
|
+
? [
|
|
106
|
+
{
|
|
107
|
+
key: 'auth',
|
|
108
|
+
object: 'Authentication',
|
|
109
|
+
match: { id: 'singleton' },
|
|
110
|
+
value: { directoryId: ref('oidc') },
|
|
111
|
+
},
|
|
112
|
+
]
|
|
113
|
+
: []),
|
|
114
|
+
{
|
|
115
|
+
key: 'reload',
|
|
116
|
+
object: 'Action',
|
|
117
|
+
match: {},
|
|
118
|
+
value: { '@type': 'ReloadSettings' },
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
function resolve(value, ids) {
|
|
123
|
+
if (Array.isArray(value))
|
|
124
|
+
return value.map((v) => resolve(v, ids));
|
|
125
|
+
if (value && typeof value === 'object') {
|
|
126
|
+
const o = value;
|
|
127
|
+
if (typeof o.$ref === 'string') {
|
|
128
|
+
const id = ids[o.$ref];
|
|
129
|
+
if (!id)
|
|
130
|
+
throw new Error(`unresolved reference "${o.$ref}"`);
|
|
131
|
+
return id;
|
|
132
|
+
}
|
|
133
|
+
return Object.fromEntries(Object.entries(o).map(([k, v]) => [k, resolve(v, ids)]));
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Apply a plan: singletons are updated, `Action`s are created (they are
|
|
139
|
+
* fire-and-forget), everything else is upserted by its `match` properties.
|
|
140
|
+
* Returns the id of every object, keyed by plan key.
|
|
141
|
+
*/
|
|
142
|
+
export async function applyObjects(client, plan, log) {
|
|
143
|
+
const ids = {};
|
|
144
|
+
for (const p of plan) {
|
|
145
|
+
const value = resolve(p.value, ids);
|
|
146
|
+
if (p.match.id === 'singleton') {
|
|
147
|
+
await client.update(p.object, 'singleton', value);
|
|
148
|
+
ids[p.key] = 'singleton';
|
|
149
|
+
log(`${p.object}: updated`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (p.object === 'Action') {
|
|
153
|
+
await client.create(p.object, value);
|
|
154
|
+
log(`${p.object}: ${String(value['@type'])}`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const entries = Object.entries(p.match);
|
|
158
|
+
// The match properties identified the object, so they are equal already;
|
|
159
|
+
// they are also where immutable properties live (`name`), and the server
|
|
160
|
+
// refuses an update that mentions one. They stay out of the patch.
|
|
161
|
+
const patch = Object.fromEntries(Object.entries(value).filter(([k]) => !(k in p.match)));
|
|
162
|
+
const { id, created } = await client.upsert(p.object, (existing) => entries.every(([k, v]) => existing[k] === v), value, patch);
|
|
163
|
+
ids[p.key] = id;
|
|
164
|
+
log(`${p.object}: ${created ? 'created' : 'updated'} (${id})`);
|
|
165
|
+
}
|
|
166
|
+
return ids;
|
|
167
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { Credential } from './client.js';
|
|
2
|
+
/**
|
|
3
|
+
* The read-only view of one mail instance: the domain, its mailboxes and the
|
|
4
|
+
* organisation's people beside them. `overview()` is pure over the wire
|
|
5
|
+
* shapes the management API returns and the people the app knows, so it is
|
|
6
|
+
* unit-tested; `loadOverview` in ./load.ts fetches and calls it.
|
|
7
|
+
*
|
|
8
|
+
* Wire shapes are as `x:<Object>/get` returns them on 0.16 (the header of
|
|
9
|
+
* ./stalwart.ts, docs/plans/email/phase-1-runbook.md): a `List<T>` is a map
|
|
10
|
+
* keyed by index, never an array. Only the properties the view reads are
|
|
11
|
+
* typed, and **nothing is passed through**: every field of the view is named
|
|
12
|
+
* and rebuilt here, so a wire property nobody listed cannot reach the page.
|
|
13
|
+
* That is what keeps a credential's secret out of the view whatever shape a
|
|
14
|
+
* future server version gives it, and it is why there is no spread anywhere
|
|
15
|
+
* below — do not add one.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The server sends a good deal more than any of these interfaces declare —
|
|
19
|
+
* a `Domain` carries a multi-kilobyte `dnsZoneFile`, an `Account` carries
|
|
20
|
+
* `emailAddress`, `locale`, `timeZone`, `externalId` and more (confirmed on
|
|
21
|
+
* 0.16.20, 2026-09-07). The index signature admits all of it *and* is the
|
|
22
|
+
* point: the extra fields exist, are typed as `unknown`, and can therefore
|
|
23
|
+
* only reach the view if someone names them explicitly. There is no spread
|
|
24
|
+
* below, so none of them does.
|
|
25
|
+
*/
|
|
26
|
+
export interface WireDomain {
|
|
27
|
+
[property: string]: unknown;
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
directoryId?: string | null;
|
|
31
|
+
certificateManagement?: Managed | null;
|
|
32
|
+
dnsManagement?: (Managed & {
|
|
33
|
+
publishRecords?: Record<string, boolean>;
|
|
34
|
+
}) | null;
|
|
35
|
+
dkimManagement?: Managed | null;
|
|
36
|
+
subAddressing?: Managed | null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* One of the server's `@type`-tagged management blocks. Each carries its own
|
|
40
|
+
* settings beside the tag — an ACME provider id, the DKIM algorithms and
|
|
41
|
+
* selector template, the DNS server id — and the view reads only the tag.
|
|
42
|
+
*/
|
|
43
|
+
export interface Managed {
|
|
44
|
+
[property: string]: unknown;
|
|
45
|
+
'@type': string;
|
|
46
|
+
}
|
|
47
|
+
export interface WireAlias {
|
|
48
|
+
[property: string]: unknown;
|
|
49
|
+
enabled?: boolean;
|
|
50
|
+
name: string;
|
|
51
|
+
domainId?: string;
|
|
52
|
+
description?: string | null;
|
|
53
|
+
}
|
|
54
|
+
export interface WireAccount {
|
|
55
|
+
[property: string]: unknown;
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
domainId: string;
|
|
59
|
+
description?: string | null;
|
|
60
|
+
/** `{'@type': 'Admin'}` for the instance's administrator, `'User'` for a person. */
|
|
61
|
+
roles?: {
|
|
62
|
+
'@type': string;
|
|
63
|
+
} | null;
|
|
64
|
+
credentials?: Record<string, Credential>;
|
|
65
|
+
aliases?: Record<string, WireAlias>;
|
|
66
|
+
/**
|
|
67
|
+
* Present and an object, but every account seen so far has it empty, so
|
|
68
|
+
* the key a *set* quota uses is still unknown — hence `quotaLimit`'s list
|
|
69
|
+
* of candidates and its `null` when none matches. Never guess a number.
|
|
70
|
+
*/
|
|
71
|
+
quotas?: Record<string, unknown> | null;
|
|
72
|
+
/** Bytes. Confirmed real on 0.16.20 (a live account reported 12362). */
|
|
73
|
+
usedDiskQuota?: number | null;
|
|
74
|
+
}
|
|
75
|
+
export interface WireSystemSettings {
|
|
76
|
+
[property: string]: unknown;
|
|
77
|
+
defaultHostname?: string | null;
|
|
78
|
+
defaultDomainId?: string | null;
|
|
79
|
+
}
|
|
80
|
+
/** A person the app knows, from the operator tenant's memberships. */
|
|
81
|
+
export interface Person {
|
|
82
|
+
readonly id: string;
|
|
83
|
+
readonly display: string;
|
|
84
|
+
readonly email: string | null;
|
|
85
|
+
}
|
|
86
|
+
export interface OverviewInput {
|
|
87
|
+
readonly domains: readonly WireDomain[];
|
|
88
|
+
readonly accounts: readonly WireAccount[];
|
|
89
|
+
readonly settings: WireSystemSettings;
|
|
90
|
+
/**
|
|
91
|
+
* The organisation's people, or `null` when the caller could not read them
|
|
92
|
+
* — which is not the same as an organisation with nobody in it, and must
|
|
93
|
+
* not be flattened into one. See `PeopleView`.
|
|
94
|
+
*/
|
|
95
|
+
readonly people: readonly Person[] | null;
|
|
96
|
+
/** MX records the resolver returned for the apex, or null when the lookup was not done. */
|
|
97
|
+
readonly mx?: readonly string[] | null;
|
|
98
|
+
}
|
|
99
|
+
export type Mode = 'automatic' | 'manual' | 'unknown';
|
|
100
|
+
export interface DomainView {
|
|
101
|
+
/** The server's id for the domain, which every write names as `domainId`. */
|
|
102
|
+
readonly id: string;
|
|
103
|
+
readonly name: string;
|
|
104
|
+
/** Where passwords live: the issuer's directory, or the instance's own. */
|
|
105
|
+
readonly directory: 'external' | 'internal';
|
|
106
|
+
readonly certificate: Mode;
|
|
107
|
+
readonly dns: Mode;
|
|
108
|
+
readonly dkim: Mode;
|
|
109
|
+
readonly subAddressing: boolean;
|
|
110
|
+
/** The instance's own hostname, the MX target and TLS name. */
|
|
111
|
+
readonly mailHost: string | null;
|
|
112
|
+
/** The apex's MX records as resolved, and whether the instance is among them. */
|
|
113
|
+
readonly mx: {
|
|
114
|
+
readonly records: readonly string[];
|
|
115
|
+
readonly pointsHere: boolean;
|
|
116
|
+
} | null;
|
|
117
|
+
}
|
|
118
|
+
export interface CredentialSummary {
|
|
119
|
+
readonly type: 'Password' | 'AppPassword' | 'ApiKey' | string;
|
|
120
|
+
readonly description: string | null;
|
|
121
|
+
/**
|
|
122
|
+
* The server's opaque id for this credential, which a revoke names. Not a
|
|
123
|
+
* secret — the secret is masked to `****` on the wire and never leaves
|
|
124
|
+
* `overview()` at all — but the only handle a caller has on one entry of
|
|
125
|
+
* a list whose entries are otherwise told apart by position.
|
|
126
|
+
*/
|
|
127
|
+
readonly credentialId: string | null;
|
|
128
|
+
}
|
|
129
|
+
export interface AccountView {
|
|
130
|
+
readonly id: string;
|
|
131
|
+
readonly local: string;
|
|
132
|
+
readonly address: string;
|
|
133
|
+
readonly description: string | null;
|
|
134
|
+
readonly role: string;
|
|
135
|
+
readonly aliases: readonly {
|
|
136
|
+
name: string;
|
|
137
|
+
enabled: boolean;
|
|
138
|
+
}[];
|
|
139
|
+
/** Secrets never reach this: only the type and the description of each credential. */
|
|
140
|
+
readonly credentials: readonly CredentialSummary[];
|
|
141
|
+
readonly quota: {
|
|
142
|
+
readonly used: number | null;
|
|
143
|
+
readonly limit: number | null;
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Who belongs, set against who has a mailbox — or `known: false`, meaning
|
|
148
|
+
* the caller could not read the member list.
|
|
149
|
+
*
|
|
150
|
+
* The distinction is load-bearing rather than tidy. Every field below is
|
|
151
|
+
* computed by subtracting one set from the other, so an unknown people list
|
|
152
|
+
* silently read as an empty one would put *every* mailbox in `orphaned` and
|
|
153
|
+
* the page would tell someone to go and delete live mail. There is no
|
|
154
|
+
* `orphaned` to report when the people are unknown, so the type does not
|
|
155
|
+
* offer one.
|
|
156
|
+
*/
|
|
157
|
+
export type PeopleView = {
|
|
158
|
+
readonly known: false;
|
|
159
|
+
} | {
|
|
160
|
+
readonly known: true;
|
|
161
|
+
/** People with a mailbox. */
|
|
162
|
+
readonly present: readonly Person[];
|
|
163
|
+
/** People with an address on the domain and no mailbox yet (slice 2 creates these). */
|
|
164
|
+
readonly missing: readonly Person[];
|
|
165
|
+
/** Mailboxes no person matches, by local part. */
|
|
166
|
+
readonly orphaned: readonly string[];
|
|
167
|
+
/** People whose address is not on this domain, so no local part can be matched. */
|
|
168
|
+
readonly unmatched: readonly Person[];
|
|
169
|
+
};
|
|
170
|
+
export interface Overview {
|
|
171
|
+
readonly domain: DomainView;
|
|
172
|
+
readonly accounts: readonly AccountView[];
|
|
173
|
+
readonly people: PeopleView;
|
|
174
|
+
}
|
|
175
|
+
/** The instance's domain: the system default, else the one named, else the only one. */
|
|
176
|
+
export declare function pickDomain(domains: readonly WireDomain[], settings: WireSystemSettings, name?: string): WireDomain | null;
|
|
177
|
+
export declare function overview(input: OverviewInput, domainName?: string): Overview;
|