@crombolo/server 0.16.0 → 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/server",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "The crombolo HTTP server: routing, configuration, media stores and background jobs.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -30,17 +30,17 @@
30
30
  "access": "public"
31
31
  },
32
32
  "dependencies": {
33
- "@crombolo/api-delivery": "0.16.0",
34
- "@crombolo/api-management": "0.16.0",
35
- "@crombolo/assistant": "0.16.0",
36
- "@crombolo/auth": "0.16.0",
37
- "@crombolo/backoffice-host": "0.16.0",
38
- "@crombolo/contracts": "0.16.0",
39
- "@crombolo/core": "0.16.0",
40
- "@crombolo/data": "0.16.0",
41
- "@crombolo/render": "0.16.0",
42
- "@crombolo/schema": "0.16.0",
43
- "@crombolo/transfer": "0.16.0",
33
+ "@crombolo/api-delivery": "0.17.0",
34
+ "@crombolo/api-management": "0.17.0",
35
+ "@crombolo/assistant": "0.17.0",
36
+ "@crombolo/auth": "0.17.0",
37
+ "@crombolo/backoffice-host": "0.17.0",
38
+ "@crombolo/contracts": "0.17.0",
39
+ "@crombolo/core": "0.17.0",
40
+ "@crombolo/data": "0.17.0",
41
+ "@crombolo/render": "0.17.0",
42
+ "@crombolo/schema": "0.17.0",
43
+ "@crombolo/transfer": "0.17.0",
44
44
  "@logtape/logtape": "^2.3.8"
45
45
  }
46
46
  }
@@ -75,6 +75,8 @@ export function createDocumentPort(
75
75
  tracker: RedirectTracker = NO_REDIRECT_TRACKING,
76
76
  /** The site's own directory: where `domains.toml` is, when the site keeps one. */
77
77
  siteDir?: string,
78
+ /** Whether the site can send the e-mails a subscription asks for (story 045). */
79
+ notificationsAvailable = true,
78
80
  ): DocumentPort {
79
81
  const repo = new DocumentRepository(db, options)
80
82
  const domains = new DomainRepository(db)
@@ -415,9 +417,14 @@ export function createDocumentPort(
415
417
  async setNotifications(key, principal, actionIds) {
416
418
  const node = await repo.nodes.byKey(key)
417
419
  const userId = await userIdOf(principal)
418
- if (!node || node.objectType !== repo.objectType || userId === undefined) return false
420
+ if (!node || node.objectType !== repo.objectType || userId === undefined) return 'notFound'
421
+ // Clearing is always allowed; asking for something the site cannot send is not.
422
+ if (!notificationsAvailable) {
423
+ const had = new Set(await notifications.subscribed(userId, node.id))
424
+ if (actionIds.some((id) => !had.has(id))) return 'unavailable'
425
+ }
419
426
  await notifications.set(userId, node.id, actionIds)
420
- return true
427
+ return 'saved'
421
428
  },
422
429
 
423
430
  versions: (key) => repo.versions(key),
@@ -119,6 +119,8 @@ export interface UserPortOptions {
119
119
  nodes: NodeLookup
120
120
  /** Signs a user out everywhere, as a password change set by someone else does. */
121
121
  endSessions: (userId: number) => Promise<void>
122
+ /** A group save changed what it may do on these pages (story 045: the "rights" notification). */
123
+ onPagePermissionsChanged?: (principal: Principal, nodeKeys: string[]) => void | Promise<void>
122
124
  }
123
125
 
124
126
  export function createUserPorts(
@@ -661,7 +663,11 @@ export function createUserPorts(
661
663
 
662
664
  return {
663
665
  users: userPort,
664
- groups: createUserGroupPort(db, { adminKey, nodes }),
666
+ groups: createUserGroupPort(db, {
667
+ adminKey,
668
+ nodes,
669
+ onPagePermissionsChanged: options.onPagePermissionsChanged,
670
+ }),
665
671
  userData: createUserDataPort(db),
666
672
  }
667
673
  }
@@ -763,7 +769,11 @@ function fromPermissions(permissions: UserGroupInput['permissions']): GranularPe
763
769
 
764
770
  function createUserGroupPort(
765
771
  db: Db,
766
- deps: { adminKey: () => Promise<string | undefined>; nodes: NodeLookup },
772
+ deps: {
773
+ adminKey: () => Promise<string | undefined>
774
+ nodes: NodeLookup
775
+ onPagePermissionsChanged?: UserPortOptions['onPagePermissionsChanged']
776
+ },
767
777
  ): UserGroupPort {
768
778
  const groups = new UserGroupRepository(db)
769
779
  const users = new UserRepository(db)
@@ -827,6 +837,34 @@ function createUserGroupPort(
827
837
  return 'Success'
828
838
  }
829
839
 
840
+ /** Each page's verbs for the group, by page key. */
841
+ const pageVerbs = (group: { granular: GranularPermission[] } | undefined) => {
842
+ const out = new Map<string, Set<string>>()
843
+ for (const row of group?.granular ?? [])
844
+ if (row.context === 'Document' && row.key) {
845
+ const verbs = out.get(row.key) ?? new Set<string>()
846
+ verbs.add(row.permission)
847
+ out.set(row.key, verbs)
848
+ }
849
+ return out
850
+ }
851
+ /** The pages whose verbs for the group a save changed. */
852
+ const changedPages = (before: Map<string, Set<string>>, after: Map<string, Set<string>>) =>
853
+ [...new Set([...before.keys(), ...after.keys()])].filter((key) => {
854
+ const a = before.get(key) ?? new Set<string>()
855
+ const b = after.get(key) ?? new Set<string>()
856
+ return a.size !== b.size || [...a].some((verb) => !b.has(verb))
857
+ })
858
+ const announcePermissions = async (
859
+ principal: Principal,
860
+ before: Map<string, Set<string>>,
861
+ key: string,
862
+ ) => {
863
+ if (!deps.onPagePermissionsChanged) return
864
+ const changed = changedPages(before, pageVerbs(await groups.byKey(key)))
865
+ if (changed.length > 0) await deps.onPagePermissionsChanged(principal, changed)
866
+ }
867
+
830
868
  const save = async (input: UserGroupInput, key: string): Promise<UserGroupStatus> => {
831
869
  const result = await groups.save({
832
870
  key,
@@ -906,6 +944,7 @@ function createUserGroupPort(
906
944
  if (await groups.byKey(key)) return { status: 'DuplicateAlias' }
907
945
  const saved = await save(input, key)
908
946
  if (saved !== 'Success') return { status: saved }
947
+ await announcePermissions(principal, new Map(), key)
909
948
  // A non-admin who creates a group joins it, as in Umbraco
910
949
  if (!principal.isAdmin) await users.addToGroup(key, [principal.id])
911
950
  return { status: 'Success', key }
@@ -917,7 +956,10 @@ function createUserGroupPort(
917
956
  if (!isMember(principal, group.key)) return 'Forbidden'
918
957
  const valid = await validate(principal, input, group)
919
958
  if (valid !== 'Success') return valid
920
- return save(input, group.key)
959
+ const before = pageVerbs(group)
960
+ const saved = await save(input, group.key)
961
+ if (saved === 'Success') await announcePermissions(principal, before, group.key)
962
+ return saved
921
963
  },
922
964
 
923
965
  async delete(principal, keys) {
package/src/approvals.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * This decides: who may act, what an edit does to a waiting request, when a
8
8
  * stage is done, and when the page is published.
9
9
  */
10
+
10
11
  import type { Principal } from '@crombolo/api-management'
11
12
  import {
12
13
  approvalsNeeded,
@@ -36,6 +37,7 @@ import {
36
37
  type StoredWorkflow,
37
38
  UserGroupRepository,
38
39
  } from '@crombolo/data'
40
+ import type { ContentNotifier } from './content-notifications.ts'
39
41
  import type { EmailPort } from './email.ts'
40
42
 
41
43
  /** What publishing and unpublishing go through when approval says yes. */
@@ -57,6 +59,8 @@ export interface ApprovalsOptions {
57
59
  email?: EmailPort
58
60
  /** Where a link in an e-mail points, e.g. `https://site.example/crombolo`. */
59
61
  backOfficeUrl?: string
62
+ /** Queues the content notifications a publish on approval makes (story 045). */
63
+ notifier?: ContentNotifier
60
64
  }
61
65
 
62
66
  /** A refusal a person reads: why, and what to do instead. */
@@ -426,6 +430,12 @@ export function createApprovals(options: ApprovalsOptions): Approvals {
426
430
  request.action === 'publish' ? 'Published on approval' : 'Unpublished on approval',
427
431
  `through "${request.workflow.name}"`,
428
432
  )
433
+ if (request.action === 'publish')
434
+ await options.notifier?.raise(
435
+ { actions: ['Umb.Document.Publish'], key: request.nodeKey, keyKind: 'document' },
436
+ principal ? { key: principal.id, name: principal.name } : null,
437
+ `approved through "${request.workflow.name}"`,
438
+ )
429
439
  await notify(
430
440
  'editor',
431
441
  request,
package/src/components.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, statSync } from 'node:fs'
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2
2
  import { join, relative, resolve, sep } from 'node:path'
3
3
 
4
4
  /**
@@ -196,6 +196,75 @@ export function componentFileFor(root: string, alias: string): string {
196
196
  return join(root, `${alias}.tsx`)
197
197
  }
198
198
 
199
+ /** What an e-mail template looks like: a default export over `EmailProps`. */
200
+ export function emailScaffold(alias: string): string {
201
+ const name = componentName(alias)
202
+ return `import type { EmailProps } from '@crombolo/crombolo'
203
+
204
+ export default function ${name}({ form, fields, site }: EmailProps) {
205
+ return (
206
+ <html lang="en">
207
+ <body>
208
+ <h1>{form.name}</h1>
209
+ <table>
210
+ {fields.map((field) => (
211
+ <tr>
212
+ <th>{field.caption}</th>
213
+ <td>
214
+ {field.leftOut
215
+ ? 'Not included'
216
+ : field.answered
217
+ ? field.values.join(', ')
218
+ : 'Not answered'}
219
+ </td>
220
+ </tr>
221
+ ))}
222
+ </table>
223
+ <p>
224
+ Sent from <a href={site.url}>{site.name}</a>
225
+ </p>
226
+ </body>
227
+ </html>
228
+ )
229
+ }
230
+ `
231
+ }
232
+
233
+ /**
234
+ * The components the forms designer offers as e-mail designs: those written
235
+ * over `EmailProps`, and any a form already names, so a picker never loses the
236
+ * one that is set. A layout takes `EmailLayoutProps`, so it is not offered.
237
+ */
238
+ export function listEmailTemplates(
239
+ root: string,
240
+ named: Iterable<string> = [],
241
+ ): { alias: string; name: string }[] {
242
+ const components = listComponents(root)
243
+ const found = new Set<string>()
244
+ for (const component of components)
245
+ if (/\bEmailProps\b/.test(readFileSync(component.path, 'utf8'))) found.add(component.alias)
246
+ const present = new Set(components.map((component) => component.alias))
247
+ for (const alias of named) if (present.has(alias)) found.add(alias)
248
+ const items = [...found].sort().map((alias) => ({ alias, name: readableName(alias) }))
249
+ // Two folders with the same file name would read alike, so those keep their path.
250
+ const counts = new Map<string, number>()
251
+ for (const item of items) counts.set(item.name, (counts.get(item.name) ?? 0) + 1)
252
+ return items.map((item) =>
253
+ (counts.get(item.name) ?? 0) > 1 ? { ...item, name: `${item.name} (${item.alias})` } : item,
254
+ )
255
+ }
256
+
257
+ /** `emails/contactReceipt` → "Contact receipt". */
258
+ function readableName(alias: string): string {
259
+ const base = alias.split('/').pop() ?? alias
260
+ const words = base
261
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
262
+ .replace(/[-_]+/g, ' ')
263
+ .trim()
264
+ .toLowerCase()
265
+ return words.charAt(0).toUpperCase() + words.slice(1)
266
+ }
267
+
199
268
  /** What a component looks like: a function, taking whatever imports it hands it. */
200
269
  export function componentScaffold(alias: string): string {
201
270
  const name = componentName(alias)
package/src/config.ts CHANGED
@@ -739,4 +739,4 @@ export function describeDatabase(
739
739
  }
740
740
  }
741
741
 
742
- export const VERSION = '0.16.0'
742
+ export const VERSION = '0.17.0'
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Content notifications, delivered (story 045): an editor who asked to hear
3
+ * when a page is published — or moved, sorted, rolled back, and the rest of
4
+ * Umbraco's eleven — gets an e-mail when it is.
5
+ *
6
+ * A change is turned into outbox rows when it happens, one per person,
7
+ * resolved against the subscriptions on the page and the pages above it, and
8
+ * never for the person who made it. The rows wait a minute, so a save and a
9
+ * publish arrive as one e-mail, and a background job on the nodes that run
10
+ * jobs sends them; each person's rows are claimed by one node.
11
+ */
12
+ import type { RaisedContentChange } from '@crombolo/api-management'
13
+ import { hasPathAccess, isValidEmail, ObjectTypes, permissionsForPath } from '@crombolo/core'
14
+ import {
15
+ type ContentNotification,
16
+ ContentNotificationRepository,
17
+ type Db,
18
+ DocumentRepository,
19
+ NOTIFIABLE_ACTIONS,
20
+ NodeRepository,
21
+ NotificationRepository,
22
+ UserRepository,
23
+ } from '@crombolo/data'
24
+ import { createNodeLookup, loadAccess } from './access.ts'
25
+ import type { EmailPort } from './email.ts'
26
+ import { retryAfter } from './form-workflows.ts'
27
+ import { logger } from './logging.ts'
28
+
29
+ const log = logger('notifications')
30
+
31
+ /** How long a change waits for others to the same person, so they arrive as one e-mail. */
32
+ export const DIGEST_DELAY_MS = 60_000
33
+ /** A row a node took and never finished is retried after this long: that node died. */
34
+ export const STALE_AFTER_MS = 15 * 60_000
35
+
36
+ export interface Actor {
37
+ key: string | null
38
+ name: string
39
+ }
40
+
41
+ export interface ContentNotifierOptions {
42
+ /** False when the site cannot send e-mail: nothing is queued, and nothing piles up. */
43
+ canSend: boolean
44
+ delayMs?: number
45
+ }
46
+
47
+ export interface ContentNotifier {
48
+ raise(change: RaisedContentChange, actor: Actor | null, detail?: string): Promise<void>
49
+ }
50
+
51
+ /** The node ids of a path, root first, without the root's own -1 or the recycle bin's. */
52
+ const pathIds = (path: string): number[] =>
53
+ path
54
+ .split(',')
55
+ .map(Number)
56
+ .filter((id) => Number.isFinite(id) && id > 0)
57
+
58
+ export function createContentNotifier(db: Db, options: ContentNotifierOptions): ContentNotifier {
59
+ const nodes = new NodeRepository(db)
60
+ const documents = new DocumentRepository(db)
61
+ const subscriptions = new NotificationRepository(db)
62
+ const outbox = new ContentNotificationRepository(db)
63
+
64
+ const resolve = async (change: RaisedContentChange) => {
65
+ if (change.keyKind === 'version') {
66
+ const version = await documents.atVersion(change.key)
67
+ return version ? nodes.byKey(version.document.key) : undefined
68
+ }
69
+ return nodes.byKey(change.key)
70
+ }
71
+
72
+ return {
73
+ async raise(change, actor, detail) {
74
+ if (!options.canSend) return
75
+ try {
76
+ const node = await resolve(change)
77
+ if (!node || node.objectType !== ObjectTypes.Document) return
78
+ // A trashed page sits under the recycle bin now; its subscribers are
79
+ // the ones where it was.
80
+ let path = pathIds(node.path)
81
+ if (node.trashed) {
82
+ const parentKey = await documents.originalParent(node.key)
83
+ const parent = parentKey ? await nodes.byKey(parentKey) : undefined
84
+ path = [...(parent ? pathIds(parent.path) : []), node.id]
85
+ }
86
+ let summary = detail ?? null
87
+ if (change.withDescendants) {
88
+ const [row] = await db.query<{ n: number }>(
89
+ 'SELECT COUNT(*) AS n FROM node WHERE path LIKE ? AND node_object_type = ? AND trashed = ?',
90
+ [`${node.path},%`, ObjectTypes.Document, db.dialect.boolValue(false)],
91
+ )
92
+ const under = Number(row?.n ?? 0)
93
+ if (under > 0) summary = `and ${under} page${under === 1 ? '' : 's'} under it`
94
+ }
95
+ const actorId = actor?.key ? (await new UserRepository(db).byKey(actor.key))?.id : undefined
96
+ const occurredAt = new Date()
97
+ const runAfter = new Date(occurredAt.getTime() + (options.delayMs ?? DIGEST_DELAY_MS))
98
+ const rows = []
99
+ for (const actionId of change.actions) {
100
+ const userIds = (await subscriptions.subscribersFor(actionId, path)).filter(
101
+ (id) => id !== actorId,
102
+ )
103
+ if (userIds.length === 0) continue
104
+ const keys = await db.query<{ key: string }>(
105
+ `SELECT key FROM user_account WHERE id IN (${userIds.map(() => '?').join(', ')})`,
106
+ userIds,
107
+ )
108
+ for (const { key } of keys)
109
+ rows.push({
110
+ recipientUserKey: String(key),
111
+ actionId,
112
+ nodeKey: node.key,
113
+ nodeName: node.text ?? node.key,
114
+ actorUserKey: actor?.key ?? null,
115
+ actorName: actor?.name ?? null,
116
+ detail: summary,
117
+ occurredAt,
118
+ runAfter,
119
+ })
120
+ }
121
+ await outbox.enqueue(rows)
122
+ } catch (error) {
123
+ // Like the audit log: a notification that cannot be queued never costs the change.
124
+ log.error('A content notification could not be queued: {error}', { error: String(error) })
125
+ }
126
+ },
127
+ }
128
+ }
129
+
130
+ const VERBS: Record<string, string> = {
131
+ 'Umb.Document.Duplicate': 'copied',
132
+ 'Umb.Document.Delete': 'moved to the recycle bin',
133
+ 'Umb.Document.Move': 'moved',
134
+ 'Umb.Document.Create': 'created',
135
+ 'Umb.Document.PublicAccess': 'changed who can see',
136
+ 'Umb.Document.Publish': 'published',
137
+ 'Umb.DocumentRecycleBin.Restore': 'restored',
138
+ 'Umb.Document.Permissions': 'changed the permissions on',
139
+ 'Umb.Document.Rollback': 'rolled back',
140
+ 'Umb.Document.Sort': 'sorted the pages under',
141
+ 'Umb.Document.Update': 'saved',
142
+ }
143
+
144
+ /** What the subscriber asked to hear of, as a reader would say it: "published". */
145
+ export const verbFor = (actionId: string): string => VERBS[actionId] ?? 'changed'
146
+
147
+ export interface NotificationEmailContext {
148
+ siteName: string
149
+ /** The page's address in the back office; undefined when the site's address is not known. */
150
+ editUrl: (nodeKey: string) => string | undefined
151
+ /** The recipient's language. Only English so far; the others fall back to it. */
152
+ language?: string | null
153
+ }
154
+
155
+ const when = (date: Date) =>
156
+ date.toLocaleString('en-GB', {
157
+ hour: '2-digit',
158
+ minute: '2-digit',
159
+ day: 'numeric',
160
+ month: 'short',
161
+ year: 'numeric',
162
+ timeZone: 'UTC',
163
+ })
164
+
165
+ /** One plain-text e-mail for everything due to one person, oldest first. */
166
+ export function notificationEmail(
167
+ recipient: { name: string },
168
+ items: readonly ContentNotification[],
169
+ context: NotificationEmailContext,
170
+ ): { subject: string; text: string } {
171
+ const who = (item: ContentNotification) => item.actorName ?? 'Someone'
172
+ const first = items[0] as ContentNotification
173
+ const subject =
174
+ items.length === 1
175
+ ? `[${context.siteName}] ${who(first)} ${verbFor(first.actionId)} "${first.nodeName}"`
176
+ : `[${context.siteName}] ${items.length} changes to pages you follow`
177
+ const lines = items.map((item) => {
178
+ const link = context.editUrl(item.nodeKey)
179
+ return [
180
+ `${who(item)} ${verbFor(item.actionId)} "${item.nodeName}"${item.detail ? ` (${item.detail})` : ''}, ${when(item.occurredAt)} UTC.`,
181
+ ...(link ? [` ${link}`] : []),
182
+ ].join('\n')
183
+ })
184
+ const verbs = [...new Set(items.map((item) => verbFor(item.actionId)))]
185
+ const text = [
186
+ `Hello ${recipient.name},`,
187
+ '',
188
+ ...lines,
189
+ '',
190
+ `You get this because you asked to hear when pages here are ${verbs.join(', ')}. To change that, open the page's Notifications in the back office.`,
191
+ ].join('\n')
192
+ return { subject, text }
193
+ }
194
+
195
+ export interface ContentNotificationRunReport {
196
+ claimed: number
197
+ sent: number
198
+ skipped: number
199
+ retrying: number
200
+ failed: number
201
+ }
202
+
203
+ export interface ContentNotificationRunner {
204
+ runDue(now?: Date, limit?: number): Promise<ContentNotificationRunReport>
205
+ prune(before: Date): Promise<number>
206
+ }
207
+
208
+ export function createContentNotificationRunner(deps: {
209
+ db: Db
210
+ email: EmailPort | undefined
211
+ siteName: string
212
+ editUrl: (nodeKey: string) => string | undefined
213
+ }): ContentNotificationRunner {
214
+ const { db } = deps
215
+ const outbox = new ContentNotificationRepository(db)
216
+ const users = new UserRepository(db)
217
+ const lookup = createNodeLookup(db)
218
+
219
+ /** Whether the person may still read the page: a change made since may have taken it away. */
220
+ const mayRead = async (userId: number, item: ContentNotification) => {
221
+ const found = (await lookup.documents([item.nodeKey])).get(item.nodeKey)
222
+ if (!found) return false
223
+ const access = await loadAccess(db, userId)
224
+ // Moved to the recycle bin is about where the page was, so the bin does not count against it.
225
+ const trashed = found.trashed && item.actionId !== 'Umb.Document.Delete'
226
+ return (
227
+ hasPathAccess(access.startNodes.document, found.chain, trashed) &&
228
+ permissionsForPath(access.groups, found.chain).has('Umb.Document.Read')
229
+ )
230
+ }
231
+
232
+ return {
233
+ async runDue(now = new Date(), limit = 25) {
234
+ const report: ContentNotificationRunReport = {
235
+ claimed: 0,
236
+ sent: 0,
237
+ skipped: 0,
238
+ retrying: 0,
239
+ failed: 0,
240
+ }
241
+ await outbox.releaseStale(new Date(now.getTime() - STALE_AFTER_MS))
242
+ for (const recipientKey of await outbox.dueRecipients(now, limit)) {
243
+ const items = await outbox.claimFor(recipientKey, now)
244
+ if (items.length === 0) continue
245
+ report.claimed += items.length
246
+ const skip = async (rows: readonly ContentNotification[], reason: string) => {
247
+ await outbox.skip(
248
+ rows.map((row) => row.id),
249
+ reason,
250
+ )
251
+ report.skipped += rows.length
252
+ }
253
+ const user = await users.byKey(recipientKey)
254
+ if (!user) {
255
+ await skip(items, 'The user no longer exists.')
256
+ continue
257
+ }
258
+ if (!user.isApproved) {
259
+ await skip(items, 'The user is disabled.')
260
+ continue
261
+ }
262
+ if (!isValidEmail(user.email)) {
263
+ await skip(items, 'The user has no e-mail address.')
264
+ continue
265
+ }
266
+ const allowed: ContentNotification[] = []
267
+ for (const item of items) {
268
+ if (await mayRead(user.id, item)) allowed.push(item)
269
+ else await skip([item], 'The user can no longer see the page.')
270
+ }
271
+ if (allowed.length === 0) continue
272
+ const ids = allowed.map((item) => item.id)
273
+ if (!deps.email) {
274
+ await outbox.fail(ids, 'This site has no e-mail provider.')
275
+ report.failed += ids.length
276
+ continue
277
+ }
278
+ const message = notificationEmail(user, allowed, {
279
+ siteName: deps.siteName,
280
+ editUrl: deps.editUrl,
281
+ language: user.languageIsoCode,
282
+ })
283
+ const result = await deps.email.send({
284
+ to: [{ email: user.email, name: user.name }],
285
+ subject: message.subject,
286
+ text: message.text,
287
+ })
288
+ if (result.ok) {
289
+ await outbox.succeed(ids)
290
+ report.sent += ids.length
291
+ continue
292
+ }
293
+ const attempts = Math.max(...allowed.map((item) => item.attempts))
294
+ const retryAt = retryAfter(attempts, now)
295
+ await outbox.fail(ids, result.error, retryAt)
296
+ if (retryAt) report.retrying += ids.length
297
+ else report.failed += ids.length
298
+ log.warning('A content notification to {email} was not sent: {error}', {
299
+ email: user.email,
300
+ error: result.error,
301
+ })
302
+ }
303
+ return report
304
+ },
305
+ prune: (before) => outbox.prune(before),
306
+ }
307
+ }
308
+
309
+ /** Every action a person can subscribe to, so a caller can say which one a change was. */
310
+ export const NOTIFICATION_ACTION_IDS: readonly string[] = NOTIFIABLE_ACTIONS.map((a) => a.actionId)
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The plain-text part of an e-mail, derived from its HTML. `docs/forms.md`.
3
+ *
4
+ * A reader whose client shows text, and every spam filter that weighs an HTML
5
+ * part with no text beside it, read this. It is a reading of the markup, not a
6
+ * rendering: block elements become lines, list items "- ", links "text (url)".
7
+ */
8
+ import { decodeEntities } from '@crombolo/data'
9
+
10
+ const PARAGRAPH =
11
+ /<\/?(p|h[1-6]|table|ul|ol|blockquote|pre|hr|section|article|header|footer)\b[^>]*>/gi
12
+ const LINE = /<\/?(div|tr|br|address|dl|dt|dd|figure|figcaption|thead|tbody|tfoot)\b[^>]*\/?>/gi
13
+
14
+ // Stand-ins for the breaks the markup asks for, kept apart from the source's
15
+ // own whitespace, which HTML collapses to a space.
16
+ const BLANK = '\u0001'
17
+ const BREAK = '\u0002'
18
+ const CELL = '\u0003'
19
+ /** A run of breaks with the spaces around them: a blank line if any asked for one. */
20
+ const BREAKS = new RegExp(`[ \u00a0]*[${BLANK}${BREAK}][${BLANK}${BREAK} \u00a0]*`, 'g')
21
+
22
+ const attribute = (tag: string, name: string): string | undefined => {
23
+ const found = new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(tag)
24
+ return found ? (found[2] ?? found[3] ?? found[4]) : undefined
25
+ }
26
+
27
+ /** The text a reader of `html` would see, as lines. */
28
+ export function htmlToText(html: string): string {
29
+ const marked = html
30
+ .replace(/<!--[\s\S]*?-->/g, '')
31
+ .replace(/<(head|style|script|title)\b[^>]*>[\s\S]*?<\/\1>/gi, '')
32
+ .replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi, (_whole, attrs: string, inner: string) => {
33
+ const href = (attribute(attrs, 'href') ?? '').trim()
34
+ if (!href || href.startsWith('#')) return inner
35
+ const address = href.replace(/^mailto:/i, '')
36
+ const label = decodeEntities(inner.replace(/<[^>]*>/g, '')).trim()
37
+ return label && label !== decodeEntities(address) ? `${inner} (${address})` : address
38
+ })
39
+ .replace(/<li\b[^>]*>/gi, `${BREAK}- `)
40
+ .replace(/<\/li>/gi, BREAK)
41
+ .replace(PARAGRAPH, BLANK)
42
+ .replace(LINE, BREAK)
43
+ // A cell is a column: the row stays one line, its cells spaced apart.
44
+ .replace(/<\/t[dh]>/gi, CELL)
45
+ .replace(/<[^>]*>/g, '')
46
+ .replace(/\s+/g, ' ')
47
+
48
+ return decodeEntities(marked)
49
+ .replace(BREAKS, (run) => (run.includes(BLANK) ? '\n\n' : '\n'))
50
+ .split('\n')
51
+ .map((line) =>
52
+ line
53
+ .split(CELL)
54
+ .map((cell) => cell.replace(/[ \u00a0]+/g, ' ').trim())
55
+ .filter(Boolean)
56
+ .join(' '),
57
+ )
58
+ .join('\n')
59
+ .replace(/\n{3,}/g, '\n\n')
60
+ .trim()
61
+ }
package/src/email.ts CHANGED
@@ -218,6 +218,7 @@ export const EMAIL_DEPENDENT_FEATURES = [
218
218
  'Inviting backoffice users',
219
219
  'Password reset by e-mail',
220
220
  "Forms' Send email workflow",
221
+ 'Notifications about page changes',
221
222
  ]
222
223
 
223
224
  export const EMAIL_SETUP_HINT =
package/src/form-files.ts CHANGED
@@ -30,6 +30,8 @@ export interface FormFileOptions {
30
30
  onChanged?: () => Promise<void>
31
31
  /** The field and workflow types the site's wired bundles add. */
32
32
  formTypes?: FormTypes
33
+ /** The components on disk, so a Send email `template` is held to one that exists. */
34
+ componentAliases?: () => ReadonlySet<string>
33
35
  }
34
36
 
35
37
  export type FormWriteResult =
@@ -243,6 +245,7 @@ export async function saveForm(
243
245
  const problems = validateForms([...others, form], {
244
246
  contentTypeAliases: new Set(loaded.set.contentTypes.map((type) => type.alias)),
245
247
  types: options.formTypes,
248
+ componentAliases: options.componentAliases?.(),
246
249
  })
247
250
  if (problems.length > 0) return { ok: false, status: 'invalid', problems }
248
251
 
@@ -14,6 +14,7 @@
14
14
  import {
15
15
  allFormFields,
16
16
  type FormValueToStore,
17
+ formFieldType,
17
18
  type SchemaForm,
18
19
  type SchemaFormWorkflow,
19
20
  } from '@crombolo/core'
@@ -25,7 +26,9 @@ import {
25
26
  type FormWorkflowRun,
26
27
  type NodeSchemaState,
27
28
  } from '@crombolo/data'
29
+ import type { EmailField, EmailProps, EmailRenderResult } from '@crombolo/render'
28
30
  import type { EmailAttachment, EmailPort } from './email.ts'
31
+ import { htmlToText } from './email-text.ts'
29
32
  import type { FormWorkflowTypeDefinition } from './form-types.ts'
30
33
  import { logger } from './logging.ts'
31
34
  import type { MediaFileStore } from './media-files.ts'
@@ -43,8 +46,13 @@ export interface WorkflowContext {
43
46
  entryId: string | null
44
47
  /** Which attempt this is, from 1. */
45
48
  attempt?: number
49
+ /** When the run was queued; now when absent. */
50
+ submittedAt?: Date
46
51
  }
47
52
 
53
+ /** What an e-mail template is rendered from: its props, less the signal the renderer adds. */
54
+ export type EmailTemplateProps = Omit<EmailProps, 'signal'>
55
+
48
56
  /** How long a bundle's workflow may run before its signal aborts. */
49
57
  const BUNDLE_WORKFLOW_TIMEOUT_MS = 60_000
50
58
 
@@ -60,6 +68,10 @@ export interface WorkflowDeps {
60
68
  onContentChanged?: () => void
61
69
  /** The workflow types wired bundles add, by type. */
62
70
  bundleWorkflows?: ReadonlyMap<string, FormWorkflowTypeDefinition>
71
+ /** Renders a `template` component to HTML; absent, a workflow naming one cannot send. */
72
+ renderEmail?: (alias: string, props: EmailTemplateProps) => Promise<EmailRenderResult>
73
+ /** The site's name and address, for an e-mail template. */
74
+ site?: () => Promise<{ name: string; url: string }>
63
75
  }
64
76
 
65
77
  export type WorkflowResult =
@@ -105,20 +117,94 @@ const asList = (value: unknown): string[] =>
105
117
  .filter(Boolean)
106
118
  : []
107
119
 
108
- /** The submission as a reader sees it: caption, then what was entered. */
109
- export function plainSummary(context: WorkflowContext): string {
120
+ /**
121
+ * The submission as a reader sees it: caption, then what was entered. A field
122
+ * in `leftOut` (lower-cased aliases) says it was left out rather than showing
123
+ * its answer.
124
+ */
125
+ export function plainSummary(
126
+ context: WorkflowContext,
127
+ leftOut: ReadonlySet<string> = new Set(),
128
+ ): string {
110
129
  const captions = new Map(
111
130
  allFormFields(context.form).map((field) => [field.alias, field.caption || field.alias]),
112
131
  )
113
132
  const lines = context.values.map(
114
133
  (value) =>
115
134
  `${captions.get(value.fieldAlias) ?? value.fieldAlias}: ${
116
- value.values.length > 0 ? shown(value).join(', ') : '(not answered)'
135
+ leftOut.has(value.fieldAlias.toLowerCase())
136
+ ? '(sensitive, not included)'
137
+ : value.values.length > 0
138
+ ? shown(value).join(', ')
139
+ : '(not answered)'
117
140
  }`,
118
141
  )
119
142
  return [`${context.form.name}`, '', ...lines].join('\n')
120
143
  }
121
144
 
145
+ /**
146
+ * The sensitive fields a workflow leaves out, lower-cased: all of them unless it
147
+ * sets `include-sensitive = true`.
148
+ */
149
+ export function leftOutFields(form: SchemaForm, workflow: SchemaFormWorkflow): Set<string> {
150
+ if (workflow.settings['include-sensitive'] === true) return new Set()
151
+ return new Set(
152
+ allFormFields(form)
153
+ .filter((field) => field.sensitive)
154
+ .map((field) => field.alias.toLowerCase()),
155
+ )
156
+ }
157
+
158
+ /** The values with every left-out field emptied, so substitution shows nothing for it. */
159
+ const withoutLeftOut = (
160
+ values: readonly FormValueToStore[],
161
+ leftOut: ReadonlySet<string>,
162
+ ): FormValueToStore[] =>
163
+ values.map((value) =>
164
+ leftOut.has(value.fieldAlias.toLowerCase())
165
+ ? { fieldAlias: value.fieldAlias, values: [] }
166
+ : value,
167
+ )
168
+
169
+ /** An e-mail template's props: the form, its fields in order, and where it came from. */
170
+ export function emailTemplateProps(
171
+ context: WorkflowContext,
172
+ leftOut: ReadonlySet<string>,
173
+ subject: string,
174
+ site: { name: string; url: string },
175
+ ): EmailTemplateProps {
176
+ const sent = withoutLeftOut(context.values, leftOut)
177
+ const byAlias = new Map(sent.map((value) => [value.fieldAlias.toLowerCase(), value]))
178
+ const fields: EmailField[] = allFormFields(context.form)
179
+ // A heading or a paragraph collects nothing; a bundle's type is kept, since
180
+ // only its own declaration could say it is presentational.
181
+ .filter((field) => formFieldType(field.type)?.presentational !== true)
182
+ .map((field) => {
183
+ const found = byAlias.get(field.alias.toLowerCase())
184
+ const values = found ? shown(found) : []
185
+ return {
186
+ alias: field.alias,
187
+ caption: field.caption || field.alias,
188
+ type: field.type,
189
+ values,
190
+ answered: values.length > 0,
191
+ sensitive: field.sensitive,
192
+ leftOut: leftOut.has(field.alias.toLowerCase()),
193
+ }
194
+ })
195
+ return {
196
+ form: context.form,
197
+ fields,
198
+ value: (alias) => {
199
+ const found = byAlias.get(alias.toLowerCase())
200
+ return found ? shown(found).join(', ') : ''
201
+ },
202
+ entry: { id: context.entryId, submittedAt: context.submittedAt ?? new Date() },
203
+ site,
204
+ subject,
205
+ }
206
+ }
207
+
122
208
  // ------------------------------------------------------------------ send email
123
209
 
124
210
  async function sendEmail(
@@ -136,8 +222,26 @@ async function sendEmail(
136
222
  }
137
223
 
138
224
  const settings = workflow.settings
139
- const to = asList(substitute(asString(settings.to) ?? '', context.values))
225
+ // Sensitive answers stay out of every part of the e-mail unless the workflow
226
+ // asks for them: an inbox is not covered by the sensitive-data permission.
227
+ const leftOut = leftOutFields(context.form, workflow)
228
+ const values = withoutLeftOut(context.values, leftOut)
229
+ const to = asList(substitute(asString(settings.to) ?? '', values))
140
230
  if (to.length === 0) return { ok: false, retry: false, error: 'The workflow has no recipient.' }
231
+ const subject = substitute(asString(settings.subject) ?? context.form.name, values).trim()
232
+
233
+ let html: string | undefined
234
+ let text: string
235
+ const template = asString(settings.template)
236
+ if (template) {
237
+ const rendered = await renderTemplate(template, context, leftOut, subject, deps)
238
+ if (!rendered.ok) return rendered
239
+ html = rendered.html
240
+ text = htmlToText(html)
241
+ } else
242
+ text = settings.body
243
+ ? substitute(String(settings.body), values)
244
+ : plainSummary(context, leftOut)
141
245
 
142
246
  const attachments: EmailAttachment[] = []
143
247
  if (settings['attach-uploads'] === true && deps.mediaFiles) {
@@ -146,7 +250,7 @@ async function sendEmail(
146
250
  .filter((field) => field.type === 'fileUpload')
147
251
  .map((field) => field.alias),
148
252
  )
149
- for (const value of context.values) {
253
+ for (const value of values) {
150
254
  if (!uploadAliases.has(value.fieldAlias)) continue
151
255
  for (const src of value.values) {
152
256
  const file = await deps.mediaFiles.open(src)
@@ -166,21 +270,67 @@ async function sendEmail(
166
270
  bcc: asList(asString(settings.bcc) ?? '').map((email) => ({ email })),
167
271
  replyTo: (() => {
168
272
  const replyTo = asString(settings['reply-to'])
169
- const resolved = replyTo ? substitute(replyTo, context.values) : undefined
273
+ const resolved = replyTo ? substitute(replyTo, values) : undefined
170
274
  return resolved ? { email: resolved } : undefined
171
275
  })(),
172
276
  from: (() => {
173
277
  const from = asString(settings.from)
174
278
  return from ? { email: from } : undefined
175
279
  })(),
176
- subject: substitute(asString(settings.subject) ?? context.form.name, context.values),
177
- text: settings.body ? substitute(String(settings.body), context.values) : plainSummary(context),
280
+ subject,
281
+ text,
282
+ ...(html === undefined ? {} : { html }),
178
283
  attachments: attachments.length > 0 ? attachments : undefined,
179
284
  })
180
285
  // A provider that refused is worth another go: a 500 or a timeout is weather.
181
286
  return result.ok ? { ok: true } : { ok: false, retry: true, error: result.error }
182
287
  }
183
288
 
289
+ /**
290
+ * The HTML of a workflow's e-mail template, or why there is none.
291
+ *
292
+ * Both failures are retried. A template missing now may be one a node has not
293
+ * caught up with after a deploy; one that throws may be reading something that
294
+ * is briefly unavailable. After the last attempt the run is left failed, with
295
+ * the reason a person can act on.
296
+ */
297
+ async function renderTemplate(
298
+ template: string,
299
+ context: WorkflowContext,
300
+ leftOut: ReadonlySet<string>,
301
+ subject: string,
302
+ deps: WorkflowDeps,
303
+ ): Promise<{ ok: true; html: string } | (WorkflowResult & { ok: false })> {
304
+ if (!deps.renderEmail)
305
+ return {
306
+ ok: false,
307
+ retry: false,
308
+ error: `The e-mail template '${template}' cannot be rendered here: this server renders no templates.`,
309
+ }
310
+ try {
311
+ const site = (await deps.site?.()) ?? { name: '', url: '' }
312
+ const rendered = await deps.renderEmail(
313
+ template,
314
+ emailTemplateProps(context, leftOut, subject, site),
315
+ )
316
+ if (rendered.status === 'ok') return { ok: true, html: rendered.html }
317
+ return {
318
+ ok: false,
319
+ retry: true,
320
+ error:
321
+ rendered.status === 'missing'
322
+ ? `There is no e-mail template '${template}': no components/${template}.tsx in the views this node serves.`
323
+ : rendered.message,
324
+ }
325
+ } catch (error) {
326
+ return {
327
+ ok: false,
328
+ retry: true,
329
+ error: `The e-mail template '${template}' failed: ${error instanceof Error ? error.message : String(error)}`,
330
+ }
331
+ }
332
+ }
333
+
184
334
  // ------------------------------------------------------------------ save as content
185
335
 
186
336
  async function saveAsContent(
@@ -404,6 +554,7 @@ export function createWorkflowRunner(
404
554
  values: run.payload,
405
555
  entryId: run.entryId,
406
556
  attempt: run.attempts,
557
+ submittedAt: run.createDate,
407
558
  }
408
559
  let result: WorkflowResult
409
560
  try {
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ export * from './bundles.ts'
25
25
  export * from './busy.ts'
26
26
  export * from './components.ts'
27
27
  export * from './config.ts'
28
+ export * from './content-notifications.ts'
28
29
  export * from './content-type-udt.ts'
29
30
  export * from './database.ts'
30
31
  export * from './diagnostics.ts'
@@ -37,6 +38,7 @@ export * from './email-notice.ts'
37
38
  export * from './email-postmark.ts'
38
39
  export * from './email-resend.ts'
39
40
  export * from './email-ses.ts'
41
+ export * from './email-text.ts'
40
42
  export * from './extensions-notice.ts'
41
43
  export * from './external-sign-in.ts'
42
44
  export * from './file-intake.ts'
package/src/jobs.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Background jobs, as Umbraco runs them: scheduled publishing every minute and
3
3
  * version cleanup every hour when it is enabled, plus the search indexer
4
- * draining its queue and the audit log ageing out old entries hourly. Each run can also be called directly, which is
5
- * how tests drive them. On a multi-node Postgres site every node runs them;
6
- * each schedule and queued item is claimed atomically, so it runs once.
4
+ * draining its queue, the audit log ageing out old entries hourly, and the
5
+ * form, webhook and content notification queues. Each run can also be called
6
+ * directly, which is how tests drive them. Every node that owns writes (`all`
7
+ * and `api`) runs them; each schedule and queued item is claimed atomically, so
8
+ * it runs once.
7
9
  */
8
10
 
9
11
  import type { NodeSchemaState } from '@crombolo/data'
@@ -16,6 +18,11 @@ import {
16
18
  } from '@crombolo/data'
17
19
  import type { PublishedCache } from '@crombolo/render'
18
20
  import { auditRetainedSince, recordAudit } from './adapters/audit.ts'
21
+ import type {
22
+ ContentNotificationRunner,
23
+ ContentNotificationRunReport,
24
+ ContentNotifier,
25
+ } from './content-notifications.ts'
19
26
  import type { WorkflowRunner, WorkflowRunReport } from './form-workflows.ts'
20
27
  import { logger } from './logging.ts'
21
28
  import type { MediaFileStore } from './media-files.ts'
@@ -49,6 +56,11 @@ export interface JobsOptions {
49
56
  searchIndexingMs?: number
50
57
  /** Days the audit log keeps an entry; 0 or absent keeps them forever. */
51
58
  auditRetentionDays?: number
59
+ /** Content notifications (story 045): queued by changes, sent here. Absent without e-mail. */
60
+ contentNotifications?: ContentNotificationRunner
61
+ contentNotificationsMs?: number
62
+ /** Queues the notifications scheduled publishing makes, as an editor's publish does. */
63
+ notifier?: ContentNotifier
52
64
  }
53
65
 
54
66
  export interface ScheduledRun {
@@ -63,6 +75,8 @@ export interface BackgroundJobs {
63
75
  runFormWorkflows(now?: Date): Promise<WorkflowRunReport>
64
76
  /** Delivers the webhook and bundle events that are due. */
65
77
  runWebhooks(now?: Date): Promise<WorkflowRunReport>
78
+ /** Sends the content notifications that are due (story 045). */
79
+ runContentNotifications(now?: Date): Promise<ContentNotificationRunReport>
66
80
  /** Indexes every queued item, and returns how many. docs/search.md. */
67
81
  runSearchIndexing(): Promise<number>
68
82
  /** Something was queued here: index it shortly, rather than at the next tick. */
@@ -115,13 +129,24 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
115
129
  }
116
130
  }
117
131
  if (run.published.length + run.unpublished.length > 0) options.cache.invalidate()
118
- for (const key of run.published)
132
+ for (const key of run.published) {
119
133
  await options.webhooks?.raise({ alias: 'Umbraco.ContentPublish', key })
134
+ await options.notifier?.raise(
135
+ { actions: ['Umb.Document.Publish'], key, keyKind: 'document' },
136
+ { key: null, name: 'The schedule' },
137
+ 'scheduled',
138
+ )
139
+ }
120
140
  for (const key of run.unpublished)
121
141
  await options.webhooks?.raise({ alias: 'Umbraco.ContentUnpublish', key })
122
142
  return run
123
143
  }
124
144
 
145
+ async function runContentNotifications(now = new Date()): Promise<ContentNotificationRunReport> {
146
+ const runner = options.contentNotifications
147
+ return runner ? runner.runDue(now) : { claimed: 0, sent: 0, skipped: 0, retrying: 0, failed: 0 }
148
+ }
149
+
125
150
  async function runAuditRetention(now = new Date()): Promise<number> {
126
151
  const before = auditRetainedSince(options.auditRetentionDays ?? 0, now)
127
152
  return before ? new AuditLog(options.db).prune(before) : 0
@@ -184,6 +209,7 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
184
209
  runAuditRetention,
185
210
  runFormWorkflows,
186
211
  runWebhooks,
212
+ runContentNotifications,
187
213
  runSearchIndexing,
188
214
  kickSearchIndexing() {
189
215
  if (timers.length === 0 || kicked) return
@@ -204,6 +230,12 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
204
230
  if (options.formWorkflows) every(options.formWorkflowsMs ?? 15_000, runFormWorkflows)
205
231
  // As often, for the same reason: a receiver that is waiting notices a minute.
206
232
  if (options.webhooks) every(options.webhooksMs ?? 15_000, runWebhooks)
233
+ // A digest waits a minute anyway; this is how soon after that it goes.
234
+ const notifications = options.contentNotifications
235
+ if (notifications) {
236
+ every(options.contentNotificationsMs ?? 15_000, runContentNotifications)
237
+ every(60 * 60_000, () => notifications.prune(new Date(Date.now() - 30 * 86_400_000)))
238
+ }
207
239
  if (options.versionCleanup.enabled) every(60 * 60_000, runVersionCleanup)
208
240
  if (options.auditRetentionDays) every(60 * 60_000, runAuditRetention)
209
241
  const files = options.mediaFiles
package/src/ports.ts CHANGED
@@ -74,6 +74,7 @@ import { createWebhookPort } from './adapters/webhooks.ts'
74
74
  import { createApprovals } from './approvals.ts'
75
75
  import { COMPONENT_SNIPPETS } from './component-snippets.ts'
76
76
  import { type CromboloConfig, type UserLinkSender, VERSION } from './config.ts'
77
+ import type { ContentNotifier } from './content-notifications.ts'
77
78
  import { type EmailPort, resolveEmailPort } from './email.ts'
78
79
  import { createFileIntake } from './file-intake.ts'
79
80
  import { formTypesFor } from './form-types.ts'
@@ -405,6 +406,26 @@ export interface DepsOptions {
405
406
  onViewsChange?: (alias: string) => void | Promise<void>
406
407
  /** Items were queued for the search index; the indexer can start on them now. */
407
408
  onSearchQueued?: () => void
409
+ /** Queues content notifications for changes made outside the router (story 045). */
410
+ notifier?: ContentNotifier
411
+ }
412
+
413
+ /** The back office's address, e.g. `https://site.example/crombolo`; undefined when the site's own is not set. */
414
+ export function backOfficeUrl(
415
+ config: Pick<CromboloConfig, 'applicationUrl' | 'backOfficePath'>,
416
+ ): string | undefined {
417
+ return config.applicationUrl
418
+ ? `${config.applicationUrl.replace(/\/$/, '')}${config.backOfficePath}`
419
+ : undefined
420
+ }
421
+
422
+ /** A page's address in the back office, for a link in an e-mail. */
423
+ export function documentEditUrl(
424
+ config: Pick<CromboloConfig, 'applicationUrl' | 'backOfficePath'>,
425
+ key: string,
426
+ ): string | undefined {
427
+ const base = backOfficeUrl(config)
428
+ return base ? `${base}/section/content/workspace/document/edit/${key}` : undefined
408
429
  }
409
430
 
410
431
  function createTemporaryFilePort(files: MediaFileStore): TemporaryFilePort {
@@ -493,6 +514,15 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
493
514
  endSessions: async (userId) => {
494
515
  await store.endAllSessionsForUser(userId)
495
516
  },
517
+ onPagePermissionsChanged: (principal, nodeKeys) =>
518
+ Promise.all(
519
+ nodeKeys.map((key) =>
520
+ options.notifier?.raise(
521
+ { actions: ['Umb.Document.Permissions'], key, keyKind: 'document' },
522
+ { key: principal.id, name: principal.name },
523
+ ),
524
+ ),
525
+ ).then(() => undefined),
496
526
  })
497
527
  // Story 032: the authenticator provider, offered only where secrets can be sealed.
498
528
  const twoFactor = new TwoFactorService(options.db, {
@@ -620,6 +650,7 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
620
650
  enabled: options.config.trackRedirects,
621
651
  }),
622
652
  options.config.siteDir,
653
+ Boolean(email),
623
654
  )
624
655
  if (deps.documents) {
625
656
  const documents = deps.documents
@@ -627,6 +658,8 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
627
658
  db: options.db,
628
659
  nodeState: options.schema?.nodeState,
629
660
  email,
661
+ backOfficeUrl: backOfficeUrl(options.config),
662
+ notifier: options.notifier,
630
663
  // Through the same port an editor's publish takes, so what approval
631
664
  // publishes is cached, tracked and announced as any publish is.
632
665
  publisher: {
package/src/server.ts CHANGED
@@ -98,6 +98,7 @@ import { CommandError, installBundleContent } from './bundle-install.ts'
98
98
  import { bundleChains, bundleTablesReport } from './bundle-tables.ts'
99
99
  import { createBundleRedirects, createServerBundles, validateServerBundles } from './bundles.ts'
100
100
  import { busyResponse } from './busy.ts'
101
+ import { listEmailTemplates } from './components.ts'
101
102
  import {
102
103
  type CromboloConfig,
103
104
  listeningBodyLimit,
@@ -105,6 +106,7 @@ import {
105
106
  type ServerRole,
106
107
  VERSION,
107
108
  } from './config.ts'
109
+ import { createContentNotificationRunner, createContentNotifier } from './content-notifications.ts'
108
110
  import { attachDatabase, bootstrapDatabase } from './database.ts'
109
111
  import { createDeliveryMemberSecurity } from './delivery-members.ts'
110
112
  import { createDiagnostics } from './diagnostics.ts'
@@ -143,7 +145,7 @@ import { mediaStoreFromEnvironment } from './media-store.ts'
143
145
  import { createMemberAuth, MEMBER_ROUTE_PREFIX } from './member-auth.ts'
144
146
  import { noNodesPage } from './no-nodes.ts'
145
147
  import { NotImplementedLog } from './not-implemented.ts'
146
- import { createDeps, PREVIEW_COOKIE, resolveUserLinkSender } from './ports.ts'
148
+ import { createDeps, documentEditUrl, PREVIEW_COOKIE, resolveUserLinkSender } from './ports.ts'
147
149
  import { createRedirectTracker } from './redirects.ts'
148
150
  import { createRemote } from './remote.ts'
149
151
  import { withRemoteMedia } from './remote-media.ts'
@@ -167,6 +169,7 @@ import { syncConfiguredRedirects } from './redirects.ts'
167
169
  import {
168
170
  bootSchema,
169
171
  CacheInstructionPoller,
172
+ componentAliasesIn,
170
173
  importSchema,
171
174
  type SchemaBoot,
172
175
  watchSchema,
@@ -751,6 +754,9 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
751
754
  // gates the options rather than `emailStatus.available`: development delivers
752
755
  // to the console, and that is a working flow for the person using it.
753
756
  const canSendUserLinks = Boolean(resolveUserLinkSender(config, email))
757
+ // Story 045: a change queues the e-mails its subscribers asked for. Nothing is
758
+ // queued on a site that cannot send; development sends to the console.
759
+ const notifier = createContentNotifier(db, { canSend: Boolean(email) })
754
760
  const mediaFiles = new MediaFileStore(mediaStore)
755
761
  const images = new ImageProcessor(mediaStore)
756
762
  const webhooks = createWebhookService({
@@ -775,6 +781,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
775
781
  schema,
776
782
  mediaFiles,
777
783
  email,
784
+ notifier,
778
785
  onSearchQueued: () => jobs.kickSearchIndexing(),
779
786
  // This node looks again at once; the others are told to. The write has
780
787
  // already landed wherever `componentsDir` points, so there is nothing to
@@ -812,6 +819,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
812
819
  onWebhookEvent: (event) => {
813
820
  void webhooks.raise(event)
814
821
  },
822
+ // After the response too: the subscribers are worked out and queued, and sent a minute later.
823
+ onContentChange: (change, principal) => {
824
+ void notifier.raise(change, principal ? { key: principal.id, name: principal.name } : null)
825
+ },
815
826
  onServerEvent: (event) => {
816
827
  events.broadcast('notify', [event])
817
828
  // Saves, deletes and moves all announce themselves; whatever they queued gets indexed now.
@@ -874,7 +885,21 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
874
885
  nodeId: schema.nodeId,
875
886
  onContentChanged: () => cache.invalidate(),
876
887
  bundleWorkflows: formTypes.workflows,
888
+ renderEmail: (alias, props) => renderer.renderEmail(alias, props),
889
+ site: async () => ({
890
+ name: (await seo.siteDefaults()).siteName || config.siteName,
891
+ url: config.applicationUrl,
892
+ }),
877
893
  }),
894
+ notifier,
895
+ contentNotifications: email
896
+ ? createContentNotificationRunner({
897
+ db,
898
+ email,
899
+ siteName: config.siteName,
900
+ editUrl: (key) => documentEditUrl(config, key),
901
+ })
902
+ : undefined,
878
903
  onDocumentChanged: (key) =>
879
904
  events.broadcast('notify', [
880
905
  { eventSource: 'Umbraco:CMS:Document', eventType: 'Updated', key },
@@ -1273,6 +1298,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1273
1298
  reason: emailStatus.reason,
1274
1299
  affects: emailStatus.affects,
1275
1300
  canSendUserLinks,
1301
+ canSendNotifications: Boolean(email),
1276
1302
  })
1277
1303
  }
1278
1304
 
@@ -1329,6 +1355,18 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1329
1355
  // Hyphenated, so no form alias (letters and digits) can be mistaken for it.
1330
1356
  if (action === 'available-types' && request.method === 'GET')
1331
1357
  return Response.json(describeFormTypes(formTypes.types))
1358
+ // The designer's "E-mail design" picker.
1359
+ if (action === 'email-templates' && request.method === 'GET')
1360
+ return Response.json({
1361
+ items: listEmailTemplates(
1362
+ config.componentsDir,
1363
+ definitions.flatMap((form) =>
1364
+ form.workflows.flatMap((workflow) =>
1365
+ typeof workflow.settings.template === 'string' ? [workflow.settings.template] : [],
1366
+ ),
1367
+ ),
1368
+ ),
1369
+ })
1332
1370
 
1333
1371
  const entries = new FormEntryRepository(db)
1334
1372
  const byKey = formsByKey(definitions)
@@ -1340,6 +1378,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1340
1378
  schemaDir: config.schemaDir,
1341
1379
  writable: config.schemaWritable,
1342
1380
  formTypes: formTypes.types,
1381
+ componentAliases: () => componentAliasesIn(config.componentsDir),
1343
1382
  onChanged: config.schemaStore
1344
1383
  ? async () => {
1345
1384
  await publishSchema(config.schemaStore as SchemaStore, config.schemaDir)