Replace email mocks and real email sending with maildev.

This commit is contained in:
Sam Rose 2025-02-26 17:55:54 +00:00
parent 92b391e9ef
commit 478d28285b
No known key found for this signature in database
7 changed files with 876 additions and 218 deletions

View File

@ -85,12 +85,14 @@
"@types/jsonwebtoken": "9.0.3",
"@types/koa__router": "12.0.4",
"@types/lodash": "4.14.200",
"@types/maildev": "^0.0.7",
"@types/node-fetch": "2.6.4",
"@types/nodemailer": "^6.4.17",
"@types/server-destroy": "1.0.1",
"@types/supertest": "2.0.14",
"@types/uuid": "8.3.4",
"jest": "29.7.0",
"maildev": "^2.2.1",
"nock": "^13.5.4",
"nodemon": "2.0.15",
"rimraf": "3.0.2",

View File

@ -1,33 +1,97 @@
jest.mock("nodemailer")
import { EmailTemplatePurpose } from "@budibase/types"
import { TestConfiguration, mocks } from "../../../../tests"
const sendMailMock = mocks.email.mock()
import { TestConfiguration } from "../../../../tests"
import {
captureEmail,
deleteAllEmail,
getAttachment,
getUnusedPort,
Mailserver,
startMailserver,
stopMailserver,
} from "../../../../tests/mocks/email"
import { objectStore } from "@budibase/backend-core"
describe("/api/global/email", () => {
const config = new TestConfiguration()
let mailserver: Mailserver
beforeAll(async () => {
await config.beforeAll()
const port = await getUnusedPort()
mailserver = await startMailserver(config, { smtp: port })
})
afterAll(async () => {
await stopMailserver(mailserver)
await config.afterAll()
})
it("should be able to send an email (with mocking)", async () => {
// initially configure settings
await config.saveSmtpConfig()
await config.saveSettingsConfig()
beforeEach(async () => {
await deleteAllEmail(mailserver)
})
const res = await config.api.emails.sendEmail(
EmailTemplatePurpose.INVITATION
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(purpose)
expect(res.body.message).toBeDefined()
expect(res.status).toBe(200)
})
expect(email.html).toContain(expectedText)
expect(email.html).not.toContain("Invalid binding")
})
it("should be able to send an email with an attachment", async () => {
let bucket = "testbucket"
let filename = "test.txt"
await objectStore.upload({
bucket,
filename,
body: Buffer.from("test data"),
})
let presignedUrl = await objectStore.getPresignedUrl(
bucket,
filename,
60000
)
expect(res.body.message).toBeDefined()
expect(sendMailMock).toHaveBeenCalled()
const emailCall = sendMailMock.mock.calls[0][0]
expect(emailCall.subject).toBe("Hello!")
expect(emailCall.html).not.toContain("Invalid binding")
let attachmentObject = {
url: presignedUrl,
filename,
}
const email = await captureEmail(mailserver, async () => {
const res = await config.api.emails.sendEmail(
EmailTemplatePurpose.WELCOME,
[attachmentObject]
)
expect(res.body.message).toBeDefined()
expect(res.status).toBe(200)
})
expect(email.html).toContain(
"Thanks for getting started with Budibase's Budibase platform."
)
expect(email.html).not.toContain("Invalid binding")
const attachment = await getAttachment(
mailserver,
email,
email.attachments[0]
)
expect(attachment).toEqual("test data")
})
})

View File

@ -1,108 +0,0 @@
jest.unmock("node-fetch")
import { TestConfiguration } from "../../../../tests"
import { objectStore } from "@budibase/backend-core"
import { helpers } from "@budibase/shared-core"
import tk from "timekeeper"
import { EmailAttachment, EmailTemplatePurpose } from "@budibase/types"
const fetch = require("node-fetch")
const nodemailer = require("nodemailer")
// for the real email tests give them a long time to try complete/fail
jest.setTimeout(30000)
describe("/api/global/email", () => {
const config = new TestConfiguration()
beforeAll(async () => {
tk.reset()
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
async function sendRealEmail(
purpose: string,
attachments?: EmailAttachment[]
) {
let response, text
try {
await helpers.withTimeout(20000, () => config.saveEtherealSmtpConfig())
await helpers.withTimeout(20000, () => config.saveSettingsConfig())
let res
if (attachments) {
res = await config.api.emails
.sendEmail(purpose, attachments)
.timeout(20000)
} else {
res = await config.api.emails.sendEmail(purpose).timeout(20000)
}
// ethereal hiccup, can't test right now
if (res.status >= 300) {
return
}
expect(res.body.message).toBeDefined()
const testUrl = nodemailer.getTestMessageUrl(res.body)
expect(testUrl).toBeDefined()
response = await fetch(testUrl)
text = await response.text()
} catch (err: any) {
// ethereal hiccup, can't test right now
if (parseInt(err.status) >= 300 || (err && err.errno === "ETIME")) {
return
} else {
throw err
}
}
let toCheckFor
switch (purpose) {
case EmailTemplatePurpose.WELCOME:
toCheckFor = `Thanks for getting started with Budibase's Budibase platform.`
break
case EmailTemplatePurpose.INVITATION:
toCheckFor = `Use the button below to set up your account and get started:`
break
case EmailTemplatePurpose.PASSWORD_RECOVERY:
toCheckFor = `You recently requested to reset your password for your Budibase account in your Budibase platform`
break
}
expect(text).toContain(toCheckFor)
}
it("should be able to send a welcome email", async () => {
await sendRealEmail(EmailTemplatePurpose.WELCOME)
})
it("should be able to send a invitation email", async () => {
await sendRealEmail(EmailTemplatePurpose.INVITATION)
})
it("should be able to send a password recovery email", async () => {
await sendRealEmail(EmailTemplatePurpose.PASSWORD_RECOVERY)
})
it("should be able to send an email with attachments", async () => {
let bucket = "testbucket"
let filename = "test.txt"
await objectStore.upload({
bucket,
filename,
body: Buffer.from("test data"),
})
let presignedUrl = await objectStore.getPresignedUrl(
bucket,
filename,
60000
)
let attachmentObject = {
url: presignedUrl,
filename,
}
await sendRealEmail(EmailTemplatePurpose.WELCOME, [attachmentObject])
})
})

View File

@ -32,6 +32,8 @@ import {
AuthToken,
SCIMConfig,
ConfigType,
SMTPConfig,
SMTPInnerConfig,
} from "@budibase/types"
import API from "./api"
import jwt, { Secret } from "jsonwebtoken"
@ -348,9 +350,15 @@ class TestConfiguration {
// CONFIGS - SMTP
async saveSmtpConfig() {
async saveSmtpConfig(config?: SMTPInnerConfig) {
await this.deleteConfig(Config.SMTP)
await this._req(structures.configs.smtp(), null, controllers.config.save)
let smtpConfig: SMTPConfig = structures.configs.smtp()
if (config) {
smtpConfig = { type: ConfigType.SMTP, config }
}
await this._req(smtpConfig, null, controllers.config.save)
}
async saveEtherealSmtpConfig() {

View File

@ -1,10 +1,148 @@
export function mock() {
// mock the email system
const sendMailMock = jest.fn()
const nodemailer = require("nodemailer")
nodemailer.createTransport.mockReturnValue({
sendMail: sendMailMock,
verify: jest.fn(),
})
return sendMailMock
import MailDev from "maildev"
import { promisify } from "util"
import TestConfiguration from "../TestConfiguration"
export type Mailserver = InstanceType<typeof MailDev>
export type MailserverConfig = ConstructorParameters<typeof MailDev>[0]
export interface Attachment {
checksum: string
contentId: string
contentType: string
fileName: string
generatedFileName: string
length: number
transferEncoding: string
transformed: boolean
}
export interface Address {
address: string
args?: boolean
name?: string
}
export interface Envelope {
from: Address
to: Address[]
host: string
remoteAddress: string
}
export interface Email {
attachments: Attachment[]
calculatedBcc: string[]
date: string
envelope: Envelope
from: Address[]
headers: Record<string, string>
html: string
id: string
messageId: string
priority: string
read: boolean
size: number
sizeHuman: string
source: string
time: Date
to: Address[]
}
export function getUnusedPort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = require("net").createServer()
server.unref()
server.on("error", reject)
server.listen(0, () => {
const port = server.address().port
server.close(() => {
resolve(port)
})
})
})
}
export function waitForEmail(
mailserver: Mailserver,
timeoutMs = 5000
): Promise<Email> {
let timeout: ReturnType<typeof setTimeout>
const timeoutPromise = new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error("Timed out waiting for email"))
}, timeoutMs)
})
const mailPromise = new Promise<Email>(resolve => {
// @ts-expect-error - types are wrong
mailserver.once("new", email => {
resolve(email as Email)
clearTimeout(timeout)
})
})
return Promise.race([mailPromise, timeoutPromise])
}
export async function captureEmail(
mailserver: Mailserver,
f: () => Promise<void>
): Promise<Email> {
const emailPromise = waitForEmail(mailserver)
await f()
return await emailPromise
}
export async function startMailserver(
config: TestConfiguration,
opts?: MailserverConfig
): Promise<Mailserver> {
if (!opts) {
opts = {}
}
if (!opts.smtp) {
opts.smtp = await getUnusedPort()
}
const mailserver = new MailDev(opts || {})
await new Promise((resolve, reject) => {
mailserver.listen(err => {
if (err) {
return reject(err)
}
resolve(mailserver)
})
})
await config.saveSmtpConfig({
host: "localhost",
port: opts.smtp,
secure: false,
from: "test@example.com",
})
return mailserver
}
export function deleteAllEmail(mailserver: Mailserver) {
return promisify(mailserver.deleteAllEmail).bind(mailserver)()
}
export function stopMailserver(mailserver: Mailserver) {
return promisify(mailserver.close).bind(mailserver)()
}
export function getAttachment(
mailserver: Mailserver,
email: Email,
attachment: Attachment
) {
return new Promise<string>(resolve => {
// @ts-expect-error - types are wrong
mailserver.getEmailAttachment(
email.id,
attachment.generatedFileName,
(err: any, _contentType: string, stream: ReadableStream) => {
if (err) {
throw err
}
resolve(new Response(stream).text())
}
)
})
}

View File

@ -7,6 +7,7 @@ import {
SendEmailOpts,
SMTPInnerConfig,
EmailTemplatePurpose,
User,
} from "@budibase/types"
import { configs, cache, objectStore } from "@budibase/backend-core"
import ical from "ical-generator"
@ -71,8 +72,8 @@ function createSMTPTransport(config?: SMTPInnerConfig) {
async function buildEmail(
purpose: EmailTemplatePurpose,
email: string,
context: any,
{ user, contents }: any = {}
context: Record<string, any>,
{ user, contents }: { user?: User; contents?: string } = {}
) {
// this isn't a full email
if (FULL_EMAIL_PURPOSES.indexOf(purpose) === -1) {
@ -90,8 +91,8 @@ async function buildEmail(
throw "Unable to build email, missing base components"
}
let name = user ? user.name : undefined
if (user && !name && user.firstName) {
let name: string | undefined
if (user && user.firstName) {
name = user.lastName ? `${user.firstName} ${user.lastName}` : user.firstName
}
context = {

713
yarn.lock

File diff suppressed because it is too large Load Diff