@crombolo/server 0.16.0 → 0.18.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,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)
package/src/email-ses.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  postToProvider,
31
31
  validateMessage,
32
32
  } from './email.ts'
33
+ import { setting } from './settings.ts'
33
34
 
34
35
  export interface SesOptions extends EmailAdapterOptions {
35
36
  region: string
@@ -228,13 +229,13 @@ export function rawMime(
228
229
 
229
230
  export function sesEmail(options: SesOptions): EmailPort {
230
231
  if (!options.region) throw new Error('An SES e-mail adapter needs a region.')
231
- const accessKeyId = options.accessKeyId ?? Bun.env.AWS_ACCESS_KEY_ID ?? ''
232
- const secretAccessKey = options.secretAccessKey ?? Bun.env.AWS_SECRET_ACCESS_KEY ?? ''
232
+ const accessKeyId = options.accessKeyId ?? setting('AWS_ACCESS_KEY_ID') ?? ''
233
+ const secretAccessKey = options.secretAccessKey ?? setting('AWS_SECRET_ACCESS_KEY') ?? ''
233
234
  if (!accessKeyId || !secretAccessKey)
234
235
  throw new Error(
235
236
  'An SES e-mail adapter needs credentials: pass accessKeyId and secretAccessKey, or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. Instance roles are not resolved.',
236
237
  )
237
- const sessionToken = options.sessionToken ?? Bun.env.AWS_SESSION_TOKEN ?? undefined
238
+ const sessionToken = options.sessionToken ?? setting('AWS_SESSION_TOKEN') ?? undefined
238
239
  const call = options.fetch ?? fetch
239
240
  const base = options.endpoint ?? `https://email.${options.region}.amazonaws.com`
240
241
  assertSecureEndpoint(base, 'SES')
@@ -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 {