@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.
- package/package.json +12 -12
- package/src/adapters/documents.ts +9 -2
- package/src/adapters/users.ts +45 -3
- package/src/approvals.ts +10 -0
- package/src/components.ts +77 -1
- package/src/config.ts +82 -81
- package/src/content-notifications.ts +310 -0
- package/src/email-ses.ts +4 -3
- package/src/email-text.ts +61 -0
- package/src/email.ts +1 -0
- package/src/form-files.ts +3 -0
- package/src/form-workflows.ts +159 -8
- package/src/index.ts +3 -0
- package/src/jobs.ts +36 -4
- package/src/ports.ts +33 -0
- package/src/server.ts +40 -1
- package/src/settings.ts +921 -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)
|
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 ??
|
|
232
|
-
const secretAccessKey = options.secretAccessKey ??
|
|
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 ??
|
|
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
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
|
|
package/src/form-workflows.ts
CHANGED
|
@@ -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
|
-
/**
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
177
|
-
text
|
|
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 {
|