More email tests.

This commit is contained in:
Sam Rose 2025-02-27 11:32:13 +00:00
parent 8df8494b16
commit 7443cd4598
No known key found for this signature in database
6 changed files with 235 additions and 49 deletions

View File

@ -11,7 +11,6 @@ export enum EmailTemplatePurpose {
}
export interface SendEmailRequest {
workspaceId?: string
email: string
userId?: string
purpose: EmailTemplatePurpose

View File

@ -11,7 +11,6 @@ export async function sendEmail(
ctx: UserCtx<SendEmailRequest, SendEmailResponse>
) {
let {
workspaceId,
email,
userId,
purpose,
@ -33,7 +32,6 @@ export async function sendEmail(
}
}
const response = await sendEmailFn(email, purpose, {
workspaceId,
user,
contents,
from,

View File

@ -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<SendEmailRequest>
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)}`
)
})
})

View File

@ -6,13 +6,16 @@ import {
import { TestAPI } from "./base"
export class EmailAPI extends TestAPI {
sendEmail = async (req: SendEmailRequest): Promise<SendEmailResponse> => {
sendEmail = async (
req: SendEmailRequest,
expectations?: { status?: number }
): Promise<SendEmailResponse> => {
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
}

View File

@ -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<number> {
})
}
export function waitForEmail(
export async function captureEmail(
mailserver: Mailserver,
timeoutMs = 5000
f: () => Promise<void>
): Promise<Email> {
let timeout: ReturnType<typeof setTimeout>
const timeoutMs = 5000
let timeout: ReturnType<typeof setTimeout> | undefined = undefined
const cancel = () => {
if (timeout) {
clearTimeout(timeout)
timeout = undefined
}
}
const timeoutPromise = new Promise<never>((_, 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<void>
): Promise<Email> {
const emailPromise = waitForEmail(mailserver)
await f()
const emailPromise = Promise.race([mailPromise, timeoutPromise])
try {
await f()
} finally {
cancel()
}
return await emailPromise
}

View File

@ -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