diff --git a/packages/types/src/api/web/global/email.ts b/packages/types/src/api/web/global/email.ts index 3d2e007231..9b3713f91c 100644 --- a/packages/types/src/api/web/global/email.ts +++ b/packages/types/src/api/web/global/email.ts @@ -11,7 +11,6 @@ export enum EmailTemplatePurpose { } export interface SendEmailRequest { - workspaceId?: string email: string userId?: string purpose: EmailTemplatePurpose diff --git a/packages/worker/src/api/controllers/global/email.ts b/packages/worker/src/api/controllers/global/email.ts index 674fe5955f..801bb0d349 100644 --- a/packages/worker/src/api/controllers/global/email.ts +++ b/packages/worker/src/api/controllers/global/email.ts @@ -11,7 +11,6 @@ export async function sendEmail( ctx: UserCtx ) { let { - workspaceId, email, userId, purpose, @@ -33,7 +32,6 @@ export async function sendEmail( } } const response = await sendEmailFn(email, purpose, { - workspaceId, user, contents, from, diff --git a/packages/worker/src/api/routes/global/tests/email.spec.ts b/packages/worker/src/api/routes/global/tests/email.spec.ts index 9e48412caf..18ab0cd78a 100644 --- a/packages/worker/src/api/routes/global/tests/email.spec.ts +++ b/packages/worker/src/api/routes/global/tests/email.spec.ts @@ -1,4 +1,4 @@ -import { EmailTemplatePurpose } from "@budibase/types" +import { EmailTemplatePurpose, SendEmailRequest } from "@budibase/types" import { TestConfiguration } from "../../../../tests" import { captureEmail, @@ -28,33 +28,63 @@ describe("/api/global/email", () => { await deleteAllEmail(mailserver) }) - it.each([ - { - purpose: EmailTemplatePurpose.WELCOME, - expectedText: `Thanks for getting started with Budibase's Budibase platform.`, - }, - { - purpose: EmailTemplatePurpose.INVITATION, - expectedText: `Use the button below to set up your account and get started:`, - }, - { - purpose: EmailTemplatePurpose.PASSWORD_RECOVERY, - expectedText: `You recently requested to reset your password for your Budibase account in your Budibase platform`, - }, - ])("can send $purpose emails", async ({ purpose, expectedText }) => { - const email = await captureEmail(mailserver, async () => { - const res = await config.api.emails.sendEmail({ - email: "foo@example.com", - subject: "Test", - userId: config.user!._id, - purpose, - }) - expect(res.message).toBeDefined() - }) + interface TestCase { + req: Partial + expectedStatus?: number + expectedContents?: string + } - expect(email.html).toContain(expectedText) - expect(email.html).not.toContain("Invalid binding") - }) + const testCases: TestCase[] = [ + { + req: { + purpose: EmailTemplatePurpose.WELCOME, + }, + expectedContents: `Thanks for getting started with Budibase's Budibase platform.`, + }, + { + req: { + purpose: EmailTemplatePurpose.INVITATION, + }, + expectedContents: `Use the button below to set up your account and get started:`, + }, + { + req: { + purpose: EmailTemplatePurpose.PASSWORD_RECOVERY, + }, + expectedContents: `You recently requested to reset your password for your Budibase account in your Budibase platform`, + }, + { + req: { + purpose: EmailTemplatePurpose.CUSTOM, + contents: "Hello, world!", + }, + expectedContents: "Hello, world!", + }, + ] + + it.each(testCases)( + "can send $req.purpose emails", + async ({ req, expectedContents, expectedStatus }) => { + 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.WELCOME, + ...req, + }, + { + status: expectedStatus || 200, + } + ) + expect(res.message).toBeDefined() + }) + + expect(email.html).toContain(expectedContents) + expect(email.html).not.toContain("Invalid binding") + } + ) it("should be able to send an email with an attachment", async () => { let bucket = "testbucket" @@ -77,7 +107,7 @@ describe("/api/global/email", () => { const email = await captureEmail(mailserver, async () => { const res = await config.api.emails.sendEmail({ - email: "foo@example.com", + email: "to@example.com", subject: "Test", userId: config.user!._id, purpose: EmailTemplatePurpose.WELCOME, @@ -94,4 +124,146 @@ describe("/api/global/email", () => { const attachments = await getAttachments(mailserver, email) expect(attachments).toEqual(["test data"]) }) + + it("should be able to send email without a userId", async () => { + const res = await config.api.emails.sendEmail({ + email: "to@example.com", + subject: "Test", + purpose: EmailTemplatePurpose.WELCOME, + }) + expect(res.message).toBeDefined() + }) + + it("should fail to send a password reset email without a userId", async () => { + const res = await config.api.emails.sendEmail( + { + email: "to@example.com", + subject: "Test", + purpose: EmailTemplatePurpose.PASSWORD_RECOVERY, + }, + { + status: 400, + } + ) + expect(res.message).toBeDefined() + }) + + it("can cc people", async () => { + const email = await captureEmail(mailserver, async () => { + const res = await config.api.emails.sendEmail({ + email: "to@example.com", + cc: "cc@example.com", + subject: "Test", + purpose: EmailTemplatePurpose.CUSTOM, + contents: "Hello, world!", + }) + }) + + expect(email.cc).toEqual([{ address: "cc@example.com", name: "" }]) + }) + + it("can bcc people", async () => { + const email = await captureEmail(mailserver, async () => { + const res = await config.api.emails.sendEmail({ + email: "to@example.com", + bcc: "bcc@example.com", + subject: "Test", + purpose: EmailTemplatePurpose.CUSTOM, + contents: "Hello, world!", + }) + }) + + expect(email.calculatedBcc).toEqual([ + { address: "bcc@example.com", name: "" }, + ]) + }) + + it("can change the from address", async () => { + const email = await captureEmail(mailserver, async () => { + const res = await config.api.emails.sendEmail({ + email: "to@example.com", + from: "from@example.com", + subject: "Test", + purpose: EmailTemplatePurpose.CUSTOM, + contents: "Hello, world!", + }) + expect(res.message).toBeDefined() + }) + + expect(email.to).toEqual([{ address: "to@example.com", name: "" }]) + expect(email.from).toEqual([{ address: "from@example.com", name: "" }]) + }) + + it("can send a calendar invite", async () => { + const startTime = new Date() + const endTime = new Date() + + const email = await captureEmail(mailserver, async () => { + await config.api.emails.sendEmail({ + email: "to@example.com", + subject: "Test", + purpose: EmailTemplatePurpose.CUSTOM, + contents: "Hello, world!", + invite: { + startTime, + endTime, + summary: "Summary", + location: "Location", + url: "http://example.com", + }, + }) + }) + + expect(email.alternatives).toEqual([ + { + charset: "utf-8", + contentType: "text/calendar", + method: "REQUEST", + transferEncoding: "7bit", + content: expect.any(String), + }, + ]) + + // Reference iCal invite: + // BEGIN:VCALENDAR + // VERSION:2.0 + // PRODID:-//sebbo.net//ical-generator//EN + // NAME:Invite + // X-WR-CALNAME:Invite + // BEGIN:VEVENT + // UID:2b5947b7-ec5a-4341-8d70-8d8130183f2a + // SEQUENCE:0 + // DTSTAMP:20200101T000000Z + // DTSTART:20200101T000000Z + // DTEND:20200101T000000Z + // SUMMARY:Summary + // LOCATION:Location + // URL;VALUE=URI:http://example.com + // END:VEVENT + // END:VCALENDAR + expect(email.alternatives[0].content).toContain("BEGIN:VCALENDAR") + expect(email.alternatives[0].content).toContain("BEGIN:VEVENT") + expect(email.alternatives[0].content).toContain("UID:") + expect(email.alternatives[0].content).toContain("SEQUENCE:0") + expect(email.alternatives[0].content).toContain("SUMMARY:Summary") + expect(email.alternatives[0].content).toContain("LOCATION:Location") + expect(email.alternatives[0].content).toContain( + "URL;VALUE=URI:http://example.com" + ) + expect(email.alternatives[0].content).toContain("END:VEVENT") + expect(email.alternatives[0].content).toContain("END:VCALENDAR") + + const formatDate = (date: Date) => + date.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z" + + expect(email.alternatives[0].content).toContain( + `DTSTAMP:${formatDate(startTime)}` + ) + expect(email.alternatives[0].content).toContain( + `DTSTART:${formatDate(startTime)}` + ) + expect(email.alternatives[0].content).toContain( + `DTEND:${formatDate(endTime)}` + ) + }) }) diff --git a/packages/worker/src/tests/api/email.ts b/packages/worker/src/tests/api/email.ts index 810e5a9763..2379f3b92b 100644 --- a/packages/worker/src/tests/api/email.ts +++ b/packages/worker/src/tests/api/email.ts @@ -6,13 +6,16 @@ import { import { TestAPI } from "./base" export class EmailAPI extends TestAPI { - sendEmail = async (req: SendEmailRequest): Promise => { + sendEmail = async ( + req: SendEmailRequest, + expectations?: { status?: number } + ): Promise => { const res = await this.request .post(`/api/global/email/send`) .send(req) .set(this.config.defaultHeaders()) .expect("Content-Type", /json/) - .expect(200) + .expect(expectations?.status || 200) return res.body as SendEmailResponse } diff --git a/packages/worker/src/tests/mocks/email.ts b/packages/worker/src/tests/mocks/email.ts index 811284ae11..d98552e1bc 100644 --- a/packages/worker/src/tests/mocks/email.ts +++ b/packages/worker/src/tests/mocks/email.ts @@ -22,6 +22,14 @@ export interface Address { name?: string } +export interface Alternative { + contentType: string + content: string + charset: string + method: string + transferEncoding: string +} + export interface Envelope { from: Address to: Address[] @@ -31,7 +39,9 @@ export interface Envelope { export interface Email { attachments: Attachment[] - calculatedBcc: string[] + alternatives: Alternative[] + calculatedBcc: Address[] + cc: Address[] date: string envelope: Envelope from: Address[] @@ -62,11 +72,18 @@ export function getUnusedPort(): Promise { }) } -export function waitForEmail( +export async function captureEmail( mailserver: Mailserver, - timeoutMs = 5000 + f: () => Promise ): Promise { - let timeout: ReturnType + const timeoutMs = 5000 + let timeout: ReturnType | undefined = undefined + const cancel = () => { + if (timeout) { + clearTimeout(timeout) + timeout = undefined + } + } const timeoutPromise = new Promise((_, reject) => { timeout = setTimeout(() => { reject(new Error("Timed out waiting for email")) @@ -76,18 +93,15 @@ export function waitForEmail( // @ts-expect-error - types are wrong mailserver.once("new", email => { resolve(email as Email) - clearTimeout(timeout) + cancel() }) }) - return Promise.race([mailPromise, timeoutPromise]) -} - -export async function captureEmail( - mailserver: Mailserver, - f: () => Promise -): Promise { - const emailPromise = waitForEmail(mailserver) - await f() + const emailPromise = Promise.race([mailPromise, timeoutPromise]) + try { + await f() + } finally { + cancel() + } return await emailPromise } diff --git a/packages/worker/src/utilities/email.ts b/packages/worker/src/utilities/email.ts index 5461a18b82..411f6b1e4a 100644 --- a/packages/worker/src/utilities/email.ts +++ b/packages/worker/src/utilities/email.ts @@ -9,7 +9,7 @@ import { EmailTemplatePurpose, User, } from "@budibase/types" -import { configs, cache, objectStore } from "@budibase/backend-core" +import { configs, cache, objectStore, HTTPError } from "@budibase/backend-core" import ical from "ical-generator" import _ from "lodash" @@ -147,7 +147,7 @@ export async function sendEmail( switch (purpose) { case EmailTemplatePurpose.PASSWORD_RECOVERY: if (!opts.user || !opts.user._id) { - throw "User must be provided for password recovery." + throw new HTTPError("User must be provided for password recovery.", 400) } code = await cache.passwordReset.createCode(opts.user._id, opts.info) break