@nuxt-customer-portal/core 0.7.0 → 0.8.1
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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/portal.manifest.mjs +1 -1
- package/server/utils/portal-email.ts +13 -21
- package/server/utils/public-api-route.ts +2 -1
- package/shared/email-template.ts +90 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @nuxt-customer-portal/core
|
|
2
2
|
|
|
3
|
+
## 0.8.1
|
|
4
|
+
|
|
5
|
+
## 0.8.0
|
|
6
|
+
|
|
7
|
+
### Minor Changes
|
|
8
|
+
|
|
9
|
+
- 175854d: Support validated conditional sections in email texts and HTML templates, preserve conditional tags in the visual editor, and let administrators preview purchase emails with or without downloads.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 28bb7af: Add public purchase file permalinks, confirmation email download links, external thank-you page delivery integration, and database records for public and portal download requests.
|
|
14
|
+
|
|
3
15
|
## 0.7.0
|
|
4
16
|
|
|
5
17
|
## 0.6.1
|
package/package.json
CHANGED
package/portal.manifest.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { renderEmailTemplate, validateEmailTemplateSyntax } from '../../shared/email-template'
|
|
1
2
|
import { isPortalDemo, rejectDemoAction } from './demo'
|
|
2
3
|
import { createHash } from 'node:crypto'
|
|
3
4
|
import { eq } from 'drizzle-orm'
|
|
@@ -173,34 +174,25 @@ export const decryptPortalEmailSecret = (value: string) => {
|
|
|
173
174
|
}
|
|
174
175
|
}
|
|
175
176
|
|
|
176
|
-
const replacePlaceholders = (input: string, values: Record<string, string>, allowed: Set<string>) =>
|
|
177
|
-
input.replace(placeholderPattern, (_match, key: string) => {
|
|
178
|
-
if (!allowed.has(key)) {
|
|
179
|
-
throw new Error(`Unknown email placeholder: ${key}`)
|
|
180
|
-
}
|
|
181
|
-
if (!(key in values)) {
|
|
182
|
-
throw new Error(`Missing email placeholder value: ${key}`)
|
|
183
|
-
}
|
|
184
|
-
return values[key]!
|
|
185
|
-
})
|
|
186
|
-
|
|
187
177
|
export const validatePortalEmailTemplate = (template: string) => {
|
|
188
178
|
const found = [...template.matchAll(placeholderPattern)].map((match) => match[1]!)
|
|
189
179
|
if (!found.includes('body')) {
|
|
190
180
|
throw createError({ statusCode: 422, message: 'The email template must contain {{body}}' })
|
|
191
181
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
182
|
+
try {
|
|
183
|
+
validateEmailTemplateSyntax(template, TEMPLATE_PLACEHOLDERS, 'template')
|
|
184
|
+
} catch (error) {
|
|
185
|
+
throw createError({ statusCode: 422, message: error instanceof Error ? error.message : 'Invalid email template' })
|
|
195
186
|
}
|
|
196
187
|
}
|
|
197
188
|
|
|
198
189
|
export const validatePortalEmailText = (definition: PortalEmailDefinition, text: PortalEmailText) => {
|
|
199
190
|
const allowed = new Set([...definition.placeholders.map((item) => item.key), 'brand_name'])
|
|
200
191
|
for (const value of [text.subject, text.body, text.footer ?? '']) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
192
|
+
try {
|
|
193
|
+
validateEmailTemplateSyntax(value, allowed, definition.id)
|
|
194
|
+
} catch (error) {
|
|
195
|
+
throw createError({ statusCode: 422, message: error instanceof Error ? error.message : 'Invalid email text' })
|
|
204
196
|
}
|
|
205
197
|
}
|
|
206
198
|
}
|
|
@@ -336,9 +328,9 @@ export const renderPortalEmail = async (input: {
|
|
|
336
328
|
const brandName = branding.brandName
|
|
337
329
|
const messageValues = { ...input.values, brand_name: brandName }
|
|
338
330
|
const allowed = new Set([...input.definition.placeholders.map((item) => item.key), 'brand_name'])
|
|
339
|
-
const subject =
|
|
340
|
-
const bodySource =
|
|
341
|
-
const footerSource =
|
|
331
|
+
const subject = renderEmailTemplate(text.subject, messageValues, allowed)
|
|
332
|
+
const bodySource = renderEmailTemplate(text.body, messageValues, allowed)
|
|
333
|
+
const footerSource = renderEmailTemplate(text.footer ?? '', messageValues, allowed)
|
|
342
334
|
const body = renderPortalEmailMarkdown(bodySource)
|
|
343
335
|
const footer = renderPortalEmailMarkdown(footerSource)
|
|
344
336
|
const htmlTemplate = input.htmlTemplate ?? settings.htmlTemplate
|
|
@@ -348,7 +340,7 @@ export const renderPortalEmail = async (input: {
|
|
|
348
340
|
input.inlineBrandAssets && htmlTemplate.includes(`{{${key}}}`)
|
|
349
341
|
? inlineBrandAsset(value, brandName, `portal-${key.replaceAll('_', '-')}`, inlineAttachments)
|
|
350
342
|
: renderBrandAsset(value, brandName)
|
|
351
|
-
const html =
|
|
343
|
+
const html = renderEmailTemplate(
|
|
352
344
|
htmlTemplate,
|
|
353
345
|
{
|
|
354
346
|
subject: escapeHtml(subject),
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export function isPublicStoreRoute(method: string, pathname: string) {
|
|
2
2
|
return (
|
|
3
3
|
(method === 'GET' &&
|
|
4
|
-
(/^\/api\/store\/
|
|
4
|
+
(/^\/api\/store\/purchases\/[^/]+(?:\/files\/[^/]+)?$/.test(pathname) ||
|
|
5
|
+
/^\/api\/store\/v1\/products(?:\/[^/]+)?$/.test(pathname) ||
|
|
5
6
|
/^\/api\/store\/(product|media)\/[^/]+$/.test(pathname) ||
|
|
6
7
|
/^\/api\/store\/planning\/[^/]+\/availability$/.test(pathname) ||
|
|
7
8
|
pathname === '/api/store/planning/hold' ||
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
type EmailNode =
|
|
2
|
+
| { type: 'text'; value: string }
|
|
3
|
+
| { type: 'value'; key: string }
|
|
4
|
+
| { type: 'if'; key: string; yes: EmailNode[]; no: EmailNode[] }
|
|
5
|
+
|
|
6
|
+
function parseEmailTemplate(source: string, allowed: Set<string>, label = 'email'): EmailNode[] {
|
|
7
|
+
const root: EmailNode[] = []
|
|
8
|
+
const stack: Array<{ node: Extract<EmailNode, { type: 'if' }>; parent: EmailNode[]; otherwise: boolean }> = []
|
|
9
|
+
let current = root
|
|
10
|
+
let offset = 0
|
|
11
|
+
const text = (value: string) => {
|
|
12
|
+
if (/{{\s*(?:#if\b|\/if\b|else\b)/.test(value)) {
|
|
13
|
+
throw new Error('Incomplete email conditional tag')
|
|
14
|
+
}
|
|
15
|
+
if (value) {
|
|
16
|
+
current.push({ type: 'text', value })
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const checkKey = (key: string) => {
|
|
20
|
+
if (!allowed.has(key)) {
|
|
21
|
+
throw new Error(`Unknown ${label} placeholder: ${key}`)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for (const match of source.matchAll(/{{([\s\S]*?)}}/g)) {
|
|
25
|
+
text(source.slice(offset, match.index))
|
|
26
|
+
offset = match.index + match[0].length
|
|
27
|
+
const token = match[1]!.trim()
|
|
28
|
+
const condition = token.match(/^#if\s+([a-z0-9_]+)$/i)
|
|
29
|
+
if (condition) {
|
|
30
|
+
checkKey(condition[1]!)
|
|
31
|
+
if (stack.length >= 20) {
|
|
32
|
+
throw new Error('Email conditionals may nest at most 20 levels')
|
|
33
|
+
}
|
|
34
|
+
const node: Extract<EmailNode, { type: 'if' }> = { type: 'if', key: condition[1]!, yes: [], no: [] }
|
|
35
|
+
current.push(node)
|
|
36
|
+
stack.push({ node, parent: current, otherwise: false })
|
|
37
|
+
current = node.yes
|
|
38
|
+
} else if (token === 'else') {
|
|
39
|
+
const frame = stack.at(-1)
|
|
40
|
+
if (!frame || frame.otherwise) {
|
|
41
|
+
throw new Error('Unexpected {{else}} in email template')
|
|
42
|
+
}
|
|
43
|
+
frame.otherwise = true
|
|
44
|
+
current = frame.node.no
|
|
45
|
+
} else if (token === '/if') {
|
|
46
|
+
const frame = stack.pop()
|
|
47
|
+
if (!frame) {
|
|
48
|
+
throw new Error('Unexpected {{/if}} in email template')
|
|
49
|
+
}
|
|
50
|
+
current = frame.parent
|
|
51
|
+
} else if (/^[a-z0-9_]+$/i.test(token)) {
|
|
52
|
+
checkKey(token)
|
|
53
|
+
current.push({ type: 'value', key: token })
|
|
54
|
+
} else {
|
|
55
|
+
throw new Error(`Unsupported email template tag: {{${token}}}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
text(source.slice(offset))
|
|
59
|
+
if (stack.length) {
|
|
60
|
+
throw new Error('Missing {{/if}} in email template')
|
|
61
|
+
}
|
|
62
|
+
return root
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function validateEmailTemplateSyntax(source: string, allowed: Set<string>, label = 'email') {
|
|
66
|
+
parseEmailTemplate(source, allowed, label)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function renderEmailTemplate(source: string, values: Record<string, string>, allowed: Set<string>) {
|
|
70
|
+
const nodes = parseEmailTemplate(source, allowed)
|
|
71
|
+
const value = (key: string) => {
|
|
72
|
+
if (!Object.hasOwn(values, key)) {
|
|
73
|
+
throw new Error(`Missing email placeholder value: ${key}`)
|
|
74
|
+
}
|
|
75
|
+
return values[key]!
|
|
76
|
+
}
|
|
77
|
+
const render = (items: EmailNode[]): string =>
|
|
78
|
+
items
|
|
79
|
+
.map((node) => {
|
|
80
|
+
if (node.type === 'text') {
|
|
81
|
+
return node.value
|
|
82
|
+
}
|
|
83
|
+
if (node.type === 'value') {
|
|
84
|
+
return value(node.key)
|
|
85
|
+
}
|
|
86
|
+
return render(value(node.key).trim() ? node.yes : node.no)
|
|
87
|
+
})
|
|
88
|
+
.join('')
|
|
89
|
+
return render(nodes)
|
|
90
|
+
}
|