Merge pull request #16103 from Budibase/feature/smtp-automation-markdown-support

Automation SMTP markdown support
This commit is contained in:
deanhannigan 2025-05-16 10:42:39 +01:00 committed by GitHub
commit fe0c60ef2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 269 additions and 8 deletions

View File

@ -100,6 +100,20 @@
comp: ExecuteScriptV2,
fullWidth: true,
},
[SchemaFieldTypes.LONGFORM]: {
comp: DrawerBindableInput,
props: (opts: FieldProps = {} as FieldProps) => {
const { key, field } = opts
return {
title: field.title ?? getFieldLabel(key, field),
panel: AutomationBindingPanel,
type: field.customType,
updateOnChange: false,
multiline: true,
placeholder: field?.description,
}
},
},
[SchemaFieldTypes.BOOL]: {
comp: Checkbox,
},
@ -309,7 +323,7 @@
*/
const getFieldType = (
field: BaseIOStructure,
block?: AutomationStep | AutomationTrigger
block: AutomationStep | AutomationTrigger
) => {
// Direct customType map
const customType = field.customType && customTypeToSchema[field.customType]

View File

@ -57,7 +57,7 @@
</script>
{#if customLayout}
<!-- Render custom layout 1 or more components in a custom layout -->
<!-- Render 1 or more components in a custom layout -->
<AutomationCustomLayout {context} {bindings} {block} layout={customLayout} />
{:else}
<!-- Render Automation Step Schema > [string, BaseIOStructure][] -->

View File

@ -24,6 +24,7 @@ export enum DataMode {
}
export enum SchemaFieldTypes {
LONGFORM = "longform",
JSON = "json",
ENUM = "enum",
BOOL = "boolean",
@ -160,6 +161,7 @@ export const customTypeToSchema: Record<string, SchemaFieldTypes> = {
export const typeToSchema: Partial<Record<AutomationIOType, SchemaFieldTypes>> =
{
[AutomationIOType.BOOLEAN]: SchemaFieldTypes.BOOL,
[AutomationIOType.LONGFORM]: SchemaFieldTypes.LONGFORM,
[AutomationIOType.DATE]: SchemaFieldTypes.DATE,
[AutomationIOType.JSON]: SchemaFieldTypes.JSON,
[AutomationIOType.ATTACHMENT]: SchemaFieldTypes.FILE,

View File

@ -43,8 +43,10 @@ export const definition: AutomationStepDefinition = {
title: "Email Subject",
},
contents: {
type: AutomationIOType.STRING,
title: "HTML Contents",
type: AutomationIOType.LONGFORM,
title: "Message",
description:
"Use markdown or HTML for rich text formatting: **bold**, _italics_, # Headings, * Bullets, <br> for line breaks, and more.",
},
addInvite: {
type: AutomationIOType.BOOLEAN,

View File

@ -16,6 +16,7 @@ export enum AutomationIOType {
DATE = "date",
DATETIME = "datetime",
ATTACHMENT = "attachment",
LONGFORM = "longform",
}
export enum AutomationCustomIOType {

View File

@ -54,6 +54,7 @@
"dotenv": "8.6.0",
"email-validator": "^2.0.4",
"global-agent": "3.0.0",
"http-graceful-shutdown": "^3.1.12",
"ical-generator": "4.1.0",
"joi": "17.6.0",
"jsonwebtoken": "9.0.2",
@ -68,6 +69,7 @@
"koa-static": "5.0.0",
"koa-useragent": "^4.1.0",
"lodash": "4.17.21",
"marked": "^15.0.11",
"node-fetch": "2.6.7",
"nodemailer": "6.9.9",
"passport-google-oauth": "2.0.0",
@ -75,8 +77,7 @@
"pouchdb": "7.3.0",
"pouchdb-all-dbs": "1.1.1",
"server-destroy": "1.0.1",
"uuid": "^8.3.2",
"http-graceful-shutdown": "^3.1.12"
"uuid": "^8.3.2"
},
"devDependencies": {
"@jest/types": "^29.6.3",
@ -92,6 +93,7 @@
"@types/server-destroy": "1.0.1",
"@types/supertest": "2.0.14",
"@types/uuid": "8.3.4",
"cheerio": "^1.0.0",
"jest": "29.7.0",
"maildev": "^2.2.1",
"nock": "^13.5.4",

View File

@ -9,6 +9,7 @@ import {
stopMailserver,
} from "../../../../tests/mocks/email"
import { objectStore } from "@budibase/backend-core"
import * as cheerio from "cheerio"
describe("/api/global/email", () => {
const config = new TestConfiguration()
@ -266,4 +267,118 @@ describe("/api/global/email", () => {
`DTEND:${formatDate(endTime)}`
)
})
it("Should parse valid markdown content from automation steps into valid HTML.", async () => {
// Basic verification that the markdown is being processed.
const email = await captureEmail(mailserver, async () => {
const res = await config.api.emails.sendEmail({
email: "to@example.com",
subject: "Test",
userId: config.user!._id,
contents: `test@home.com [Call Me!](tel:1111111)`,
purpose: EmailTemplatePurpose.CUSTOM,
})
expect(res.message).toBeDefined()
})
const $ = cheerio.load(email.html)
// Verify the email body rendered
const emailBody = $("td.email-body").first()
expect(emailBody.length).toBe(1)
// Verify a valid link was generated and is queryable
const emailLink = $("a[href^='mailto:']").first()
expect(emailLink.length).toBe(1)
expect(emailLink.text()).toBe("test@home.com")
// Verify the markdown link has been built correctly
const phoneLink = $("a[href^='tel:']").first()
expect(phoneLink.length).toBe(1)
expect(phoneLink.text()).toBe("Call Me!")
expect(phoneLink.attr("href")).toBe("tel:1111111")
})
it("Should ignore invalid markdown content and return nothing", async () => {
// The only failure case for a parse with marked is 'undefined'
// It should be caught and resolve to nothing.
const email = await captureEmail(mailserver, async () => {
const res = await config.api.emails.sendEmail({
email: "to@example.com",
subject: "Test",
userId: config.user!._id,
contents: undefined,
purpose: EmailTemplatePurpose.CUSTOM,
})
expect(res.message).toBeDefined()
})
const $ = cheerio.load(email.html)
const emailBody = $("td.email-body").first()
expect(emailBody.length).toBe(1)
const bodyText = emailBody.text().trim()
expect(bodyText).toBe("")
})
it("Should render a mixture of content. Plain text, markdown and HTML", async () => {
// A more involved check to ensure all content types are still respected
const email = await captureEmail(mailserver, async () => {
const res = await config.api.emails.sendEmail({
email: "to@example.com",
subject: "Test",
userId: config.user!._id,
contents: `<div class="html-content"><strong>Some content</strong></div>
# A heading
- This should be list entry 1
- This should be list entry 2
Some plain text`,
purpose: EmailTemplatePurpose.CUSTOM,
})
expect(res.message).toBeDefined()
})
const $ = cheerio.load(email.html)
const emailBody = $("td.email-body").first()
expect(emailBody.length).toBe(1)
const divEle = emailBody.find("div.html-content").first()
expect(divEle.length).toBe(1)
expect(divEle.text()).toBe("Some content")
const heading = emailBody.find("h1").first()
expect(heading.length).toBe(1)
expect(heading.text()).toBe("A heading")
// Both list items rendered
const listEles = emailBody.find("ul li")
expect(listEles.length).toBe(2)
const plainText = emailBody.find("p")
expect(plainText.length).toBe(1)
expect(plainText.text()).toBe("Some plain text")
})
it("Should only parse markdown content for the CUSTOM email template used in automation steps", async () => {
const email = await captureEmail(mailserver, async () => {
const res = await config.api.emails.sendEmail({
email: "to@example.com",
subject: "Test",
userId: config.user!._id,
purpose: EmailTemplatePurpose.INVITATION,
})
expect(res.message).toBeDefined()
})
const $ = cheerio.load(email.html)
const emailBody = $("td.email-body").first()
expect(emailBody.length).toBe(1)
const heading = emailBody.find("h1").first()
expect(heading.length).toBe(1)
// The email should not be parsed as markdown.
expect(heading.text()).toBe("Hi, to@example.com!")
})
})

View File

@ -12,6 +12,7 @@ import {
import { configs, cache, objectStore, HTTPError } from "@budibase/backend-core"
import ical from "ical-generator"
import _ from "lodash"
import { marked } from "marked"
import nodemailer from "nodemailer"
import SMTPTransport from "nodemailer/lib/smtp-transport"
@ -97,7 +98,10 @@ async function buildEmail(
}
context = {
...context,
contents,
contents:
purpose === EmailTemplatePurpose.CUSTOM
? marked.parse(contents || "")
: contents,
email,
name,
user: user || {},

123
yarn.lock
View File

@ -8539,6 +8539,11 @@ body-parser@1.20.3:
type-is "~1.6.18"
unpipe "1.0.0"
boolbase@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
boolean@^3.0.1:
version "3.2.0"
resolved "https://registry.yarnpkg.com/boolean/-/boolean-3.2.0.tgz#9e5294af4e98314494cbb17979fa54ca159f116b"
@ -8999,6 +9004,35 @@ check-error@^1.0.3:
dependencies:
get-func-name "^2.0.2"
cheerio-select@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4"
integrity sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==
dependencies:
boolbase "^1.0.0"
css-select "^5.1.0"
css-what "^6.1.0"
domelementtype "^2.3.0"
domhandler "^5.0.3"
domutils "^3.0.1"
cheerio@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0.tgz#1ede4895a82f26e8af71009f961a9b8cb60d6a81"
integrity sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==
dependencies:
cheerio-select "^2.1.0"
dom-serializer "^2.0.0"
domhandler "^5.0.3"
domutils "^3.1.0"
encoding-sniffer "^0.2.0"
htmlparser2 "^9.1.0"
parse5 "^7.1.2"
parse5-htmlparser2-tree-adapter "^7.0.0"
parse5-parser-stream "^7.1.2"
undici "^6.19.5"
whatwg-mimetype "^4.0.0"
chokidar@3.5.3, chokidar@^3.5.2, chokidar@^3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
@ -9766,6 +9800,17 @@ css-line-break@^2.1.0:
dependencies:
utrie "^1.0.2"
css-select@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6"
integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==
dependencies:
boolbase "^1.0.0"
css-what "^6.1.0"
domhandler "^5.0.2"
domutils "^3.0.1"
nth-check "^2.0.1"
css-tree@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.3.1.tgz#10264ce1e5442e8572fc82fbe490644ff54b5c20"
@ -9774,6 +9819,11 @@ css-tree@^2.3.1:
mdn-data "2.0.30"
source-map-js "^1.0.1"
css-what@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==
css.escape@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb"
@ -10669,7 +10719,7 @@ domexception@^4.0.0:
dependencies:
webidl-conversions "^7.0.0"
domhandler@^5.0.1, domhandler@^5.0.2:
domhandler@^5.0.1, domhandler@^5.0.2, domhandler@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
@ -10692,6 +10742,15 @@ domutils@^3.0.1:
domelementtype "^2.3.0"
domhandler "^5.0.1"
domutils@^3.1.0:
version "3.2.2"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78"
integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==
dependencies:
dom-serializer "^2.0.0"
domelementtype "^2.3.0"
domhandler "^5.0.3"
dot-prop@^5.1.0, dot-prop@^5.2.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88"
@ -10884,6 +10943,14 @@ encoding-down@^6.3.0:
level-codec "^9.0.0"
level-errors "^2.0.0"
encoding-sniffer@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz#799569d66d443babe82af18c9f403498365ef1d5"
integrity sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==
dependencies:
iconv-lite "^0.6.3"
whatwg-encoding "^3.1.1"
encoding@^0.1.13:
version "0.1.13"
resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9"
@ -10957,6 +11024,11 @@ entities@^4.2.0, entities@^4.3.0, entities@^4.4.0, entities@^4.5.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
entities@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.0.tgz#09c9e29cb79b0a6459a9b9db9efb418ac5bb8e51"
integrity sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==
env-paths@^2.2.0:
version "2.2.1"
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2"
@ -13116,6 +13188,16 @@ htmlparser2@^8.0.0:
domutils "^3.0.1"
entities "^4.3.0"
htmlparser2@^9.1.0:
version "9.1.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-9.1.0.tgz#cdb498d8a75a51f739b61d3f718136c369bc8c23"
integrity sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.3"
domutils "^3.1.0"
entities "^4.5.0"
http-assert@^1.3.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.5.0.tgz#c389ccd87ac16ed2dfa6246fd73b926aa00e6b8f"
@ -15971,6 +16053,11 @@ map-obj@^4.0.0:
resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.3.0.tgz#9304f906e93faae70880da102a9f1df0ea8bb05a"
integrity sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==
marked@^15.0.11:
version "15.0.11"
resolved "https://registry.yarnpkg.com/marked/-/marked-15.0.11.tgz#08a8d12c285e16259e44287b89ce0d871c9d55e8"
integrity sha512-1BEXAU2euRCG3xwgLVT1y0xbJEld1XOrmRJpUwRCcy7rxhSCwMrmEu9LXoPhHSCJG41V7YcQ2mjKRr5BA3ITIA==
marked@^15.0.8:
version "15.0.8"
resolved "https://registry.yarnpkg.com/marked/-/marked-15.0.8.tgz#39873a3fdf91a520111e48aeb2ef3746d58d7166"
@ -16942,6 +17029,13 @@ npmlog@^6.0.0, npmlog@^6.0.2:
gauge "^4.0.3"
set-blocking "^2.0.0"
nth-check@^2.0.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d"
integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==
dependencies:
boolbase "^1.0.0"
nunjucks@^3.2.3:
version "3.2.4"
resolved "https://registry.yarnpkg.com/nunjucks/-/nunjucks-3.2.4.tgz#f0878eef528ce7b0aa35d67cc6898635fd74649e"
@ -17607,6 +17701,21 @@ parse-url@^8.1.0:
dependencies:
parse-path "^7.0.0"
parse5-htmlparser2-tree-adapter@^7.0.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz#b5a806548ed893a43e24ccb42fbb78069311e81b"
integrity sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==
dependencies:
domhandler "^5.0.3"
parse5 "^7.0.0"
parse5-parser-stream@^7.1.2:
version "7.1.2"
resolved "https://registry.yarnpkg.com/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz#d7c20eadc37968d272e2c02660fff92dd27e60e1"
integrity sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==
dependencies:
parse5 "^7.0.0"
parse5@6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
@ -17617,6 +17726,13 @@ parse5@^1.5.1:
resolved "https://registry.yarnpkg.com/parse5/-/parse5-1.5.1.tgz#9b7f3b0de32be78dc2401b17573ccaf0f6f59d94"
integrity sha512-w2jx/0tJzvgKwZa58sj2vAYq/S/K1QJfIB3cWYea/Iu1scFPDQQ3IQiVZTHWtRBwAjv2Yd7S/xeZf3XqLDb3bA==
parse5@^7.0.0:
version "7.3.0"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.3.0.tgz#d7e224fa72399c7a175099f45fc2ad024b05ec05"
integrity sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==
dependencies:
entities "^6.0.0"
parse5@^7.1.2:
version "7.1.2"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32"
@ -21600,6 +21716,11 @@ undici@^5.28.4:
dependencies:
"@fastify/busboy" "^2.0.0"
undici@^6.19.5:
version "6.21.2"
resolved "https://registry.yarnpkg.com/undici/-/undici-6.21.2.tgz#49c5884e8f9039c65a89ee9018ef3c8e2f1f4928"
integrity sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g==
unicode-canonical-property-names-ecmascript@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc"