@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.
@@ -0,0 +1,112 @@
1
+ import { planMailboxes } from './mailboxes.js';
2
+ function mode(m) {
3
+ if (!m)
4
+ return 'unknown';
5
+ return m['@type'] === 'Automatic' ? 'automatic' : m['@type'] === 'Manual' ? 'manual' : 'unknown';
6
+ }
7
+ const isNumber = (v) => typeof v === 'number' && Number.isFinite(v);
8
+ /** The one number a `quotas` map is expected to carry, whatever key it turns out to use. */
9
+ function quotaLimit(quotas) {
10
+ if (!quotas)
11
+ return null;
12
+ for (const key of ['storage', 'disk', 'diskQuota', 'quota']) {
13
+ if (isNumber(quotas[key]))
14
+ return quotas[key];
15
+ }
16
+ return null;
17
+ }
18
+ /** The instance's domain: the system default, else the one named, else the only one. */
19
+ export function pickDomain(domains, settings, name) {
20
+ if (name)
21
+ return domains.find((d) => d.name === name) ?? null;
22
+ if (settings.defaultDomainId) {
23
+ const d = domains.find((d) => d.id === settings.defaultDomainId);
24
+ if (d)
25
+ return d;
26
+ }
27
+ return domains.length === 1 ? (domains[0] ?? null) : null;
28
+ }
29
+ export function overview(input, domainName) {
30
+ const domain = pickDomain(input.domains, input.settings, domainName);
31
+ if (!domain) {
32
+ const have = input.domains.map((d) => d.name).join(', ') || 'none';
33
+ throw new Error(domainName
34
+ ? `the instance has no domain ${domainName} (have: ${have})`
35
+ : `the instance has ${input.domains.length} domains and no default; set one in the web admin (have: ${have})`);
36
+ }
37
+ const apex = domain.name;
38
+ const mailHost = input.settings.defaultHostname ?? null;
39
+ const mx = input.mx
40
+ ? {
41
+ records: [...input.mx].map((r) => r.toLowerCase()).sort(),
42
+ pointsHere: mailHost !== null && input.mx.some((r) => r.toLowerCase() === mailHost.toLowerCase()),
43
+ }
44
+ : null;
45
+ const accounts = input.accounts
46
+ .filter((a) => a.domainId === domain.id)
47
+ .map((a) => ({
48
+ id: a.id,
49
+ local: a.name,
50
+ address: `${a.name}@${apex}`,
51
+ description: a.description ?? null,
52
+ role: a.roles?.['@type'] ?? 'unknown',
53
+ aliases: Object.values(a.aliases ?? {}).map((al) => ({
54
+ name: al.name,
55
+ enabled: al.enabled !== false,
56
+ })),
57
+ credentials: Object.values(a.credentials ?? {}).map((c) => ({
58
+ type: c['@type'],
59
+ description: typeof c.description === 'string' ? c.description : null,
60
+ credentialId: typeof c.credentialId === 'string' ? c.credentialId : null,
61
+ })),
62
+ quota: {
63
+ used: isNumber(a.usedDiskQuota) ? a.usedDiskQuota : null,
64
+ limit: quotaLimit(a.quotas),
65
+ },
66
+ }))
67
+ .sort((a, b) => a.local.localeCompare(b.local));
68
+ const people = peopleView(input.people, apex, accounts);
69
+ return {
70
+ domain: {
71
+ id: domain.id,
72
+ name: apex,
73
+ directory: domain.directoryId ? 'external' : 'internal',
74
+ certificate: mode(domain.certificateManagement),
75
+ dns: mode(domain.dnsManagement),
76
+ dkim: mode(domain.dkimManagement),
77
+ subAddressing: domain.subAddressing?.['@type'] === 'Enabled',
78
+ mailHost,
79
+ mx,
80
+ },
81
+ accounts,
82
+ people,
83
+ };
84
+ }
85
+ /** The part before the `@`. Typed as possibly absent by `noUncheckedIndexedAccess`; never absent in fact, since a string always splits into at least one piece. */
86
+ function localPartOf(address) {
87
+ return (address.split('@')[0] ?? address).toLowerCase();
88
+ }
89
+ /**
90
+ * The people half, kept separate because it is the half with a refusal in
91
+ * it: an unreadable member list produces `known: false` and no claim about
92
+ * any mailbox.
93
+ */
94
+ function peopleView(people, apex, accounts) {
95
+ if (people === null)
96
+ return { known: false };
97
+ // A person is matched to a mailbox by the local part of their address on
98
+ // this domain, the way `mail:sync` matches the issuer's user names. An
99
+ // address elsewhere says nothing about which mailbox is theirs.
100
+ const onDomain = people.filter((p) => p.email?.toLowerCase().endsWith(`@${apex.toLowerCase()}`));
101
+ const unmatched = people.filter((p) => !onDomain.includes(p));
102
+ const plan = planMailboxes(onDomain.map((p) => ({ userName: p.email })), accounts.map((a) => ({ name: a.local })));
103
+ const byLocal = new Map(onDomain.map((p) => [localPartOf(p.email), p]));
104
+ const pick = (locals) => locals.map((l) => byLocal.get(l)).filter((p) => !!p);
105
+ return {
106
+ known: true,
107
+ present: pick(plan.present),
108
+ missing: pick(plan.create),
109
+ orphaned: plan.orphaned,
110
+ unmatched,
111
+ };
112
+ }
@@ -0,0 +1,18 @@
1
+ import type { WriteOutcome } from './types.js';
2
+ /** One write, with its answer underneath and no way to press it twice. */
3
+ export declare function WriteButton({ run, label, kind, confirm, }: {
4
+ run: () => Promise<WriteOutcome>;
5
+ label: string;
6
+ kind?: 'default' | 'primary' | 'ghost' | 'danger';
7
+ /** A sentence to agree to first. Present means the write is irreversible. */
8
+ confirm?: string;
9
+ }): import("react").JSX.Element;
10
+ /** A name and a button to add it: an alias, or a mailbox for someone. */
11
+ export declare function AddByName({ run, label, placeholder, suffix, ariaLabel, }: {
12
+ run: (name: string) => Promise<WriteOutcome>;
13
+ label: string;
14
+ placeholder: string;
15
+ /** Shown after the field, so it is plain that the domain is not typed. */
16
+ suffix: string;
17
+ ariaLabel: string;
18
+ }): import("react").JSX.Element;
@@ -0,0 +1,71 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, Callout, Input } from '@wtfalch/design';
4
+ import { useState, useTransition } from 'react';
5
+ /**
6
+ * The interactive leaves. Everything else this package draws is a server
7
+ * component; these are here because a control that must say "working" and
8
+ * then say what happened needs state, and only these do.
9
+ *
10
+ * The shape they share: the Server Action arrives as a prop, already bound to
11
+ * whatever it acts on, so this file names no action, holds no identifier and
12
+ * decides nothing. That is what lets the package hold them at all: the
13
+ * actions live in the consuming app, where the gate and the audit log are. It shows a result, keeps a control busy while a write is
14
+ * in flight, and asks twice before something irreversible.
15
+ *
16
+ * The result is deliberately a `Callout` and not a toast. A toast floats and
17
+ * expires, and the rule that comes with one is that anything disappearing on
18
+ * a timer must be safe to have missed. "That app password no longer works"
19
+ * is not, and neither is a refusal the person has to read to correct.
20
+ */
21
+ function Result({ outcome }) {
22
+ if (!outcome)
23
+ return null;
24
+ return (_jsx(Callout, { tone: outcome.ok ? 'good' : 'bad', icon: true, children: outcome.message }));
25
+ }
26
+ /** One write, with its answer underneath and no way to press it twice. */
27
+ export function WriteButton({ run, label, kind = 'default', confirm, }) {
28
+ const [pending, start] = useTransition();
29
+ const [outcome, setOutcome] = useState(null);
30
+ const [asking, setAsking] = useState(false);
31
+ const go = () => {
32
+ // Guarded like `AddByName` below. A second press during the round trip
33
+ // would start a second write from the same page state, and two different
34
+ // writes to one mailbox's list can lose one of them.
35
+ if (pending)
36
+ return;
37
+ setAsking(false);
38
+ start(async () => setOutcome(await run()));
39
+ };
40
+ // The confirmation is a question in the page rather than `window.confirm`:
41
+ // a browser dialog blocks the whole tab, cannot be styled or read out with
42
+ // the rest of the page, and gives no room to say what will actually happen.
43
+ if (asking) {
44
+ return (_jsxs("div", { className: "app-stack-tight", children: [_jsx(Callout, { tone: "warn", icon: true, children: confirm }), _jsxs("div", { className: "app-fields", children: [_jsx(Button, { kind: "danger", size: "sm", busy: pending, onPress: go, children: label }), _jsx(Button, { kind: "ghost", size: "sm", disabled: pending, onPress: () => setAsking(false), children: "Cancel" })] })] }));
45
+ }
46
+ return (_jsxs("div", { className: "app-stack-tight", children: [_jsx(Button, { kind: kind, size: "sm", busy: pending, onPress: () => (confirm ? setAsking(true) : go()), children: label }), _jsx(Result, { outcome: outcome })] }));
47
+ }
48
+ /** A name and a button to add it: an alias, or a mailbox for someone. */
49
+ export function AddByName({ run, label, placeholder, suffix, ariaLabel, }) {
50
+ const [name, setName] = useState('');
51
+ const [pending, start] = useTransition();
52
+ const [outcome, setOutcome] = useState(null);
53
+ const go = () => {
54
+ const value = name.trim();
55
+ if (!value || pending)
56
+ return;
57
+ start(async () => {
58
+ const result = await run(value);
59
+ setOutcome(result);
60
+ // Cleared only on success. A refused name is the one the person has to
61
+ // see to correct, and blanking it makes them retype it to find out what
62
+ // was wrong.
63
+ if (result.ok)
64
+ setName('');
65
+ });
66
+ };
67
+ return (_jsxs("div", { className: "app-stack-tight", children: [_jsxs("div", { className: "app-fields", children: [_jsx(Input, { size: "sm", "aria-label": ariaLabel, value: name, placeholder: placeholder, disabled: pending, onChange: (e) => setName(e.target.value), onKeyDown: (e) => {
68
+ if (e.key === 'Enter')
69
+ go();
70
+ } }), _jsx("span", { className: "app-quiet", children: suffix }), _jsx(Button, { kind: "default", size: "sm", busy: pending, disabled: !name.trim(), onPress: go, children: label })] }), _jsx(Result, { outcome: outcome })] }));
71
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The components that draw a mail instance's administration.
3
+ *
4
+ * Server components except `./controls`, which are the interactive leaves.
5
+ * Nothing here fetches, gates or decides: the consuming app reads the
6
+ * instance with the main entry, asks its own authorisation, binds its own
7
+ * Server Actions and passes the results in. `@wtfalch/design` is a peer
8
+ * dependency, and its stylesheet and the `app-*` layout classes are the
9
+ * consuming app's to load.
10
+ */
11
+ export { DomainCard, Mailboxes, People, bytes, type MailActions } from './panel.js';
12
+ export { AddByName, WriteButton } from './controls.js';
13
+ export type { WriteOutcome } from './types.js';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The components that draw a mail instance's administration.
3
+ *
4
+ * Server components except `./controls`, which are the interactive leaves.
5
+ * Nothing here fetches, gates or decides: the consuming app reads the
6
+ * instance with the main entry, asks its own authorisation, binds its own
7
+ * Server Actions and passes the results in. `@wtfalch/design` is a peer
8
+ * dependency, and its stylesheet and the `app-*` layout classes are the
9
+ * consuming app's to load.
10
+ */
11
+ export { DomainCard, Mailboxes, People, bytes } from './panel.js';
12
+ export { AddByName, WriteButton } from './controls.js';
@@ -0,0 +1,42 @@
1
+ import type { AccountView, DomainView, PeopleView } from '../overview.js';
2
+ import type { WriteOutcome } from './types.js';
3
+ /**
4
+ * What a mail instance's administration looks like, as server components.
5
+ *
6
+ * Every one takes data and, where it can change something, an already-bound
7
+ * action. None of them fetches, gates or decides: the consuming app reads the
8
+ * instance, asks its own authorisation, binds its own Server Actions and
9
+ * hands the results here. That division is the same one `@wtfalch/threads`
10
+ * draws, and it is what keeps a package free of anybody's session.
11
+ */
12
+ /**
13
+ * The writes a panel offers. Omit one and its control is not drawn.
14
+ *
15
+ * **These must be Server Actions, and they are bound rather than wrapped.**
16
+ * React refuses a function passed from a server component to a client one
17
+ * unless it is a Server Action — an inline `async () => act(id)` closure is
18
+ * not one, and the failure is at render rather than at build, which is the
19
+ * worst place to find it. `.bind(null, …)` on a Server Action produces
20
+ * another Server Action with the first arguments fixed, which is why every
21
+ * signature here takes its identifiers positionally and first.
22
+ */
23
+ export interface MailActions {
24
+ readonly addAlias?: (accountId: string, name: string) => Promise<WriteOutcome>;
25
+ readonly removeAlias?: (accountId: string, name: string) => Promise<WriteOutcome>;
26
+ readonly revokeAppPassword?: (accountId: string, credentialId: string) => Promise<WriteOutcome>;
27
+ readonly createMailbox?: (local: string, description: string | null) => Promise<WriteOutcome>;
28
+ }
29
+ export declare function DomainCard({ domain }: {
30
+ domain: DomainView;
31
+ }): import("react").JSX.Element;
32
+ export declare function Mailboxes({ accounts, apex, actions, }: {
33
+ accounts: readonly AccountView[];
34
+ apex: string;
35
+ actions?: MailActions;
36
+ }): import("react").JSX.Element;
37
+ export declare function bytes(n: number): string;
38
+ export declare function People({ people, apex, actions, }: {
39
+ people: PeopleView;
40
+ apex: string;
41
+ actions?: MailActions;
42
+ }): import("react").JSX.Element;
@@ -0,0 +1,104 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Callout, Card, Empty, Pill, Progress, Row, Rows } from '@wtfalch/design';
3
+ import { AddByName, WriteButton } from './controls.js';
4
+ export function DomainCard({ domain }) {
5
+ return (_jsx(Card, { icon: "cloud", title: domain.name, description: domain.mailHost
6
+ ? `Mail for this domain is delivered to ${domain.mailHost}.`
7
+ : 'The instance has no default hostname set.', children: _jsxs("div", { className: "app-stack-tight", children: [_jsxs("div", { className: "app-fields", children: [_jsxs(Pill, { tone: domain.dkim === 'automatic' ? 'good' : 'warn', children: ["DKIM ", domain.dkim === 'automatic' ? 'published automatically' : domain.dkim] }), _jsxs(Pill, { tone: domain.certificate === 'automatic' ? 'good' : 'warn', children: ["Certificate ", domain.certificate] }), _jsxs(Pill, { children: ["DNS ", domain.dns] }), _jsxs(Pill, { children: ["Passwords: ", domain.directory === 'external' ? 'the issuer’s' : 'the instance’s own'] }), domain.subAddressing ? _jsx(Pill, { children: "Sub-addressing on" }) : null] }), _jsx(MxLine, { domain: domain })] }) }));
8
+ }
9
+ /**
10
+ * What the world's resolver says, beside what the instance believes. The apex
11
+ * MX belongs to whoever provisioned the domain and not to the mail server, so
12
+ * a mismatch here is a real thing to see and not something this page can fix.
13
+ */
14
+ function MxLine({ domain }) {
15
+ if (!domain.mx) {
16
+ return _jsx("p", { className: "app-quiet", children: "MX not checked: the resolver did not answer." });
17
+ }
18
+ if (domain.mx.records.length === 0) {
19
+ return (_jsxs(Callout, { tone: "bad", children: [domain.name, " publishes no MX record, so mail for it is not delivered anywhere."] }));
20
+ }
21
+ const list = domain.mx.records.join(', ');
22
+ return domain.mx.pointsHere ? (_jsxs("p", { className: "app-quiet", children: ["MX: ", list] })) : (_jsxs(Callout, { tone: "warn", children: ["MX for ", domain.name, " is ", list, ", which does not include ", domain.mailHost ?? 'this instance', ". Mail is delivered elsewhere."] }));
23
+ }
24
+ export function Mailboxes({ accounts, apex, actions = {}, }) {
25
+ return (_jsx(Card, { icon: "folder", title: "Mailboxes", description: `Every account on ${apex}, with its aliases, the credentials it signs in with, and what it is using.`, children: _jsx(Rows, { label: `Mailboxes on ${apex}`, empty: _jsxs(Empty, { icon: "folder", children: ["No mailboxes on ", apex, " yet."] }), children: accounts.map((account) => (_jsx(Row, { align: "start", name: account.address, pills: _jsx(AccountPills, { account: account }), hint: _jsx(AccountHint, { account: account }), trail: _jsx(Quota, { account: account }), below: _jsx(Manage, { account: account, apex: apex, actions: actions }) }, account.id))) }) }));
26
+ }
27
+ /**
28
+ * What can be changed about one mailbox. Nothing deletes a mailbox: that is
29
+ * somebody's mail, the instance is the system of record, and an irreversible
30
+ * thing that size belongs in the server's own admin.
31
+ */
32
+ function Manage({ account, apex, actions, }) {
33
+ const appPasswords = account.credentials.filter((c) => c.type === 'AppPassword' && c.credentialId !== null);
34
+ const { addAlias, removeAlias, revokeAppPassword } = actions;
35
+ if (!addAlias && !removeAlias && !revokeAppPassword)
36
+ return null;
37
+ return (_jsxs("div", { className: "app-stack-tight", children: [addAlias ? (_jsx(AddByName, { ariaLabel: `New alias for ${account.address}`, label: "Add alias", placeholder: "sales", suffix: `@${apex}`, run: addAlias.bind(null, account.id) })) : null, removeAlias
38
+ ? account.aliases.map((alias) => (_jsx(WriteButton, { kind: "ghost", label: `Remove ${alias.name}@${apex}`, confirm: `Mail sent to ${alias.name}@${apex} will be rejected from then on. Anyone who has that address will need telling.`, run: removeAlias.bind(null, account.id, alias.name) }, alias.name)))
39
+ : null, revokeAppPassword
40
+ ? appPasswords.map((credential) => (_jsx(WriteButton, { kind: "ghost", label: `Revoke ${credential.description ?? 'an app password'}`, confirm: `Whatever is signed in with this app password stops receiving mail until somebody makes a new one on ${account.address}. There is no undo.`, run: revokeAppPassword.bind(null, account.id, credential.credentialId) }, credential.credentialId)))
41
+ : null] }));
42
+ }
43
+ function AccountPills({ account }) {
44
+ return (_jsxs(_Fragment, { children: [account.role !== 'User' ? _jsx(Pill, { inRow: true, children: account.role }) : null, account.aliases.map((alias) => (_jsxs(Pill, { inRow: true, tone: alias.enabled ? undefined : 'warn', children: [alias.name, alias.enabled ? '' : ' (off)'] }, alias.name)))] }));
45
+ }
46
+ /**
47
+ * The credentials, by type and count, and never a secret: the view they come
48
+ * from carries none. An account with no credential at all is the normal shape
49
+ * on a domain whose passwords an issuer holds, and is worth saying rather
50
+ * than leaving blank.
51
+ */
52
+ function AccountHint({ account }) {
53
+ const counts = new Map();
54
+ for (const c of account.credentials)
55
+ counts.set(c.type, (counts.get(c.type) ?? 0) + 1);
56
+ const parts = [...counts].map(([type, n]) => (n === 1 ? type : `${type} ×${n}`));
57
+ return (_jsxs(_Fragment, { children: [account.description ? `${account.description} · ` : '', parts.length > 0 ? parts.join(', ') : 'no credentials of its own'] }));
58
+ }
59
+ /** Bytes as a human size. A null limit is the common case: no quota is set. */
60
+ function Quota({ account }) {
61
+ const { used, limit } = account.quota;
62
+ if (used === null && limit === null)
63
+ return _jsx("span", { className: "app-quiet", children: "\u2014" });
64
+ if (limit === null || limit === 0) {
65
+ return _jsxs("span", { className: "app-quiet", children: [bytes(used ?? 0), " used"] });
66
+ }
67
+ return (_jsx(Progress, { value: used ?? 0, max: limit, label: `${account.address} storage`, detail: `${bytes(used ?? 0)} of ${bytes(limit)}` }));
68
+ }
69
+ export function bytes(n) {
70
+ const units = ['B', 'kB', 'MB', 'GB', 'TB'];
71
+ let value = n;
72
+ let unit = 0;
73
+ while (value >= 1000 && unit < units.length - 1) {
74
+ value /= 1000;
75
+ unit += 1;
76
+ }
77
+ return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
78
+ }
79
+ export function People({ people, apex, actions = {}, }) {
80
+ if (!people.known) {
81
+ return (_jsx(Card, { icon: "chat", title: "People", description: `Who belongs to this organisation, and whether they have a mailbox on ${apex}.`, children: _jsx(Callout, { tone: "warn", icon: true, children: "Your role does not let you read this organisation's member list, so the mailboxes above cannot be matched to people. Nothing here says a mailbox is unused." }) }));
82
+ }
83
+ const rows = [
84
+ ...people.present.map((person) => ({ person, state: 'present' })),
85
+ ...people.missing.map((person) => ({ person, state: 'missing' })),
86
+ ...people.unmatched.map((person) => ({ person, state: 'unmatched' })),
87
+ ];
88
+ const { createMailbox } = actions;
89
+ return (_jsx(Card, { icon: "chat", title: "People", description: `Who belongs to this organisation, and whether they have a mailbox on ${apex}.`, children: _jsxs("div", { className: "app-stack-tight", children: [people.missing.length > 0 ? (_jsxs(Callout, { tone: "warn", children: [people.missing.length === 1
90
+ ? 'One person has no mailbox yet.'
91
+ : `${people.missing.length} people have no mailbox yet.`, ' ', "Mail to an unknown address is rejected, so the account has to exist before the first message arrives."] })) : null, _jsx(Rows, { label: "People in this organisation", empty: _jsx(Empty, { icon: "chat", children: "Nobody belongs to this organisation yet." }), children: rows.map(({ person, state }) => (_jsx(Row, { align: state === 'missing' ? 'start' : 'center', name: person.display, pills: _jsx(PersonPill, { state: state }), hint: person.email ?? 'no address at the issuer', actions: state === 'missing' && createMailbox ? (_jsx(WriteButton, { label: "Create mailbox", run: createMailbox.bind(null, localPart(person), person.email) })) : null }, person.id))) }), people.orphaned.length > 0 ? (_jsxs(Callout, { tone: "info", children: [people.orphaned.map((local) => `${local}@${apex}`).join(', '), ' ', people.orphaned.length === 1 ? 'has a mailbox but no' : 'have mailboxes but no', ' ', "matching person. A mailbox is somebody\u2019s data, so nothing here deletes one; do it in the server\u2019s own admin if that is right."] })) : null] }) }));
92
+ }
93
+ /** The local part of a person's own address, which is how a mailbox is matched to them. */
94
+ function localPart(person) {
95
+ const address = person.email ?? '';
96
+ return address.split('@')[0] ?? address;
97
+ }
98
+ function PersonPill({ state }) {
99
+ if (state === 'present')
100
+ return (_jsx(Pill, { inRow: true, tone: "good", children: "Has a mailbox" }));
101
+ if (state === 'missing')
102
+ return (_jsx(Pill, { inRow: true, tone: "warn", children: "No mailbox" }));
103
+ return _jsx(Pill, { inRow: true, children: "Address elsewhere" });
104
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * What a write answers with. The consuming app's Server Actions return this
3
+ * shape and the controls render it; the package neither performs the write
4
+ * nor decides who may, because both of those belong where the session and
5
+ * the audit log are.
6
+ */
7
+ export interface WriteOutcome {
8
+ readonly ok: boolean;
9
+ readonly message: string;
10
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ import type { Credential } from './client.js';
2
+ import type { WireAccount, WireAlias } from './overview.js';
3
+ export declare class MailWriteRefused extends Error {
4
+ }
5
+ /**
6
+ * The `aliases` list to send so that `name` is also delivered to this
7
+ * account. Refuses a duplicate rather than sending one, because the server
8
+ * would accept two entries with the same name and the second would be
9
+ * unreachable noise.
10
+ *
11
+ * `name` is the local part only: an alias belongs to a domain, and the
12
+ * domain travels as `domainId`. A caller that passes `w@example.com` gets a
13
+ * refusal rather than an alias literally called `w@example.com`.
14
+ */
15
+ export declare function withAlias(account: Pick<WireAccount, 'aliases'>, alias: {
16
+ name: string;
17
+ domainId: string;
18
+ description?: string | null;
19
+ }): Record<string, WireAlias>;
20
+ /**
21
+ * The `aliases` list to send so that `name` is no longer delivered here.
22
+ * Refuses when the alias is absent, so a stale page cannot send the whole
23
+ * list back unchanged and report that it removed something.
24
+ */
25
+ export declare function withoutAlias(account: Pick<WireAccount, 'aliases'>, name: string): Record<string, WireAlias>;
26
+ /**
27
+ * The `credentials` list to send so that one app password stops working and
28
+ * every other credential survives.
29
+ *
30
+ * Two refusals, both of which would otherwise be silent and severe. An entry
31
+ * whose type is not `AppPassword` is not this action's to touch: `Password`
32
+ * is the account's own password and removing it through a "revoke an app
33
+ * password" button would lock the person out of a domain that holds its own
34
+ * passwords; `ApiKey` is a machine's, including possibly this app's
35
+ * own credential (D3), so revoking it here could take the panel offline
36
+ * from inside the panel. And an id that matches nothing is refused rather
37
+ * than sending the list back unchanged and reporting success.
38
+ *
39
+ * Surviving entries keep their masked secrets, which the server reads as
40
+ * "leave this one alone" — the behaviour `withPassword` documents and
41
+ * relies on.
42
+ */
43
+ export declare function withoutAppPassword(account: Pick<WireAccount, 'credentials'>, credentialId: string): Record<string, Credential>;
44
+ /**
45
+ * The `Account` to create for a person, mirroring what
46
+ * `scripts/mail-sync.mts` sends, minus the password.
47
+ *
48
+ * **No credential, ever.** On a domain the issuer holds (`directoryId` set,
49
+ * which is every domain `mail-configure --mail-audience` has touched) the
50
+ * server refuses one anyway and the person signs in through
51
+ * auth.wtfalch.dev. On a domain with the instance's own directory a new
52
+ * account needs a first password, which `mail:sync` prints once to a
53
+ * terminal — a web page is the wrong place to show a secret and a worse
54
+ * place to store one, so this refuses that case and says which tool does it.
55
+ */
56
+ export declare function newAccount(input: {
57
+ local: string;
58
+ domainId: string;
59
+ description?: string | null;
60
+ /** Whether the domain delegates passwords to an external directory. */
61
+ external: boolean;
62
+ }): Record<string, unknown>;
package/dist/writes.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The payloads the write actions send, as pure functions over the wire
3
+ * shapes, so what gets sent to a real mail server is decided in a unit test
4
+ * rather than at the end of a Server Action.
5
+ *
6
+ * The same reasoning as `withPassword` in ./stalwart.ts, and the same rule
7
+ * that governs everything here: **a `List<T>` is a map keyed by index**
8
+ * (`{"0": …}`), never an array, and the server replaces the whole list when
9
+ * one is sent. So adding or removing one entry means sending every entry
10
+ * that survives, which is what makes these functions worth testing — a
11
+ * mistake does not fail, it silently deletes the entries left out.
12
+ *
13
+ * Indices are rebuilt contiguously from zero rather than preserved with a
14
+ * hole. A gap ("0" and "2", no "1") is not a shape the server has been seen
15
+ * to send, so it is not one to send back.
16
+ */
17
+ /** A `List<T>` map, rebuilt contiguously from the entries that survive. */
18
+ function indexed(values) {
19
+ return Object.fromEntries(values.map((v, i) => [String(i), v]));
20
+ }
21
+ /** The entries of a `List<T>`, in index order rather than object order. */
22
+ function entriesOf(list) {
23
+ return Object.entries(list ?? {})
24
+ .sort(([a], [b]) => Number(a) - Number(b))
25
+ .map(([, v]) => v);
26
+ }
27
+ export class MailWriteRefused extends Error {
28
+ }
29
+ /**
30
+ * The `aliases` list to send so that `name` is also delivered to this
31
+ * account. Refuses a duplicate rather than sending one, because the server
32
+ * would accept two entries with the same name and the second would be
33
+ * unreachable noise.
34
+ *
35
+ * `name` is the local part only: an alias belongs to a domain, and the
36
+ * domain travels as `domainId`. A caller that passes `w@example.com` gets a
37
+ * refusal rather than an alias literally called `w@example.com`.
38
+ */
39
+ export function withAlias(account, alias) {
40
+ const name = alias.name.trim().toLowerCase();
41
+ if (!name)
42
+ throw new MailWriteRefused('an alias needs a name');
43
+ if (name.includes('@')) {
44
+ throw new MailWriteRefused(`"${alias.name}" looks like a whole address; an alias is the local part only, and the domain is already known`);
45
+ }
46
+ const existing = entriesOf(account.aliases);
47
+ if (existing.some((a) => a.name.toLowerCase() === name)) {
48
+ throw new MailWriteRefused(`this mailbox already has the alias "${name}"`);
49
+ }
50
+ return indexed([
51
+ ...existing,
52
+ { enabled: true, name, domainId: alias.domainId, description: alias.description ?? null },
53
+ ]);
54
+ }
55
+ /**
56
+ * The `aliases` list to send so that `name` is no longer delivered here.
57
+ * Refuses when the alias is absent, so a stale page cannot send the whole
58
+ * list back unchanged and report that it removed something.
59
+ */
60
+ export function withoutAlias(account, name) {
61
+ const wanted = name.trim().toLowerCase();
62
+ const existing = entriesOf(account.aliases);
63
+ const kept = existing.filter((a) => a.name.toLowerCase() !== wanted);
64
+ if (kept.length === existing.length) {
65
+ throw new MailWriteRefused(`this mailbox has no alias "${name}"`);
66
+ }
67
+ return indexed(kept);
68
+ }
69
+ /**
70
+ * The `credentials` list to send so that one app password stops working and
71
+ * every other credential survives.
72
+ *
73
+ * Two refusals, both of which would otherwise be silent and severe. An entry
74
+ * whose type is not `AppPassword` is not this action's to touch: `Password`
75
+ * is the account's own password and removing it through a "revoke an app
76
+ * password" button would lock the person out of a domain that holds its own
77
+ * passwords; `ApiKey` is a machine's, including possibly this app's
78
+ * own credential (D3), so revoking it here could take the panel offline
79
+ * from inside the panel. And an id that matches nothing is refused rather
80
+ * than sending the list back unchanged and reporting success.
81
+ *
82
+ * Surviving entries keep their masked secrets, which the server reads as
83
+ * "leave this one alone" — the behaviour `withPassword` documents and
84
+ * relies on.
85
+ */
86
+ export function withoutAppPassword(account, credentialId) {
87
+ const existing = entriesOf(account.credentials);
88
+ const target = existing.find((c) => c.credentialId === credentialId);
89
+ if (!target) {
90
+ throw new MailWriteRefused('that credential is not on this mailbox any more');
91
+ }
92
+ if (target['@type'] !== 'AppPassword') {
93
+ throw new MailWriteRefused(`that credential is a ${target['@type']}, not an app password; only app passwords are revoked here`);
94
+ }
95
+ return indexed(existing.filter((c) => c !== target));
96
+ }
97
+ /**
98
+ * The `Account` to create for a person, mirroring what
99
+ * `scripts/mail-sync.mts` sends, minus the password.
100
+ *
101
+ * **No credential, ever.** On a domain the issuer holds (`directoryId` set,
102
+ * which is every domain `mail-configure --mail-audience` has touched) the
103
+ * server refuses one anyway and the person signs in through
104
+ * auth.wtfalch.dev. On a domain with the instance's own directory a new
105
+ * account needs a first password, which `mail:sync` prints once to a
106
+ * terminal — a web page is the wrong place to show a secret and a worse
107
+ * place to store one, so this refuses that case and says which tool does it.
108
+ */
109
+ export function newAccount(input) {
110
+ const local = input.local.trim().toLowerCase();
111
+ if (!local)
112
+ throw new MailWriteRefused('a mailbox needs a name');
113
+ if (local.includes('@')) {
114
+ throw new MailWriteRefused(`"${input.local}" looks like a whole address; a mailbox is named by its local part`);
115
+ }
116
+ if (!input.external) {
117
+ throw new MailWriteRefused('this domain keeps its own passwords, so a new mailbox needs a first password shown once. Create it with `pnpm mail:sync --name <app> --apply`, which prints it to a terminal rather than a web page.');
118
+ }
119
+ return {
120
+ '@type': 'User',
121
+ name: local,
122
+ domainId: input.domainId,
123
+ description: input.description ?? null,
124
+ roles: { '@type': 'User' },
125
+ permissions: { '@type': 'Inherit' },
126
+ encryptionAtRest: { '@type': 'Disabled' },
127
+ quotas: {},
128
+ aliases: {},
129
+ memberGroupIds: {},
130
+ };
131
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@wtfalch/postmaster",
3
+ "version": "0.1.0",
4
+ "description": "Administer a Stalwart mail server: its domain, mailboxes, aliases, app passwords and quotas, over the JMAP management API.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/wtfalch/postmaster.git"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "./react": {
17
+ "types": "./dist/react/index.d.ts",
18
+ "default": "./dist/react/index.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "peerDependencies": {
25
+ "@wtfalch/design": ">=0.4.0",
26
+ "react": "^19.0.0"
27
+ },
28
+ "peerDependenciesMeta": {
29
+ "@wtfalch/design": {
30
+ "optional": true
31
+ },
32
+ "react": {
33
+ "optional": true
34
+ }
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^22.13.1",
38
+ "@types/react": "^19.0.8",
39
+ "@wtfalch/design": "^0.4.0",
40
+ "react": "^19.0.0",
41
+ "typescript": "^5.7.3",
42
+ "vitest": "^3.0.5"
43
+ },
44
+ "scripts": {
45
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
46
+ "typecheck": "tsc --noEmit",
47
+ "test": "vitest run"
48
+ }
49
+ }