@wtfalch/email 0.6.0 → 0.7.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.
@@ -10,14 +10,27 @@ import { useCallback, useEffect, useRef } from 'react';
10
10
  * time is the thing they do not know. */
11
11
  export function shortTime(iso, now = new Date()) {
12
12
  const then = new Date(iso);
13
+ /* Before the elapsed-time branches, not after them, and that ordering is
14
+ the whole of this function's only sharp edge.
15
+
16
+ Elapsed time and calendar day disagree for one hour out of every
17
+ twenty-four. Just after midnight a message sent forty-five minutes ago
18
+ was sent yesterday, so the row said "45m" under a heading that said
19
+ "Yesterday" -- both true, and together they read as a fault. Checking
20
+ elapsed time first is what let that through; the version before this one
21
+ only reached the clock after the hour was up, so the hour where it
22
+ mattered was the hour it never covered.
23
+
24
+ The heading wins, because the heading is the thing the eye has already
25
+ read. A row under "Yesterday" says a time of day, always. */
26
+ if (dayGroup(iso, now) === 'Yesterday') {
27
+ return then.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
28
+ }
13
29
  const minutes = Math.max(0, Math.round((now.getTime() - then.getTime()) / 60_000));
14
30
  if (minutes < 1)
15
31
  return 'now';
16
32
  if (minutes < 60)
17
33
  return `${minutes}m`;
18
- if (dayGroup(iso, now) === 'Yesterday') {
19
- return then.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
20
- }
21
34
  const hours = Math.round(minutes / 60);
22
35
  if (hours < 24)
23
36
  return `${hours}h`;
@@ -1,3 +1,4 @@
1
+ import type { PostmasterAudit } from './audit.js';
1
2
  import type { Instance } from './instance.js';
2
3
  export interface Applied {
3
4
  /** For the audit row's `targetType`. */
@@ -21,19 +22,19 @@ export declare function createMailbox(instance: Instance, input: {
21
22
  apex: string;
22
23
  description?: string | null;
23
24
  external: boolean;
24
- }): Promise<Applied>;
25
+ }, audit?: PostmasterAudit): Promise<Applied>;
25
26
  export declare function addAlias(instance: Instance, input: {
26
27
  accountId: string;
27
28
  name: string;
28
29
  domainId: string;
29
30
  apex: string;
30
- }): Promise<Applied>;
31
+ }, audit?: PostmasterAudit): Promise<Applied>;
31
32
  export declare function removeAlias(instance: Instance, input: {
32
33
  accountId: string;
33
34
  name: string;
34
35
  domainId: string;
35
36
  apex: string;
36
- }): Promise<Applied>;
37
+ }, audit?: PostmasterAudit): Promise<Applied>;
37
38
  /**
38
39
  * Stop one app password working. The secret is never read, here or anywhere
39
40
  * else: the entry is identified by its id and the surviving entries go back
@@ -58,4 +59,4 @@ export declare function revokeAppPassword(instance: Instance, input: {
58
59
  credentialId: string;
59
60
  domainId: string;
60
61
  apex: string;
61
- }): Promise<Applied>;
62
+ }, audit?: PostmasterAudit): Promise<Applied>;
@@ -68,6 +68,21 @@ async function credentialIds(instance, accountId) {
68
68
  .map((c) => c.credentialId)
69
69
  .filter((id) => typeof id === 'string');
70
70
  }
71
+ /** Records an applied write on the caller's audit, when it brought one. */
72
+ async function recorded(audit, action, applied) {
73
+ if (audit) {
74
+ await audit.write({
75
+ action,
76
+ actor: audit.actor,
77
+ target: { type: applied.targetType, id: applied.targetId },
78
+ outcome: 'success',
79
+ before: applied.before,
80
+ after: applied.after,
81
+ request: audit.requestId ? { id: audit.requestId } : undefined,
82
+ });
83
+ }
84
+ return applied;
85
+ }
71
86
  /**
72
87
  * The account as the server has it right now, confined to the domain the
73
88
  * caller resolved. A mismatched domain gets the same answer as a missing
@@ -104,7 +119,7 @@ function isOurOwnAccount(instance, account, apex) {
104
119
  * once would otherwise make two accounts, and the second would be the one
105
120
  * mail stopped arriving at.
106
121
  */
107
- export async function createMailbox(instance, input) {
122
+ export async function createMailbox(instance, input, audit) {
108
123
  // Throws before any read when the request is one this app does not make
109
124
  // (a whole address, an empty name, a domain that keeps its own passwords).
110
125
  const value = newAccount(input);
@@ -115,40 +130,40 @@ export async function createMailbox(instance, input) {
115
130
  throw new MailWriteRefused(`${address} already has a mailbox`);
116
131
  }
117
132
  const id = await instance.client.create('Account', value);
118
- return {
133
+ return recorded(audit, 'mail.mailbox_created', {
119
134
  targetType: 'mailbox',
120
135
  targetId: address,
121
136
  before: null,
122
137
  after: { id, name: local, domainId: input.domainId, description: input.description ?? null },
123
138
  message: `${address} now has a mailbox. They sign in through the issuer and make an app password for their mail client.`,
124
- };
139
+ });
125
140
  }
126
- export async function addAlias(instance, input) {
141
+ export async function addAlias(instance, input, audit) {
127
142
  const account = await readAccount(instance, input.accountId, input.domainId);
128
143
  const aliases = withAlias(account, { name: input.name, domainId: input.domainId });
129
144
  await instance.client.update('Account', account.id, { aliases });
130
145
  const added = Object.values(aliases).at(-1)?.name;
131
146
  const lost = missingSince(Object.values(aliases).map((a) => a.name), await aliasNames(instance, account.id));
132
- return {
147
+ return recorded(audit, 'mail.alias_added', {
133
148
  targetType: 'mail_alias',
134
149
  targetId: `${added}@${input.apex}`,
135
150
  before: null,
136
151
  after: { alias: added, mailbox: `${account.name}@${input.apex}` },
137
152
  message: `${added}@${input.apex} is now delivered to ${account.name}@${input.apex}.${lost}`,
138
- };
153
+ });
139
154
  }
140
- export async function removeAlias(instance, input) {
155
+ export async function removeAlias(instance, input, audit) {
141
156
  const account = await readAccount(instance, input.accountId, input.domainId);
142
157
  const aliases = withoutAlias(account, input.name);
143
158
  await instance.client.update('Account', account.id, { aliases });
144
159
  const lost = missingSince(Object.values(aliases).map((a) => a.name), await aliasNames(instance, account.id));
145
- return {
160
+ return recorded(audit, 'mail.alias_removed', {
146
161
  targetType: 'mail_alias',
147
162
  targetId: `${input.name}@${input.apex}`,
148
163
  before: { alias: input.name, mailbox: `${account.name}@${input.apex}` },
149
164
  after: null,
150
165
  message: `${input.name}@${input.apex} is no longer delivered anywhere. Mail sent to it will be rejected.${lost}`,
151
- };
166
+ });
152
167
  }
153
168
  /**
154
169
  * Stop one app password working. The secret is never read, here or anywhere
@@ -169,7 +184,7 @@ export async function removeAlias(instance, input) {
169
184
  * limits here; its credentials belong to the bootstrap and to this app,
170
185
  * never to a mail client, and the web admin is where they are managed.
171
186
  */
172
- export async function revokeAppPassword(instance, input) {
187
+ export async function revokeAppPassword(instance, input, audit) {
173
188
  const account = await readAccount(instance, input.accountId, input.domainId);
174
189
  if (isOurOwnAccount(instance, account, input.apex)) {
175
190
  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.`);
@@ -181,11 +196,11 @@ export async function revokeAppPassword(instance, input) {
181
196
  const lost = missingSince(Object.values(credentials)
182
197
  .map((c) => c.credentialId)
183
198
  .filter((id) => typeof id === 'string'), await credentialIds(instance, account.id));
184
- return {
199
+ return recorded(audit, 'mail.app_password_revoked', {
185
200
  targetType: 'mail_app_password',
186
201
  targetId: input.credentialId,
187
202
  before: { mailbox: `${account.name}@${input.apex}`, description },
188
203
  after: null,
189
204
  message: `That app password no longer works${description ? ` (${description})` : ''}. Any mail client using it will be asked to sign in again.${lost}`,
190
- };
205
+ });
191
206
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The events the postmaster's four writes record, when a caller hands them
3
+ * an audit. Declared here so a host can merge them into its ledger's
4
+ * vocabulary before it binds a writer to the `mail` namespace; the names sit
5
+ * in that namespace and nowhere else. `tenantVisible` is true throughout:
6
+ * these are an organisation's own facts about its own mail.
7
+ */
8
+ export declare const POSTMASTER_AUDIT_EVENTS: {
9
+ readonly 'mail.mailbox_created': {
10
+ readonly tenantVisible: true;
11
+ };
12
+ readonly 'mail.alias_added': {
13
+ readonly tenantVisible: true;
14
+ };
15
+ readonly 'mail.alias_removed': {
16
+ readonly tenantVisible: true;
17
+ };
18
+ readonly 'mail.app_password_revoked': {
19
+ readonly tenantVisible: true;
20
+ };
21
+ };
22
+ export type PostmasterAuditAction = keyof typeof POSTMASTER_AUDIT_EVENTS;
23
+ /** One write, as the host's writer receives it. `targetId` is an address or a credential id, never a secret. */
24
+ export interface PostmasterAuditEvent {
25
+ readonly action: PostmasterAuditAction;
26
+ readonly actor: {
27
+ readonly id: string;
28
+ readonly display: string;
29
+ };
30
+ readonly target: {
31
+ readonly type: 'mailbox' | 'mail_alias' | 'mail_app_password';
32
+ readonly id: string;
33
+ };
34
+ readonly outcome: 'success';
35
+ readonly before: unknown;
36
+ readonly after: unknown;
37
+ readonly request?: {
38
+ readonly id?: string;
39
+ };
40
+ }
41
+ /**
42
+ * What a caller passes to a write: a writer the host bound to its ledger and
43
+ * to the `mail` namespace, the principal the host resolved for this request,
44
+ * and the request id that joins the row to the host's event log. The write
45
+ * records itself after the mail server has answered, so the row carries what
46
+ * actually happened; a write the server refused throws before this is
47
+ * called, and the caller records that refusal as it sees fit.
48
+ */
49
+ export interface PostmasterAudit {
50
+ readonly write: (event: PostmasterAuditEvent) => Promise<void>;
51
+ readonly actor: {
52
+ readonly id: string;
53
+ readonly display: string;
54
+ };
55
+ readonly requestId?: string;
56
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The events the postmaster's four writes record, when a caller hands them
3
+ * an audit. Declared here so a host can merge them into its ledger's
4
+ * vocabulary before it binds a writer to the `mail` namespace; the names sit
5
+ * in that namespace and nowhere else. `tenantVisible` is true throughout:
6
+ * these are an organisation's own facts about its own mail.
7
+ */
8
+ export const POSTMASTER_AUDIT_EVENTS = {
9
+ 'mail.mailbox_created': { tenantVisible: true },
10
+ 'mail.alias_added': { tenantVisible: true },
11
+ 'mail.alias_removed': { tenantVisible: true },
12
+ 'mail.app_password_revoked': { tenantVisible: true },
13
+ };
@@ -22,3 +22,4 @@ export { planMailboxes, withPassword } from './mailboxes.js';
22
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
23
  export { MailWriteRefused, newAccount, withAlias, withoutAlias, withoutAppPassword, } from './writes.js';
24
24
  export { addAlias, createMailbox, removeAlias, revokeAppPassword, type Applied, } from './apply.js';
25
+ export { POSTMASTER_AUDIT_EVENTS, type PostmasterAudit, type PostmasterAuditAction, type PostmasterAuditEvent, } from './audit.js';
@@ -22,3 +22,4 @@ export { planMailboxes, withPassword } from './mailboxes.js';
22
22
  export { overview, pickDomain, } from './overview.js';
23
23
  export { MailWriteRefused, newAccount, withAlias, withoutAlias, withoutAppPassword, } from './writes.js';
24
24
  export { addAlias, createMailbox, removeAlias, revokeAppPassword, } from './apply.js';
25
+ export { POSTMASTER_AUDIT_EVENTS, } from './audit.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/email",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "The wtfalch estate: reading a mailbox over JMAP, and administering the Stalwart server it lives on. Two entries with no code in common.",
5
5
  "license": "MIT",
6
6
  "type": "module",