Send meeting invite via automation email smtp (#10787)

* Pass calendar props into sendSmtpEmail

* Add calendar event to message

* Add Checkbox and DatePicker automation field UI

* Add URL prop

* Add url to sendSmtpEmail unit test

* Refactor

* Code review comments

* Make location optional

* Add EmailInvite type

---------

Co-authored-by: mike12345567 <me@michaeldrury.co.uk>
This commit is contained in:
melohagan 2023-06-08 14:25:35 +01:00 committed by GitHub
parent e1ede8edbe
commit c0578d4cc2
10 changed files with 437 additions and 294 deletions

View File

@ -13,6 +13,8 @@
Modal, Modal,
notifications, notifications,
Icon, Icon,
Checkbox,
DatePicker,
} from "@budibase/bbui" } from "@budibase/bbui"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte" import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import { automationStore, selectedAutomation } from "builderStore" import { automationStore, selectedAutomation } from "builderStore"
@ -306,6 +308,11 @@
drawer.hide() drawer.hide()
} }
function canShowField(key, value) {
const dependsOn = value.dependsOn
return !dependsOn || !!inputData[dependsOn]
}
onMount(async () => { onMount(async () => {
try { try {
await environment.loadVariables() await environment.loadVariables()
@ -317,15 +324,16 @@
<div class="fields"> <div class="fields">
{#each deprecatedSchemaProperties as [key, value]} {#each deprecatedSchemaProperties as [key, value]}
{#if canShowField(key, value)}
<div class="block-field"> <div class="block-field">
{#if key !== "fields"} {#if key !== "fields" && value.type !== "boolean"}
<Label <Label
tooltip={value.title === "Binding / Value" tooltip={value.title === "Binding / Value"
? "If using the String input type, please use a comma or newline separated string" ? "If using the String input type, please use a comma or newline separated string"
: null}>{value.title || (key === "row" ? "Table" : key)}</Label : null}>{value.title || (key === "row" ? "Table" : key)}</Label
> >
{/if} {/if}
{#if value.type === "string" && value.enum} {#if value.type === "string" && value.enum && canShowField(key)}
<Select <Select
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
value={inputData[key]} value={inputData[key]}
@ -355,6 +363,19 @@
onChange(e, key) onChange(e, key)
}} }}
/> />
{:else if value.type === "boolean"}
<div style="margin-top: 10px">
<Checkbox
text={value.title}
value={inputData[key]}
on:change={e => onChange(e, key)}
/>
</div>
{:else if value.type === "date"}
<DatePicker
value={inputData[key]}
on:change={e => onChange(e, key)}
/>
{:else if value.customType === "column"} {:else if value.customType === "column"}
<Select <Select
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
@ -416,7 +437,10 @@
value={inputData[key]} value={inputData[key]}
/> />
{:else if value.customType === "cron"} {:else if value.customType === "cron"}
<CronBuilder on:change={e => onChange(e, key)} value={inputData[key]} /> <CronBuilder
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "queryParams"} {:else if value.customType === "queryParams"}
<QueryParamSelector <QueryParamSelector
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
@ -459,7 +483,10 @@
{isTestModal} {isTestModal}
/> />
{:else if value.customType === "triggerSchema"} {:else if value.customType === "triggerSchema"}
<SchemaSetup on:change={e => onChange(e, key)} value={inputData[key]} /> <SchemaSetup
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "code"} {:else if value.customType === "code"}
<CodeEditorModal> <CodeEditorModal>
<CodeEditor <CodeEditor
@ -514,13 +541,16 @@
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
{bindings} {bindings}
updateOnChange={false} updateOnChange={false}
placeholder={value.customType === "queryLimit" ? queryLimit : ""} placeholder={value.customType === "queryLimit"
? queryLimit
: ""}
drawerLeft="260px" drawerLeft="260px"
/> />
</div> </div>
{/if} {/if}
{/if} {/if}
</div> </div>
{/if}
{/each} {/each}
</div> </div>
<Modal bind:this={webhookModal} width="30%"> <Modal bind:this={webhookModal} width="30%">

View File

@ -48,6 +48,35 @@ export const definition: AutomationStepSchema = {
type: AutomationIOType.STRING, type: AutomationIOType.STRING,
title: "HTML Contents", title: "HTML Contents",
}, },
addInvite: {
type: AutomationIOType.BOOLEAN,
title: "Add calendar invite",
},
startTime: {
type: AutomationIOType.DATE,
title: "Start Time",
dependsOn: "addInvite",
},
endTime: {
type: AutomationIOType.DATE,
title: "End Time",
dependsOn: "addInvite",
},
summary: {
type: AutomationIOType.STRING,
title: "Meeting Summary",
dependsOn: "addInvite",
},
location: {
type: AutomationIOType.STRING,
title: "Location",
dependsOn: "addInvite",
},
url: {
type: AutomationIOType.STRING,
title: "URL",
dependsOn: "addInvite",
},
}, },
required: ["to", "from", "subject", "contents"], required: ["to", "from", "subject", "contents"],
}, },
@ -68,21 +97,43 @@ export const definition: AutomationStepSchema = {
} }
export async function run({ inputs }: AutomationStepInput) { export async function run({ inputs }: AutomationStepInput) {
let { to, from, subject, contents, cc, bcc } = inputs let {
if (!contents) {
contents = "<h1>No content</h1>"
}
to = to || undefined
try {
let response = await sendSmtpEmail(
to, to,
from, from,
subject, subject,
contents, contents,
cc, cc,
bcc, bcc,
true addInvite,
) startTime,
endTime,
summary,
location,
url,
} = inputs
if (!contents) {
contents = "<h1>No content</h1>"
}
to = to || undefined
try {
let response = await sendSmtpEmail({
to,
from,
subject,
contents,
cc,
bcc,
automation: true,
invite: addInvite
? {
startTime,
endTime,
summary,
location,
url,
}
: undefined,
})
return { return {
success: true, success: true,
response, response,

View File

@ -1,71 +0,0 @@
function generateResponse(to, from) {
return {
"success": true,
"response": {
"accepted": [
to
],
"envelope": {
"from": from,
"to": [
to
]
},
"message": `Email sent to ${to}.`
}
}
}
const mockFetch = jest.fn(() => ({
headers: {
raw: () => {
return { "content-type": ["application/json"] }
},
get: () => ["application/json"],
},
json: jest.fn(() => response),
status: 200,
text: jest.fn(),
}))
jest.mock("node-fetch", () => mockFetch)
const setup = require("./utilities")
describe("test the outgoing webhook action", () => {
let inputs
let config = setup.getConfig()
beforeAll(async () => {
await config.init()
})
afterAll(setup.afterAll)
it("should be able to run the action", async () => {
inputs = {
to: "user1@test.com",
from: "admin@test.com",
subject: "hello",
contents: "testing",
}
let resp = generateResponse(inputs.to, inputs.from)
mockFetch.mockImplementationOnce(() => ({
headers: {
raw: () => {
return { "content-type": ["application/json"] }
},
get: () => ["application/json"],
},
json: jest.fn(() => resp),
status: 200,
text: jest.fn(),
}))
const res = await setup.runStep(setup.actions.SEND_EMAIL_SMTP.stepId, inputs)
expect(res.response).toEqual(resp)
expect(res.success).toEqual(true)
})
})

View File

@ -0,0 +1,74 @@
import * as workerRequests from "../../utilities/workerRequests"
jest.mock("../../utilities/workerRequests", () => ({
sendSmtpEmail: jest.fn(),
}))
function generateResponse(to: string, from: string) {
return {
success: true,
response: {
accepted: [to],
envelope: {
from: from,
to: [to],
},
message: `Email sent to ${to}.`,
},
}
}
const setup = require("./utilities")
describe("test the outgoing webhook action", () => {
let inputs
let config = setup.getConfig()
beforeAll(async () => {
await config.init()
})
afterAll(setup.afterAll)
it("should be able to run the action", async () => {
jest
.spyOn(workerRequests, "sendSmtpEmail")
.mockImplementationOnce(async () =>
generateResponse("user1@test.com", "admin@test.com")
)
const invite = {
startTime: new Date(),
endTime: new Date(),
summary: "summary",
location: "location",
url: "url",
}
inputs = {
to: "user1@test.com",
from: "admin@test.com",
subject: "hello",
contents: "testing",
cc: "cc",
bcc: "bcc",
addInvite: true,
...invite,
}
let resp = generateResponse(inputs.to, inputs.from)
const res = await setup.runStep(
setup.actions.SEND_EMAIL_SMTP.stepId,
inputs
)
expect(res.response).toEqual(resp)
expect(res.success).toEqual(true)
expect(workerRequests.sendSmtpEmail).toHaveBeenCalledTimes(1)
expect(workerRequests.sendSmtpEmail).toHaveBeenCalledWith({
to: "user1@test.com",
from: "admin@test.com",
subject: "hello",
contents: "testing",
cc: "cc",
bcc: "bcc",
invite,
automation: true,
})
})
})

View File

@ -9,7 +9,7 @@ import {
env as coreEnv, env as coreEnv,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { updateAppRole } from "./global" import { updateAppRole } from "./global"
import { BBContext, User } from "@budibase/types" import { BBContext, User, EmailInvite } from "@budibase/types"
export function request(ctx?: BBContext, request?: any) { export function request(ctx?: BBContext, request?: any) {
if (!request.headers) { if (!request.headers) {
@ -65,15 +65,25 @@ async function checkResponse(
} }
// have to pass in the tenant ID as this could be coming from an automation // have to pass in the tenant ID as this could be coming from an automation
export async function sendSmtpEmail( export async function sendSmtpEmail({
to: string, to,
from: string, from,
subject: string, subject,
contents: string, contents,
cc: string, cc,
bcc: string, bcc,
automation,
invite,
}: {
to: string
from: string
subject: string
contents: string
cc: string
bcc: string
automation: boolean automation: boolean
) { invite?: EmailInvite
}) {
// tenant ID will be set in header // tenant ID will be set in header
const response = await fetch( const response = await fetch(
checkSlashesInUrl(env.WORKER_URL + `/api/global/email/send`), checkSlashesInUrl(env.WORKER_URL + `/api/global/email/send`),
@ -88,6 +98,7 @@ export async function sendSmtpEmail(
bcc, bcc,
purpose: "custom", purpose: "custom",
automation, automation,
invite,
}, },
}) })
) )

View File

@ -1,5 +1,6 @@
import { Document } from "../document" import { Document } from "../document"
import { EventEmitter } from "events" import { EventEmitter } from "events"
import { User } from "../global"
export enum AutomationIOType { export enum AutomationIOType {
OBJECT = "object", OBJECT = "object",
@ -8,6 +9,7 @@ export enum AutomationIOType {
NUMBER = "number", NUMBER = "number",
ARRAY = "array", ARRAY = "array",
JSON = "json", JSON = "json",
DATE = "date",
} }
export enum AutomationCustomIOType { export enum AutomationCustomIOType {
@ -66,6 +68,33 @@ export enum AutomationActionStepId {
integromat = "integromat", integromat = "integromat",
} }
export interface EmailInvite {
startTime: Date
endTime: Date
summary: string
location?: string
url?: string
}
export interface SendEmailOpts {
// workspaceId If finer grain controls being used then this will lookup config for workspace.
workspaceId?: string
// user If sending to an existing user the object can be provided, this is used in the context.
user: User
// from If sending from an address that is not what is configured in the SMTP config.
from?: string
// contents If sending a custom email then can supply contents which will be added to it.
contents?: string
// subject A custom subject can be specified if the config one is not desired.
subject?: string
// info Pass in a structure of information to be stored alongside the invitation.
info?: any
cc?: boolean
bcc?: boolean
automation?: boolean
invite?: EmailInvite
}
export const AutomationStepIdArray = [ export const AutomationStepIdArray = [
...Object.values(AutomationActionStepId), ...Object.values(AutomationActionStepId),
...Object.values(AutomationTriggerStepId), ...Object.values(AutomationTriggerStepId),
@ -90,6 +119,7 @@ interface BaseIOStructure {
customType?: AutomationCustomIOType customType?: AutomationCustomIOType
title?: string title?: string
description?: string description?: string
dependsOn?: string
enum?: string[] enum?: string[]
pretty?: string[] pretty?: string[]
properties?: { properties?: {

View File

@ -53,6 +53,7 @@
"elastic-apm-node": "3.38.0", "elastic-apm-node": "3.38.0",
"global-agent": "3.0.0", "global-agent": "3.0.0",
"got": "11.8.3", "got": "11.8.3",
"ical-generator": "4.1.0",
"joi": "17.6.0", "joi": "17.6.0",
"koa": "2.13.4", "koa": "2.13.4",
"koa-body": "4.2.0", "koa-body": "4.2.0",

View File

@ -14,6 +14,7 @@ export async function sendEmail(ctx: BBContext) {
cc, cc,
bcc, bcc,
automation, automation,
invite,
} = ctx.request.body } = ctx.request.body
let user let user
if (userId) { if (userId) {
@ -29,6 +30,7 @@ export async function sendEmail(ctx: BBContext) {
cc, cc,
bcc, bcc,
automation, automation,
invite,
}) })
ctx.body = { ctx.body = {
...response, ...response,

View File

@ -4,28 +4,11 @@ import { getTemplateByPurpose, EmailTemplates } from "../constants/templates"
import { getSettingsTemplateContext } from "./templates" import { getSettingsTemplateContext } from "./templates"
import { processString } from "@budibase/string-templates" import { processString } from "@budibase/string-templates"
import { getResetPasswordCode, getInviteCode } from "./redis" import { getResetPasswordCode, getInviteCode } from "./redis"
import { User, SMTPInnerConfig } from "@budibase/types" import { User, SendEmailOpts, SMTPInnerConfig } from "@budibase/types"
import { configs } from "@budibase/backend-core" import { configs } from "@budibase/backend-core"
import ical from "ical-generator"
const nodemailer = require("nodemailer") const nodemailer = require("nodemailer")
type SendEmailOpts = {
// workspaceId If finer grain controls being used then this will lookup config for workspace.
workspaceId?: string
// user If sending to an existing user the object can be provided, this is used in the context.
user: User
// from If sending from an address that is not what is configured in the SMTP config.
from?: string
// contents If sending a custom email then can supply contents which will be added to it.
contents?: string
// subject A custom subject can be specified if the config one is not desired.
subject?: string
// info Pass in a structure of information to be stored alongside the invitation.
info?: any
cc?: boolean
bcc?: boolean
automation?: boolean
}
const TEST_MODE = env.ENABLE_EMAIL_TEST_MODE && env.isDev() const TEST_MODE = env.ENABLE_EMAIL_TEST_MODE && env.isDev()
const TYPE = TemplateType.EMAIL const TYPE = TemplateType.EMAIL
@ -200,6 +183,26 @@ export async function sendEmail(
context context
) )
} }
if (opts?.invite) {
const calendar = ical({
name: "Invite",
})
calendar.createEvent({
start: opts.invite.startTime,
end: opts.invite.endTime,
summary: opts.invite.summary,
location: opts.invite.location,
url: opts.invite.url,
})
message = {
...message,
icalEvent: {
method: "request",
content: calendar.toString(),
},
}
}
const response = await transport.sendMail(message) const response = await transport.sendMail(message)
if (TEST_MODE) { if (TEST_MODE) {
console.log("Test email URL: " + nodemailer.getTestMessageUrl(response)) console.log("Test email URL: " + nodemailer.getTestMessageUrl(response))

View File

@ -13668,6 +13668,13 @@ husky@^8.0.3:
resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.3.tgz#4936d7212e46d1dea28fef29bb3a108872cd9184" resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.3.tgz#4936d7212e46d1dea28fef29bb3a108872cd9184"
integrity sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg== integrity sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==
ical-generator@4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/ical-generator/-/ical-generator-4.1.0.tgz#2a336c951864c5583a2aa715d16f2edcdfd2d90b"
integrity sha512-5GrFDJ8SAOj8cB9P1uEZIfKrNxSZ1R2eOQfZePL+CtdWh4RwNXWe8b0goajz+Hu37vcipG3RVldoa2j57Y20IA==
dependencies:
uuid-random "^1.3.2"
iconv-lite@0.4.24, iconv-lite@^0.4.15, iconv-lite@^0.4.24, iconv-lite@^0.4.5: iconv-lite@0.4.24, iconv-lite@^0.4.15, iconv-lite@^0.4.24, iconv-lite@^0.4.5:
version "0.4.24" version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@ -25279,6 +25286,11 @@ utils-merge@1.0.1, utils-merge@1.x.x:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
uuid-random@^1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/uuid-random/-/uuid-random-1.3.2.tgz#96715edbaef4e84b1dcf5024b00d16f30220e2d0"
integrity sha512-UOzej0Le/UgkbWEO8flm+0y+G+ljUon1QWTEZOq1rnMAsxo2+SckbiZdKzAHHlVh6gJqI1TjC/xwgR50MuCrBQ==
uuid@3.3.2: uuid@3.3.2:
version "3.3.2" version "3.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"