@budibase/worker 3.13.10 → 3.13.12

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@budibase/worker",
3
3
  "email": "hi@budibase.com",
4
- "version": "3.13.10",
4
+ "version": "3.13.12",
5
5
  "description": "Budibase background service",
6
6
  "main": "src/index.ts",
7
7
  "repository": {
@@ -68,7 +68,8 @@
68
68
  "pouchdb": "7.3.0",
69
69
  "pouchdb-all-dbs": "1.1.1",
70
70
  "server-destroy": "1.0.1",
71
- "uuid": "^8.3.2"
71
+ "uuid": "^8.3.2",
72
+ "yaml": "^2.8.0"
72
73
  },
73
74
  "devDependencies": {
74
75
  "@jest/types": "^29.6.3",
@@ -113,5 +114,5 @@
113
114
  }
114
115
  }
115
116
  },
116
- "gitHead": "9d6b86cc387bd1e3cc4ec8f3a1c7f8f3a94d1205"
117
+ "gitHead": "fc4f6a024f4bd3a9cb8af3f292ce0c783a4331a3"
117
118
  }
@@ -1,10 +1,15 @@
1
+ import { v4 } from "uuid"
1
2
  import {
2
3
  TemplateMetadata,
3
4
  TemplateBindings,
4
5
  GLOBAL_OWNER,
5
6
  } from "../../../constants"
6
- import { getTemplateByID, getTemplates } from "../../../constants/templates"
7
- import { tenancy, db as dbCore } from "@budibase/backend-core"
7
+ import {
8
+ addBaseTemplates,
9
+ getTemplateByID,
10
+ getTemplates,
11
+ } from "../../../constants/templates"
12
+ import { tenancy, db as dbCore, objectStore } from "@budibase/backend-core"
8
13
  import {
9
14
  DeleteGlobalTemplateResponse,
10
15
  FetchGlobalTemplateByOwnerIDResponse,
@@ -17,7 +22,12 @@ import {
17
22
  GlobalTemplateBinding,
18
23
  GlobalTemplateDefinition,
19
24
  UserCtx,
25
+ Ctx,
26
+ EmailTemplatePurpose,
20
27
  } from "@budibase/types"
28
+ import { join } from "path"
29
+ import fs from "fs"
30
+ import yaml from "yaml"
21
31
 
22
32
  export async function save(
23
33
  ctx: UserCtx<SaveGlobalTemplateRequest, SaveGlobalTemplateResponse>
@@ -93,3 +103,58 @@ export async function destroy(
93
103
  await db.remove(ctx.params.id, ctx.params.rev)
94
104
  ctx.body = { message: `Template ${ctx.params.id} deleted.` }
95
105
  }
106
+
107
+ function templatesToYaml(
108
+ templates: Array<{ contents: string; purpose: string }>
109
+ ): string {
110
+ const doc = new yaml.Document()
111
+
112
+ // Build the template object
113
+ const templatesObj: Record<string, yaml.Scalar> = {}
114
+ templates.forEach(template => {
115
+ const scalar = new yaml.Scalar(template.contents)
116
+ scalar.type = "BLOCK_LITERAL"
117
+ templatesObj[template.purpose] = scalar
118
+ })
119
+
120
+ // Set the document contents
121
+ doc.contents = doc.createNode({ templates: templatesObj })
122
+
123
+ return doc.toString()
124
+ }
125
+
126
+ export async function exportTemplates(ctx: Ctx) {
127
+ const { params } = ctx
128
+
129
+ // Limited to email for now
130
+ if (params.type === "email") {
131
+ // default|custom
132
+ const { type = "default" } = ctx.request?.body || {}
133
+
134
+ // Load either original or customised templates
135
+ const allTemplates =
136
+ type === "custom" ? await getTemplates() : addBaseTemplates([], "email")
137
+
138
+ const filtered = allTemplates.filter(
139
+ ({ purpose }) => purpose !== EmailTemplatePurpose.CORE
140
+ )
141
+ // Outline all the type keys
142
+ const header = `# Template types: ${filtered.map(t => t.purpose).join(", ")} :) \n\n`
143
+ const yamlContent = templatesToYaml(filtered)
144
+ try {
145
+ // Combine header and content
146
+ const finalYaml = header + yamlContent
147
+ ctx.attachment(
148
+ `${type === "custom" ? "bb_email_templates" : "bb_default_email_templates"}.yaml`
149
+ )
150
+ const path = join(objectStore.budibaseTempDir(), v4())
151
+ fs.writeFileSync(path, finalYaml)
152
+ ctx.body = fs.createReadStream(path)
153
+ } catch (err: any) {
154
+ ctx.throw(
155
+ err.status,
156
+ `Could not download email templates: ${err.message}`
157
+ )
158
+ }
159
+ }
160
+ }
@@ -33,6 +33,7 @@ router
33
33
  .get("/api/global/template/:type", controller.fetchByType)
34
34
  .get("/api/global/template/:ownerId", controller.fetchByOwner)
35
35
  .get("/api/global/template/:id", controller.find)
36
+ .post("/api/global/template/:type/export", controller.exportTemplates)
36
37
  .delete("/api/global/template/:id/:rev", adminOnly, controller.destroy)
37
38
 
38
39
  export default router
@@ -1,6 +1,9 @@
1
1
  import { TemplateMetadata, TemplateType } from "../../../../constants"
2
2
  import { TestConfiguration } from "../../../../tests"
3
- import { EmailTemplatePurpose } from "@budibase/types"
3
+ import { EmailTemplatePurpose, Template } from "@budibase/types"
4
+ import { addBaseTemplates } from "../../../../constants/templates"
5
+ import { tenancy } from "@budibase/backend-core"
6
+ import yaml from "yaml"
4
7
 
5
8
  // TODO
6
9
 
@@ -101,4 +104,91 @@ describe("/api/global/template", () => {
101
104
  ).toBeDefined()
102
105
  })
103
106
  })
107
+
108
+ describe("POST /api/global/template/:type/export - emails", () => {
109
+ let templates: Template[] = []
110
+
111
+ beforeAll(async () => {
112
+ addBaseTemplates(templates, "email")
113
+
114
+ // Core is not an updatable template and should never be included
115
+ templates = templates.filter(t => t.purpose !== EmailTemplatePurpose.CORE)
116
+
117
+ await config.beforeAll()
118
+
119
+ const templateResp = await config.api.templates.getTemplate()
120
+ const templateDocs = templateResp.body.filter((t: Template) => t._id)
121
+
122
+ // Purge all docs from the previous test blocks
123
+ await config.doInTenant(async () => {
124
+ const db = tenancy.getGlobalDB()
125
+ await db.bulkRemove(templateDocs)
126
+ })
127
+ })
128
+
129
+ it("should export all default email templates as yaml", async () => {
130
+ const resp = await config.api.templates.exportTemplates()
131
+
132
+ expect(resp.type).toBe("text/yaml")
133
+
134
+ const doc = yaml.parse(resp.text)
135
+
136
+ // Confirm the export file name
137
+ const disposition = resp.header["content-disposition"]
138
+ expect(disposition).toContain("bb_default_email_templates")
139
+
140
+ // All templates type should be present and match the hbs contents.
141
+ const processedTemplates = templates.filter(t => {
142
+ const yamlTemplateContents = doc.templates[t.purpose]
143
+ return t.contents === yamlTemplateContents
144
+ })
145
+
146
+ expect(processedTemplates.length).toBe(templates.length)
147
+ })
148
+
149
+ it("should export user edited email templates", async () => {
150
+ const customContent = "<div>hello</div>"
151
+
152
+ // Alter one of the templates. This creates 1 couch doc, for the custom type
153
+ // All the rest will still contain content from the static hbs files
154
+ await config.api.templates.saveTemplate({
155
+ purpose: EmailTemplatePurpose.CUSTOM,
156
+ contents: customContent,
157
+ type: "email",
158
+ })
159
+
160
+ const resp = await config.api.templates.exportTemplates({
161
+ data: {
162
+ type: "custom",
163
+ },
164
+ })
165
+
166
+ expect(resp.type).toBe("text/yaml")
167
+
168
+ const doc = yaml.parse(resp.text)
169
+
170
+ const disposition = resp.header["content-disposition"]
171
+ expect(disposition).toContain("bb_email_templates.yaml")
172
+
173
+ // All templates should be present and match both the static and custom contents
174
+ const processedTemplates = templates.filter(t => {
175
+ const yamlTemplateContents = doc.templates[t.purpose]
176
+ // Check the custom contents has been updated
177
+ if (t.purpose === EmailTemplatePurpose.CUSTOM) {
178
+ return yamlTemplateContents === customContent
179
+ }
180
+ return t.contents === yamlTemplateContents
181
+ })
182
+
183
+ expect(processedTemplates.length).toBe(templates.length)
184
+ })
185
+
186
+ it("should ignore non-email requests", async () => {
187
+ // Only email templates are supported.
188
+ await config.api.templates.exportTemplates(
189
+ { type: "something" },
190
+ { status: 404 }
191
+ )
192
+ })
193
+ })
104
194
  })
@@ -1,8 +1,9 @@
1
1
  import { readStaticFile } from "../../utilities/fileSystem"
2
2
  import { TemplateType, TemplatePurpose, GLOBAL_OWNER } from "../index"
3
3
  import { join } from "path"
4
- import { db as dbCore, tenancy } from "@budibase/backend-core"
4
+ import { db as dbCore, tenancy, context } from "@budibase/backend-core"
5
5
  import { Template, EmailTemplatePurpose } from "@budibase/types"
6
+ import yaml from "yaml"
6
7
 
7
8
  export const EmailTemplates = {
8
9
  [EmailTemplatePurpose.PASSWORD_RECOVERY]: readStaticFile(
@@ -19,6 +20,78 @@ export const EmailTemplates = {
19
20
  [EmailTemplatePurpose.CORE]: readStaticFile(join(__dirname, "core.hbs")),
20
21
  }
21
22
 
23
+ export const removeWhitespaceBetweenTags = (content: string) => {
24
+ return content.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim()
25
+ }
26
+
27
+ export async function loadTemplateConfig(pathTo: string) {
28
+ let templateConfig: Record<string, string>
29
+
30
+ try {
31
+ console.log("Loading email templates:", pathTo)
32
+ const config = readStaticFile(pathTo)
33
+ const parsed = yaml.parse(config)
34
+
35
+ if (!parsed || !parsed.templates) {
36
+ console.log(`No email templates found: ${pathTo}`)
37
+ return
38
+ }
39
+
40
+ // Remove core as it contains licensed content
41
+ // Unlikely, but just to be certain
42
+ const { core, ...rest } = parsed.templates
43
+ templateConfig = rest
44
+ } catch (e: any) {
45
+ console.log("There was a problem parsing email templates: ", e.message)
46
+ return
47
+ }
48
+
49
+ await context.doInTenant(tenancy.getTenantId(), async () => {
50
+ try {
51
+ const templates = await getTemplates({ type: "email" })
52
+
53
+ const updates: Template[] = templates.reduce(
54
+ (acc: Template[], template: Template) => {
55
+ const config = templateConfig[template.purpose]
56
+ if (config) {
57
+ // Need to normalise the text spacing for comparison
58
+ const configContent = removeWhitespaceBetweenTags(config)
59
+ const templateContent = removeWhitespaceBetweenTags(
60
+ template.contents
61
+ )
62
+
63
+ // If the template is exactly the same, ignore it.
64
+ if (configContent === templateContent) return acc
65
+ const owner = template.ownerId || GLOBAL_OWNER
66
+ // Create/Update template docs
67
+ acc.push({
68
+ ...template,
69
+ contents: config,
70
+ ...(!template._id
71
+ ? { _id: dbCore.generateTemplateID(owner) }
72
+ : {}),
73
+ ownerId: owner,
74
+ })
75
+ }
76
+ return acc
77
+ },
78
+ []
79
+ )
80
+
81
+ if (updates.length) {
82
+ const updateList = updates.map(u => u.purpose)
83
+ const db = tenancy.getGlobalDB()
84
+ await db.bulkDocs(updates)
85
+ console.log(`Email templates updated: ${updateList.join(",")}`)
86
+ } else {
87
+ console.log(`Email templates unchanged`)
88
+ }
89
+ } catch (e: any) {
90
+ console.log("Error persisting email templates", e.message)
91
+ }
92
+ })
93
+ }
94
+
22
95
  export function addBaseTemplates(templates: Template[], type?: string) {
23
96
  let purposeList
24
97
  switch (type) {
@@ -0,0 +1,523 @@
1
+ # Template types: base, password_recovery, invitation, welcome, custom :)
2
+
3
+ templates:
4
+ base: |-
5
+ <!-- Based on templates: https://github.com/wildbit/postmark-templates/blob/master/templates/plain -->
6
+ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
7
+ <html xmlns="http://www.w3.org/1999/xhtml">
8
+ <head>
9
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
10
+ <meta name="x-apple-disable-message-reformatting" />
11
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
12
+ <meta name="color-scheme" content="light dark" />
13
+ <meta name="supported-color-schemes" content="light dark" />
14
+ <title></title>
15
+ <style type="text/css" rel="stylesheet" media="all">
16
+ @import url('https://fonts.googleapis.com/css2?family=Source+Sans+Pro&display=swap');
17
+
18
+ body {
19
+ width: 100% !important;
20
+ height: 100%;
21
+ margin: 0;
22
+ -webkit-text-size-adjust: none;
23
+ }
24
+
25
+ a {
26
+ color: #6E56FF !important;
27
+ }
28
+
29
+ a img {
30
+ border: none;
31
+ }
32
+
33
+ td {
34
+ word-break: break-word;
35
+ }
36
+
37
+ .preheader {
38
+ display: none !important;
39
+ visibility: hidden;
40
+ mso-hide: all;
41
+ font-size: 1px;
42
+ line-height: 1px;
43
+ max-height: 0;
44
+ max-width: 0;
45
+ opacity: 0;
46
+ overflow: hidden;
47
+ }
48
+
49
+ /* Type ------------------------------ */
50
+
51
+ body,
52
+ td,
53
+ th {
54
+ font-family: "Source Sans 3", Helvetica, Arial, sans-serif;
55
+ }
56
+
57
+ h1 {
58
+ margin-top: 0;
59
+ color: #333333;
60
+ font-size: 22px;
61
+ font-weight: bold;
62
+ text-align: left;
63
+ }
64
+
65
+ h2 {
66
+ margin-top: 0;
67
+ color: #333333;
68
+ font-size: 16px;
69
+ font-weight: bold;
70
+ text-align: left;
71
+ }
72
+
73
+ h3 {
74
+ margin-top: 0;
75
+ color: #333333;
76
+ font-size: 14px;
77
+ font-weight: bold;
78
+ text-align: left;
79
+ }
80
+
81
+ td,
82
+ th {
83
+ font-size: 16px;
84
+ }
85
+
86
+ p,
87
+ ul,
88
+ ol,
89
+ blockquote {
90
+ margin: .4em 0 1.1875em;
91
+ font-size: 16px;
92
+ line-height: 1.625;
93
+ }
94
+
95
+ p.sub {
96
+ font-size: 13px;
97
+ }
98
+
99
+ /* Utilities ------------------------------ */
100
+
101
+ .align-right {
102
+ text-align: right;
103
+ }
104
+
105
+ .align-left {
106
+ text-align: left;
107
+ }
108
+
109
+ .align-center {
110
+ text-align: center;
111
+ }
112
+
113
+ /* Buttons ------------------------------ */
114
+
115
+ .button {
116
+ background-color: #6E56FF;
117
+ border-top: 10px solid #6E56FF;
118
+ border-right: 18px solid #6E56FF;
119
+ border-bottom: 10px solid #6E56FF;
120
+ border-left: 18px solid #6E56FF;
121
+ display: inline-block;
122
+ color: #FFF !important;
123
+ text-decoration: none !important;
124
+ border-radius: 3px;
125
+ box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
126
+ -webkit-text-size-adjust: none;
127
+ box-sizing: border-box;
128
+ }
129
+
130
+ .button--green {
131
+ background-color: #22BC66;
132
+ border-top: 10px solid #22BC66;
133
+ border-right: 18px solid #22BC66;
134
+ border-bottom: 10px solid #22BC66;
135
+ border-left: 18px solid #22BC66;
136
+ }
137
+
138
+ .button--red {
139
+ background-color: #FF6136;
140
+ border-top: 10px solid #FF6136;
141
+ border-right: 18px solid #FF6136;
142
+ border-bottom: 10px solid #FF6136;
143
+ border-left: 18px solid #FF6136;
144
+ }
145
+
146
+ @media only screen and (max-width: 500px) {
147
+ .button {
148
+ width: 100% !important;
149
+ text-align: center !important;
150
+ }
151
+ }
152
+
153
+ /* Attribute list ------------------------------ */
154
+
155
+ .attributes {
156
+ margin: 0 0 21px;
157
+ }
158
+
159
+ .attributes_content {
160
+ background-color: #F4F4F7;
161
+ padding: 16px;
162
+ }
163
+
164
+ .attributes_item {
165
+ padding: 0;
166
+ }
167
+
168
+ /* Related Items ------------------------------ */
169
+
170
+ .related {
171
+ width: 100%;
172
+ margin: 0;
173
+ padding: 25px 0 0 0;
174
+ -premailer-width: 100%;
175
+ -premailer-cellpadding: 0;
176
+ -premailer-cellspacing: 0;
177
+ }
178
+
179
+ .related_item {
180
+ padding: 10px 0;
181
+ color: #CBCCCF;
182
+ font-size: 15px;
183
+ line-height: 18px;
184
+ }
185
+
186
+ .related_item-title {
187
+ display: block;
188
+ margin: .5em 0 0;
189
+ }
190
+
191
+ .related_item-thumb {
192
+ display: block;
193
+ padding-bottom: 10px;
194
+ }
195
+
196
+ .related_heading {
197
+ border-top: 1px solid #CBCCCF;
198
+ text-align: center;
199
+ padding: 25px 0 10px;
200
+ }
201
+
202
+ /* Discount Code ------------------------------ */
203
+
204
+ .discount {
205
+ width: 100%;
206
+ margin: 0;
207
+ padding: 24px;
208
+ -premailer-width: 100%;
209
+ -premailer-cellpadding: 0;
210
+ -premailer-cellspacing: 0;
211
+ background-color: #F4F4F7;
212
+ border: 2px dashed #CBCCCF;
213
+ }
214
+
215
+ .discount_heading {
216
+ text-align: center;
217
+ }
218
+
219
+ .discount_body {
220
+ text-align: center;
221
+ font-size: 15px;
222
+ }
223
+
224
+ /* Social Icons ------------------------------ */
225
+
226
+ .social {
227
+ width: auto;
228
+ }
229
+
230
+ .social td {
231
+ padding: 0;
232
+ width: auto;
233
+ }
234
+
235
+ .social_icon {
236
+ height: 20px;
237
+ margin: 0 8px 10px 8px;
238
+ padding: 0;
239
+ }
240
+
241
+ /* Data table ------------------------------ */
242
+
243
+ .purchase {
244
+ width: 100%;
245
+ margin: 0;
246
+ padding: 35px 0;
247
+ -premailer-width: 100%;
248
+ -premailer-cellpadding: 0;
249
+ -premailer-cellspacing: 0;
250
+ }
251
+
252
+ .purchase_content {
253
+ width: 100%;
254
+ margin: 0;
255
+ padding: 25px 0 0 0;
256
+ -premailer-width: 100%;
257
+ -premailer-cellpadding: 0;
258
+ -premailer-cellspacing: 0;
259
+ }
260
+
261
+ .purchase_item {
262
+ padding: 10px 0;
263
+ color: #51545E;
264
+ font-size: 15px;
265
+ line-height: 18px;
266
+ }
267
+
268
+ .purchase_heading {
269
+ padding-bottom: 8px;
270
+ border-bottom: 1px solid #EAEAEC;
271
+ }
272
+
273
+ .purchase_heading p {
274
+ margin: 0;
275
+ color: #85878E;
276
+ font-size: 12px;
277
+ }
278
+
279
+ .purchase_footer {
280
+ padding-top: 15px;
281
+ border-top: 1px solid #EAEAEC;
282
+ }
283
+
284
+ .purchase_total {
285
+ margin: 0;
286
+ text-align: right;
287
+ font-weight: bold;
288
+ color: #333333;
289
+ }
290
+
291
+ .purchase_total--label {
292
+ padding: 0 15px 0 0;
293
+ }
294
+
295
+ body {
296
+ background-color: #FFF;
297
+ color: #333;
298
+ }
299
+
300
+ p {
301
+ color: #333;
302
+ }
303
+
304
+ .email-wrapper {
305
+ width: 100%;
306
+ margin: 0;
307
+ padding: 0;
308
+ -premailer-width: 100%;
309
+ -premailer-cellpadding: 0;
310
+ -premailer-cellspacing: 0;
311
+ }
312
+
313
+ .email-content {
314
+ width: 100%;
315
+ margin: 0;
316
+ padding: 0;
317
+ -premailer-width: 100%;
318
+ -premailer-cellpadding: 0;
319
+ -premailer-cellspacing: 0;
320
+ }
321
+
322
+ /* Masthead ----------------------- */
323
+
324
+ .email-masthead {
325
+ padding: 25px 0;
326
+ text-align: center;
327
+ }
328
+
329
+ .email-masthead_logo {
330
+ width: 94px;
331
+ }
332
+
333
+ .email-masthead_name {
334
+ font-size: 16px;
335
+ font-weight: bold;
336
+ color: #A8AAAF;
337
+ text-decoration: none;
338
+ text-shadow: 0 1px 0 white;
339
+ }
340
+
341
+ /* Body ------------------------------ */
342
+
343
+ .email-body {
344
+ width: 100%;
345
+ margin: 0;
346
+ padding: 0;
347
+ -premailer-width: 100%;
348
+ -premailer-cellpadding: 0;
349
+ -premailer-cellspacing: 0;
350
+ }
351
+
352
+ .email-body_inner {
353
+ width: 570px;
354
+ margin: 0 auto;
355
+ padding: 0;
356
+ -premailer-width: 570px;
357
+ -premailer-cellpadding: 0;
358
+ -premailer-cellspacing: 0;
359
+ }
360
+
361
+ .email-footer {
362
+ width: 570px;
363
+ margin: 0 auto;
364
+ padding: 0;
365
+ -premailer-width: 570px;
366
+ -premailer-cellpadding: 0;
367
+ -premailer-cellspacing: 0;
368
+ text-align: center;
369
+ }
370
+
371
+ .email-footer p {
372
+ color: #A8AAAF;
373
+ }
374
+
375
+ .body-action {
376
+ width: 100%;
377
+ margin: 30px auto;
378
+ padding: 0;
379
+ -premailer-width: 100%;
380
+ -premailer-cellpadding: 0;
381
+ -premailer-cellspacing: 0;
382
+ text-align: center;
383
+ }
384
+
385
+ .body-sub {
386
+ margin-top: 25px;
387
+ padding-top: 25px;
388
+ border-top: 1px solid #EAEAEC;
389
+ }
390
+
391
+ .content-cell {
392
+ padding: 35px;
393
+ }
394
+
395
+ /*Media Queries ------------------------------ */
396
+
397
+ @media only screen and (max-width: 600px) {
398
+ .email-body_inner,
399
+ .email-footer {
400
+ width: 100% !important;
401
+ }
402
+ }
403
+
404
+ @media (prefers-color-scheme: dark) {
405
+ body {
406
+ background-color: #333333 !important;
407
+ color: #FFF !important;
408
+ }
409
+
410
+ p,
411
+ ul,
412
+ ol,
413
+ blockquote,
414
+ h1,
415
+ h2,
416
+ h3,
417
+ span,
418
+ .purchase_item {
419
+ color: #FFF !important;
420
+ }
421
+
422
+ .attributes_content,
423
+ .discount {
424
+ background-color: #222 !important;
425
+ }
426
+
427
+ .email-masthead_name {
428
+ text-shadow: none !important;
429
+ }
430
+ }
431
+
432
+ :root {
433
+ color-scheme: light dark;
434
+ supported-color-schemes: light dark;
435
+ }
436
+ </style>
437
+ <!--[if mso]>
438
+ <style type="text/css">
439
+ .f-fallback {
440
+ font-family: Arial, sans-serif;
441
+ }
442
+ </style>
443
+ <![endif]-->
444
+ </head>
445
+ <body>
446
+ <table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
447
+ <tr>
448
+ <td align="center">
449
+ <table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
450
+ <tr>
451
+ <td class="email-masthead">
452
+ <a href="{{ platformUrl }}" class="f-fallback email-masthead_name">
453
+ {{ company }}
454
+ </a>
455
+ </td>
456
+ </tr>
457
+ {{ body }}
458
+ <tr>
459
+ <table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
460
+ <tr>
461
+ <td class="content-cell" align="center">
462
+ <p class="f-fallback sub align-center">&copy; {{ currentYear }} {{ company }}. All rights reserved.</p>
463
+ </td>
464
+ </tr>
465
+ </table>
466
+ </tr>
467
+ </table>
468
+ </td>
469
+ </tr>
470
+ </table>
471
+ </body>
472
+ </html>
473
+ password_recovery: |-
474
+ <!-- Based on template: https://github.com/wildbit/postmark-templates/blob/master/templates/plain/password-reset/content.html -->
475
+ <tr>
476
+ <td class="email-body" width="570" cellpadding="0" cellspacing="0">
477
+ <table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
478
+ <!-- Body content -->
479
+ <tr>
480
+ <td class="content-cell">
481
+ <div class="f-fallback">
482
+ <h1>Hi {{#if name}}{{ name }}{{else}}{{ email }}{{/if}},</h1>
483
+ <p>You recently requested to reset your password for your {{ company }} account in your Budibase platform. Use the button below to reset it. <strong>This password reset is only valid for the next 24 hours.</strong></p>
484
+ <!-- Action -->
485
+ <table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
486
+ <tr>
487
+ <td align="center">
488
+ <table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
489
+ <tr>
490
+ <td align="center">
491
+ <a href="{{ resetUrl }}" class="f-fallback button button--green" target="_blank">Reset your password</a>
492
+ </td>
493
+ </tr>
494
+ </table>
495
+ </td>
496
+ </tr>
497
+ </table>
498
+ {{#if request}}
499
+ <p>For security, this request was received from a {{ request.os }} device.</p>
500
+ {{/if}}
501
+ <p>If you did not request a password reset, please ignore this email or contact support if you have questions.</p>
502
+ <p>Thanks,
503
+ <br>The {{ company }} Team</p>
504
+ <!-- Sub copy -->
505
+ <table class="body-sub" role="presentation">
506
+ <tr>
507
+ <td>
508
+ <p class="f-fallback sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
509
+ <p class="f-fallback sub">{{ resetUrl }}</p>
510
+ </td>
511
+ </tr>
512
+ </table>
513
+ </div>
514
+ </td>
515
+ </tr>
516
+ </table>
517
+ </td>
518
+ </tr>
519
+ welcome: "<div>welcome altered</div>"
520
+ custom: |-
521
+ <div>altered</div>
522
+ core: |-
523
+ I shouldn't be here
@@ -0,0 +1,4 @@
1
+ templates:
2
+ test: "open quote
3
+ base: |-
4
+ <div>Invalid structure</div>
@@ -0,0 +1,174 @@
1
+ import { TestConfiguration } from "../../../tests"
2
+ import { addBaseTemplates, loadTemplateConfig } from ".."
3
+ import { EmailTemplatePurpose, type Template } from "@budibase/types"
4
+ import { join } from "path"
5
+
6
+ describe("Loading yaml email templates", () => {
7
+ const config = new TestConfiguration()
8
+ let consoleSpy: jest.SpyInstance
9
+ let templates: Template[] = []
10
+
11
+ beforeEach(() => {
12
+ consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {})
13
+ })
14
+
15
+ beforeAll(async () => {
16
+ addBaseTemplates(templates, "email")
17
+ // Core is not an updatable template and should never be included
18
+ templates = templates.filter(t => t.purpose !== EmailTemplatePurpose.CORE)
19
+
20
+ await config.beforeAll()
21
+ })
22
+
23
+ afterAll(async () => {
24
+ await config.afterAll()
25
+ })
26
+
27
+ it("should ignore an invalid path", async () => {
28
+ const testPath = join(__dirname, `./email_templates_wrong_path.yaml`)
29
+
30
+ await config.doInTenant(async () => {
31
+ loadTemplateConfig(testPath)
32
+ })
33
+
34
+ expect(consoleSpy).toHaveBeenCalledTimes(2)
35
+
36
+ // Confirm the error is relayed
37
+ expect(consoleSpy).toHaveBeenCalledWith(
38
+ "There was a problem parsing email templates: ",
39
+ expect.stringContaining("no such file")
40
+ )
41
+
42
+ consoleSpy.mockRestore()
43
+ })
44
+
45
+ it("should ignore empty contents", async () => {
46
+ // Valid path but its empty.
47
+ const testPath = join(__dirname, `./email_templates_empty.yaml`)
48
+
49
+ await config.doInTenant(async () => {
50
+ await loadTemplateConfig(testPath)
51
+ })
52
+
53
+ expect(consoleSpy).toHaveBeenCalledTimes(2)
54
+ expect(consoleSpy).toHaveBeenCalledWith(
55
+ `No email templates found: ${testPath}`
56
+ )
57
+
58
+ consoleSpy.mockRestore()
59
+ })
60
+
61
+ it("should detect and ignore invalid yaml content", async () => {
62
+ const testPath = join(__dirname, `./email_templates_invalid.yaml`)
63
+
64
+ await config.doInTenant(async () => {
65
+ await loadTemplateConfig(testPath)
66
+ })
67
+
68
+ expect(consoleSpy).toHaveBeenCalledTimes(2)
69
+ // Confirm the error is relayed - there is an unclosed string in the test doc
70
+ expect(consoleSpy).toHaveBeenCalledWith(
71
+ "There was a problem parsing email templates: ",
72
+ expect.stringContaining("Missing closing")
73
+ )
74
+ consoleSpy.mockRestore()
75
+ })
76
+
77
+ it("should process valid yaml content", async () => {
78
+ // welcome and custom modified, invitation was excluded
79
+ const testPath = join(__dirname, `./email_templates.yaml`)
80
+
81
+ const templatesBeforeResp = await config.api.templates.getTemplate()
82
+ const templatesBefore = templatesBeforeResp.body.reduce(
83
+ (acc: Record<string, any>, t: any) => {
84
+ if (t.purpose === EmailTemplatePurpose.CORE) return acc
85
+ acc[t.purpose] ??= t
86
+ return acc
87
+ },
88
+ {}
89
+ )
90
+
91
+ await config.doInTenant(async () => {
92
+ await loadTemplateConfig(testPath)
93
+ })
94
+
95
+ const templatesUpdatedResp = await config.api.templates.getTemplate()
96
+ const templatesUpdated = templatesUpdatedResp.body.filter(
97
+ (t: any) => t.purpose !== EmailTemplatePurpose.CORE
98
+ )
99
+
100
+ expect(templatesUpdated.length).toBe(templates.length)
101
+
102
+ // Confirm the changes have been applied and the templates are persisted
103
+ const processedTemplates = templatesUpdated.filter((t: any) => {
104
+ if (
105
+ (t._id &&
106
+ t.purpose === EmailTemplatePurpose.CUSTOM &&
107
+ t.contents === "<div>altered</div>") ||
108
+ (t._id &&
109
+ t.purpose === EmailTemplatePurpose.WELCOME &&
110
+ t.contents === "<div>welcome altered</div>")
111
+ ) {
112
+ return true
113
+ }
114
+ // All others are the same.
115
+ return templatesBefore[t.purpose].contents === t.contents
116
+ })
117
+
118
+ expect(processedTemplates.length).toBe(templates.length)
119
+
120
+ // Should notify that the two templates that didn't match were in actually persisted.
121
+ expect(consoleSpy).toHaveBeenCalledTimes(2)
122
+ expect(consoleSpy).toHaveBeenCalledWith(
123
+ `Email templates updated: welcome,custom`
124
+ )
125
+
126
+ consoleSpy.mockRestore()
127
+ })
128
+
129
+ it("should only update template docs when the contents has changed", async () => {
130
+ // welcome and custom modified, invitation was excluded
131
+ const testPath = join(__dirname, `./email_templates.yaml`)
132
+
133
+ const templatesBeforeResp = await config.api.templates.getTemplate()
134
+ const templatesBefore = templatesBeforeResp.body.reduce(
135
+ (acc: Record<string, any>, t: any) => {
136
+ if (t.purpose === EmailTemplatePurpose.CORE) return acc
137
+ acc[t.purpose] ??= t
138
+ return acc
139
+ },
140
+ {}
141
+ )
142
+
143
+ // Update the templates. welcome and custom are persisted to couch
144
+ await config.doInTenant(async () => {
145
+ await loadTemplateConfig(testPath)
146
+ })
147
+ consoleSpy.mockReset()
148
+
149
+ // Run the update again. Simulating a restart of the worker.
150
+ await config.doInTenant(async () => {
151
+ await loadTemplateConfig(testPath)
152
+ })
153
+
154
+ const templatesUpdatedResp = await config.api.templates.getTemplate()
155
+ const templatesUpdated = templatesUpdatedResp.body.filter(
156
+ (t: any) => t.purpose !== EmailTemplatePurpose.CORE
157
+ )
158
+
159
+ expect(templatesUpdated.length).toBe(templates.length)
160
+
161
+ const processedTemplates = templatesUpdated.filter((t: any) => {
162
+ // All content unchanged
163
+ return templatesBefore[t.purpose].contents === t.contents
164
+ })
165
+
166
+ expect(processedTemplates.length).toBe(templates.length)
167
+
168
+ expect(consoleSpy).toHaveBeenCalledTimes(2)
169
+ // Should confirm that on reprocessing, the templates were not persisted
170
+ expect(consoleSpy).toHaveBeenCalledWith(`Email templates unchanged`)
171
+
172
+ consoleSpy.mockRestore()
173
+ })
174
+ })
@@ -67,6 +67,7 @@ const environment = {
67
67
  ENCRYPTED_TEST_PUBLIC_API_KEY: process.env.ENCRYPTED_TEST_PUBLIC_API_KEY,
68
68
  SESSION_EXPIRY_SECONDS: process.env.SESSION_EXPIRY_SECONDS,
69
69
  TOP_LEVEL_PATH: TOP_LEVEL_PATH,
70
+ EMAIL_TEMPLATE_PATH: process.env.EMAIL_TEMPLATE_PATH,
70
71
  /**
71
72
  * Mock the email service in use - links to ethereal hosted emails are logged instead.
72
73
  */
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  features,
22
22
  } from "@budibase/backend-core"
23
23
  import RedisStore from "koa-redis"
24
+ import { loadTemplateConfig } from "./constants/templates"
24
25
 
25
26
  db.init()
26
27
  import koaBody from "koa-body"
@@ -131,6 +132,11 @@ export default server.listen(parseInt(env.PORT || "4002"), async () => {
131
132
  await initPro()
132
133
  await redis.clients.init()
133
134
  features.init()
135
+
136
+ if (env.EMAIL_TEMPLATE_PATH) {
137
+ await loadTemplateConfig(env.EMAIL_TEMPLATE_PATH)
138
+ }
139
+
134
140
  cache.docWritethrough.init()
135
141
  // configure events to use the pro audit log write
136
142
  // can't integrate directly into backend-core due to cyclic issues
@@ -22,4 +22,16 @@ export class TemplatesAPI extends TestAPI {
22
22
  .set(opts?.headers ? opts.headers : this.config.defaultHeaders())
23
23
  .expect(opts?.status ? opts.status : 200)
24
24
  }
25
+
26
+ exportTemplates = (
27
+ req?: { type?: string; data?: any },
28
+ opts?: TestAPIOpts
29
+ ) => {
30
+ const { type = "email", data } = req || {}
31
+ return this.request
32
+ .post(`/api/global/template/${type}/export`)
33
+ .send(data)
34
+ .set(opts?.headers ? opts.headers : this.config.defaultHeaders())
35
+ .expect(opts?.status ? opts.status : 200)
36
+ }
25
37
  }