@budibase/worker 3.4.20 → 3.4.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/src/api/controllers/global/email.ts +5 -4
- package/src/api/routes/global/tests/email.spec.ts +251 -15
- package/src/tests/TestConfiguration.ts +10 -2
- package/src/tests/api/email.ts +10 -11
- package/src/tests/mocks/email.ts +174 -0
- package/src/utilities/email.ts +22 -26
- package/src/api/routes/global/tests/realEmail.spec.ts +0 -108
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@budibase/worker",
|
|
3
3
|
"email": "hi@budibase.com",
|
|
4
|
-
"version": "3.4.
|
|
4
|
+
"version": "3.4.22",
|
|
5
5
|
"description": "Budibase background service",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"repository": {
|
|
@@ -85,11 +85,14 @@
|
|
|
85
85
|
"@types/jsonwebtoken": "9.0.3",
|
|
86
86
|
"@types/koa__router": "12.0.4",
|
|
87
87
|
"@types/lodash": "4.14.200",
|
|
88
|
+
"@types/maildev": "^0.0.7",
|
|
88
89
|
"@types/node-fetch": "2.6.4",
|
|
90
|
+
"@types/nodemailer": "^6.4.17",
|
|
89
91
|
"@types/server-destroy": "1.0.1",
|
|
90
92
|
"@types/supertest": "2.0.14",
|
|
91
93
|
"@types/uuid": "8.3.4",
|
|
92
94
|
"jest": "29.7.0",
|
|
95
|
+
"maildev": "^2.2.1",
|
|
93
96
|
"nock": "^13.5.4",
|
|
94
97
|
"nodemon": "2.0.15",
|
|
95
98
|
"rimraf": "3.0.2",
|
|
@@ -114,5 +117,5 @@
|
|
|
114
117
|
}
|
|
115
118
|
}
|
|
116
119
|
},
|
|
117
|
-
"gitHead": "
|
|
120
|
+
"gitHead": "d13bdcbc41f1c15066ef24c19290452cd5c4dd46"
|
|
118
121
|
}
|
|
@@ -11,7 +11,6 @@ export async function sendEmail(
|
|
|
11
11
|
ctx: UserCtx<SendEmailRequest, SendEmailResponse>
|
|
12
12
|
) {
|
|
13
13
|
let {
|
|
14
|
-
workspaceId,
|
|
15
14
|
email,
|
|
16
15
|
userId,
|
|
17
16
|
purpose,
|
|
@@ -24,13 +23,15 @@ export async function sendEmail(
|
|
|
24
23
|
invite,
|
|
25
24
|
attachments,
|
|
26
25
|
} = ctx.request.body
|
|
27
|
-
let user:
|
|
26
|
+
let user: User | undefined = undefined
|
|
28
27
|
if (userId) {
|
|
29
28
|
const db = tenancy.getGlobalDB()
|
|
30
|
-
user = await db.
|
|
29
|
+
user = await db.tryGet<User>(userId)
|
|
30
|
+
if (!user) {
|
|
31
|
+
ctx.throw(404, "User not found.")
|
|
32
|
+
}
|
|
31
33
|
}
|
|
32
34
|
const response = await sendEmailFn(email, purpose, {
|
|
33
|
-
workspaceId,
|
|
34
35
|
user,
|
|
35
36
|
contents,
|
|
36
37
|
from,
|
|
@@ -1,33 +1,269 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { EmailTemplatePurpose, SendEmailRequest } from "@budibase/types"
|
|
2
|
+
import { TestConfiguration } from "../../../../tests"
|
|
3
|
+
import {
|
|
4
|
+
captureEmail,
|
|
5
|
+
deleteAllEmail,
|
|
6
|
+
getAttachments,
|
|
7
|
+
Mailserver,
|
|
8
|
+
startMailserver,
|
|
9
|
+
stopMailserver,
|
|
10
|
+
} from "../../../../tests/mocks/email"
|
|
11
|
+
import { objectStore } from "@budibase/backend-core"
|
|
6
12
|
|
|
7
13
|
describe("/api/global/email", () => {
|
|
8
14
|
const config = new TestConfiguration()
|
|
15
|
+
let mailserver: Mailserver
|
|
9
16
|
|
|
10
17
|
beforeAll(async () => {
|
|
11
18
|
await config.beforeAll()
|
|
19
|
+
mailserver = await startMailserver(config)
|
|
12
20
|
})
|
|
13
21
|
|
|
14
22
|
afterAll(async () => {
|
|
23
|
+
await stopMailserver(mailserver)
|
|
15
24
|
await config.afterAll()
|
|
16
25
|
})
|
|
17
26
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
beforeEach(async () => {
|
|
28
|
+
await deleteAllEmail(mailserver)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
interface TestCase {
|
|
32
|
+
req: Partial<SendEmailRequest>
|
|
33
|
+
expectedStatus?: number
|
|
34
|
+
expectedContents?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const testCases: TestCase[] = [
|
|
38
|
+
{
|
|
39
|
+
req: {
|
|
40
|
+
purpose: EmailTemplatePurpose.WELCOME,
|
|
41
|
+
},
|
|
42
|
+
expectedContents: `Thanks for getting started with Budibase's Budibase platform.`,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
req: {
|
|
46
|
+
purpose: EmailTemplatePurpose.INVITATION,
|
|
47
|
+
},
|
|
48
|
+
expectedContents: `Use the button below to set up your account and get started:`,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
req: {
|
|
52
|
+
purpose: EmailTemplatePurpose.PASSWORD_RECOVERY,
|
|
53
|
+
},
|
|
54
|
+
expectedContents: `You recently requested to reset your password for your Budibase account in your Budibase platform`,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
req: {
|
|
58
|
+
purpose: EmailTemplatePurpose.CUSTOM,
|
|
59
|
+
contents: "Hello, world!",
|
|
60
|
+
},
|
|
61
|
+
expectedContents: "Hello, world!",
|
|
62
|
+
},
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
it.each(testCases)(
|
|
66
|
+
"can send $req.purpose emails",
|
|
67
|
+
async ({ req, expectedContents, expectedStatus }) => {
|
|
68
|
+
const email = await captureEmail(mailserver, async () => {
|
|
69
|
+
const res = await config.api.emails.sendEmail(
|
|
70
|
+
{
|
|
71
|
+
email: "to@example.com",
|
|
72
|
+
subject: "Test",
|
|
73
|
+
userId: config.user!._id,
|
|
74
|
+
purpose: EmailTemplatePurpose.WELCOME,
|
|
75
|
+
...req,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
status: expectedStatus || 200,
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
expect(res.message).toBeDefined()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
expect(email.html).toContain(expectedContents)
|
|
85
|
+
expect(email.html).not.toContain("Invalid binding")
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
it("should be able to send an email with an attachment", async () => {
|
|
90
|
+
let bucket = "testbucket"
|
|
91
|
+
let filename = "test.txt"
|
|
92
|
+
await objectStore.upload({
|
|
93
|
+
bucket,
|
|
94
|
+
filename,
|
|
95
|
+
body: Buffer.from("test data"),
|
|
96
|
+
})
|
|
97
|
+
let presignedUrl = await objectStore.getPresignedUrl(
|
|
98
|
+
bucket,
|
|
99
|
+
filename,
|
|
100
|
+
60000
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
let attachmentObject = {
|
|
104
|
+
url: presignedUrl,
|
|
105
|
+
filename,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const email = await captureEmail(mailserver, async () => {
|
|
109
|
+
const res = await config.api.emails.sendEmail({
|
|
110
|
+
email: "to@example.com",
|
|
111
|
+
subject: "Test",
|
|
112
|
+
userId: config.user!._id,
|
|
113
|
+
purpose: EmailTemplatePurpose.WELCOME,
|
|
114
|
+
attachments: [attachmentObject],
|
|
115
|
+
})
|
|
116
|
+
expect(res.message).toBeDefined()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
expect(email.html).toContain(
|
|
120
|
+
"Thanks for getting started with Budibase's Budibase platform."
|
|
121
|
+
)
|
|
122
|
+
expect(email.html).not.toContain("Invalid binding")
|
|
123
|
+
|
|
124
|
+
const attachments = await getAttachments(mailserver, email)
|
|
125
|
+
expect(attachments).toEqual(["test data"])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("should be able to send email without a userId", async () => {
|
|
129
|
+
const res = await config.api.emails.sendEmail({
|
|
130
|
+
email: "to@example.com",
|
|
131
|
+
subject: "Test",
|
|
132
|
+
purpose: EmailTemplatePurpose.WELCOME,
|
|
133
|
+
})
|
|
134
|
+
expect(res.message).toBeDefined()
|
|
135
|
+
})
|
|
22
136
|
|
|
137
|
+
it("should fail to send a password reset email without a userId", async () => {
|
|
23
138
|
const res = await config.api.emails.sendEmail(
|
|
24
|
-
|
|
139
|
+
{
|
|
140
|
+
email: "to@example.com",
|
|
141
|
+
subject: "Test",
|
|
142
|
+
purpose: EmailTemplatePurpose.PASSWORD_RECOVERY,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
status: 400,
|
|
146
|
+
}
|
|
25
147
|
)
|
|
148
|
+
expect(res.message).toBeDefined()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("can cc people", async () => {
|
|
152
|
+
const email = await captureEmail(mailserver, async () => {
|
|
153
|
+
await config.api.emails.sendEmail({
|
|
154
|
+
email: "to@example.com",
|
|
155
|
+
cc: "cc@example.com",
|
|
156
|
+
subject: "Test",
|
|
157
|
+
purpose: EmailTemplatePurpose.CUSTOM,
|
|
158
|
+
contents: "Hello, world!",
|
|
159
|
+
})
|
|
160
|
+
})
|
|
26
161
|
|
|
27
|
-
expect(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
162
|
+
expect(email.cc).toEqual([{ address: "cc@example.com", name: "" }])
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("can bcc people", async () => {
|
|
166
|
+
const email = await captureEmail(mailserver, async () => {
|
|
167
|
+
await config.api.emails.sendEmail({
|
|
168
|
+
email: "to@example.com",
|
|
169
|
+
bcc: "bcc@example.com",
|
|
170
|
+
subject: "Test",
|
|
171
|
+
purpose: EmailTemplatePurpose.CUSTOM,
|
|
172
|
+
contents: "Hello, world!",
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
expect(email.calculatedBcc).toEqual([
|
|
177
|
+
{ address: "bcc@example.com", name: "" },
|
|
178
|
+
])
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("can change the from address", async () => {
|
|
182
|
+
const email = await captureEmail(mailserver, async () => {
|
|
183
|
+
const res = await config.api.emails.sendEmail({
|
|
184
|
+
email: "to@example.com",
|
|
185
|
+
from: "from@example.com",
|
|
186
|
+
subject: "Test",
|
|
187
|
+
purpose: EmailTemplatePurpose.CUSTOM,
|
|
188
|
+
contents: "Hello, world!",
|
|
189
|
+
})
|
|
190
|
+
expect(res.message).toBeDefined()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
expect(email.to).toEqual([{ address: "to@example.com", name: "" }])
|
|
194
|
+
expect(email.from).toEqual([{ address: "from@example.com", name: "" }])
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("can send a calendar invite", async () => {
|
|
198
|
+
const startTime = new Date()
|
|
199
|
+
const endTime = new Date()
|
|
200
|
+
|
|
201
|
+
const email = await captureEmail(mailserver, async () => {
|
|
202
|
+
await config.api.emails.sendEmail({
|
|
203
|
+
email: "to@example.com",
|
|
204
|
+
subject: "Test",
|
|
205
|
+
purpose: EmailTemplatePurpose.CUSTOM,
|
|
206
|
+
contents: "Hello, world!",
|
|
207
|
+
invite: {
|
|
208
|
+
startTime,
|
|
209
|
+
endTime,
|
|
210
|
+
summary: "Summary",
|
|
211
|
+
location: "Location",
|
|
212
|
+
url: "http://example.com",
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
expect(email.alternatives).toEqual([
|
|
218
|
+
{
|
|
219
|
+
charset: "utf-8",
|
|
220
|
+
contentType: "text/calendar",
|
|
221
|
+
method: "REQUEST",
|
|
222
|
+
transferEncoding: "7bit",
|
|
223
|
+
content: expect.any(String),
|
|
224
|
+
},
|
|
225
|
+
])
|
|
226
|
+
|
|
227
|
+
// Reference iCal invite:
|
|
228
|
+
// BEGIN:VCALENDAR
|
|
229
|
+
// VERSION:2.0
|
|
230
|
+
// PRODID:-//sebbo.net//ical-generator//EN
|
|
231
|
+
// NAME:Invite
|
|
232
|
+
// X-WR-CALNAME:Invite
|
|
233
|
+
// BEGIN:VEVENT
|
|
234
|
+
// UID:2b5947b7-ec5a-4341-8d70-8d8130183f2a
|
|
235
|
+
// SEQUENCE:0
|
|
236
|
+
// DTSTAMP:20200101T000000Z
|
|
237
|
+
// DTSTART:20200101T000000Z
|
|
238
|
+
// DTEND:20200101T000000Z
|
|
239
|
+
// SUMMARY:Summary
|
|
240
|
+
// LOCATION:Location
|
|
241
|
+
// URL;VALUE=URI:http://example.com
|
|
242
|
+
// END:VEVENT
|
|
243
|
+
// END:VCALENDAR
|
|
244
|
+
expect(email.alternatives[0].content).toContain("BEGIN:VCALENDAR")
|
|
245
|
+
expect(email.alternatives[0].content).toContain("BEGIN:VEVENT")
|
|
246
|
+
expect(email.alternatives[0].content).toContain("UID:")
|
|
247
|
+
expect(email.alternatives[0].content).toContain("SEQUENCE:0")
|
|
248
|
+
expect(email.alternatives[0].content).toContain("SUMMARY:Summary")
|
|
249
|
+
expect(email.alternatives[0].content).toContain("LOCATION:Location")
|
|
250
|
+
expect(email.alternatives[0].content).toContain(
|
|
251
|
+
"URL;VALUE=URI:http://example.com"
|
|
252
|
+
)
|
|
253
|
+
expect(email.alternatives[0].content).toContain("END:VEVENT")
|
|
254
|
+
expect(email.alternatives[0].content).toContain("END:VCALENDAR")
|
|
255
|
+
|
|
256
|
+
const formatDate = (date: Date) =>
|
|
257
|
+
date.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"
|
|
258
|
+
|
|
259
|
+
expect(email.alternatives[0].content).toContain(
|
|
260
|
+
`DTSTAMP:${formatDate(startTime)}`
|
|
261
|
+
)
|
|
262
|
+
expect(email.alternatives[0].content).toContain(
|
|
263
|
+
`DTSTART:${formatDate(startTime)}`
|
|
264
|
+
)
|
|
265
|
+
expect(email.alternatives[0].content).toContain(
|
|
266
|
+
`DTEND:${formatDate(endTime)}`
|
|
267
|
+
)
|
|
32
268
|
})
|
|
33
269
|
})
|
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
AuthToken,
|
|
33
33
|
SCIMConfig,
|
|
34
34
|
ConfigType,
|
|
35
|
+
SMTPConfig,
|
|
36
|
+
SMTPInnerConfig,
|
|
35
37
|
} from "@budibase/types"
|
|
36
38
|
import API from "./api"
|
|
37
39
|
import jwt, { Secret } from "jsonwebtoken"
|
|
@@ -348,9 +350,15 @@ class TestConfiguration {
|
|
|
348
350
|
|
|
349
351
|
// CONFIGS - SMTP
|
|
350
352
|
|
|
351
|
-
async saveSmtpConfig() {
|
|
353
|
+
async saveSmtpConfig(config?: SMTPInnerConfig) {
|
|
352
354
|
await this.deleteConfig(Config.SMTP)
|
|
353
|
-
|
|
355
|
+
|
|
356
|
+
let smtpConfig: SMTPConfig = structures.configs.smtp()
|
|
357
|
+
if (config) {
|
|
358
|
+
smtpConfig = { type: ConfigType.SMTP, config }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
await this._req(smtpConfig, null, controllers.config.save)
|
|
354
362
|
}
|
|
355
363
|
|
|
356
364
|
async saveEtherealSmtpConfig() {
|
package/src/tests/api/email.ts
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { SendEmailRequest, SendEmailResponse } from "@budibase/types"
|
|
2
2
|
import { TestAPI } from "./base"
|
|
3
3
|
|
|
4
4
|
export class EmailAPI extends TestAPI {
|
|
5
|
-
sendEmail = (
|
|
6
|
-
|
|
5
|
+
sendEmail = async (
|
|
6
|
+
req: SendEmailRequest,
|
|
7
|
+
expectations?: { status?: number }
|
|
8
|
+
): Promise<SendEmailResponse> => {
|
|
9
|
+
const res = await this.request
|
|
7
10
|
.post(`/api/global/email/send`)
|
|
8
|
-
.send(
|
|
9
|
-
email: "test@example.com",
|
|
10
|
-
attachments,
|
|
11
|
-
purpose,
|
|
12
|
-
tenantId: this.config.getTenantId(),
|
|
13
|
-
userId: this.config.user!._id!,
|
|
14
|
-
})
|
|
11
|
+
.send(req)
|
|
15
12
|
.set(this.config.defaultHeaders())
|
|
16
13
|
.expect("Content-Type", /json/)
|
|
17
|
-
.expect(200)
|
|
14
|
+
.expect(expectations?.status || 200)
|
|
15
|
+
|
|
16
|
+
return res.body as SendEmailResponse
|
|
18
17
|
}
|
|
19
18
|
}
|
package/src/tests/mocks/email.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import MailDev from "maildev"
|
|
2
|
+
import { promisify } from "util"
|
|
3
|
+
import TestConfiguration from "../TestConfiguration"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @deprecated please use the `MailDev` email server instead of this mock.
|
|
7
|
+
*/
|
|
1
8
|
export function mock() {
|
|
2
9
|
// mock the email system
|
|
3
10
|
const sendMailMock = jest.fn()
|
|
@@ -8,3 +15,170 @@ export function mock() {
|
|
|
8
15
|
})
|
|
9
16
|
return sendMailMock
|
|
10
17
|
}
|
|
18
|
+
|
|
19
|
+
export type Mailserver = InstanceType<typeof MailDev>
|
|
20
|
+
export type MailserverConfig = ConstructorParameters<typeof MailDev>[0]
|
|
21
|
+
|
|
22
|
+
export interface Attachment {
|
|
23
|
+
checksum: string
|
|
24
|
+
contentId: string
|
|
25
|
+
contentType: string
|
|
26
|
+
fileName: string
|
|
27
|
+
generatedFileName: string
|
|
28
|
+
length: number
|
|
29
|
+
transferEncoding: string
|
|
30
|
+
transformed: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Address {
|
|
34
|
+
address: string
|
|
35
|
+
args?: boolean
|
|
36
|
+
name?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Alternative {
|
|
40
|
+
contentType: string
|
|
41
|
+
content: string
|
|
42
|
+
charset: string
|
|
43
|
+
method: string
|
|
44
|
+
transferEncoding: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface Envelope {
|
|
48
|
+
from: Address
|
|
49
|
+
to: Address[]
|
|
50
|
+
host: string
|
|
51
|
+
remoteAddress: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Email {
|
|
55
|
+
attachments: Attachment[]
|
|
56
|
+
alternatives: Alternative[]
|
|
57
|
+
calculatedBcc: Address[]
|
|
58
|
+
cc: Address[]
|
|
59
|
+
date: string
|
|
60
|
+
envelope: Envelope
|
|
61
|
+
from: Address[]
|
|
62
|
+
headers: Record<string, string>
|
|
63
|
+
html: string
|
|
64
|
+
id: string
|
|
65
|
+
messageId: string
|
|
66
|
+
priority: string
|
|
67
|
+
read: boolean
|
|
68
|
+
size: number
|
|
69
|
+
sizeHuman: string
|
|
70
|
+
source: string
|
|
71
|
+
time: Date
|
|
72
|
+
to: Address[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getUnusedPort(): Promise<number> {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const server = require("net").createServer()
|
|
78
|
+
server.unref()
|
|
79
|
+
server.on("error", reject)
|
|
80
|
+
server.listen(0, () => {
|
|
81
|
+
const port = server.address().port
|
|
82
|
+
server.close(() => {
|
|
83
|
+
resolve(port)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function captureEmail(
|
|
90
|
+
mailserver: Mailserver,
|
|
91
|
+
f: () => Promise<void>
|
|
92
|
+
): Promise<Email> {
|
|
93
|
+
const timeoutMs = 5000
|
|
94
|
+
let timeout: ReturnType<typeof setTimeout> | undefined = undefined
|
|
95
|
+
const cancel = () => {
|
|
96
|
+
if (timeout) {
|
|
97
|
+
clearTimeout(timeout)
|
|
98
|
+
timeout = undefined
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
102
|
+
timeout = setTimeout(() => {
|
|
103
|
+
reject(new Error("Timed out waiting for email"))
|
|
104
|
+
}, timeoutMs)
|
|
105
|
+
})
|
|
106
|
+
const mailPromise = new Promise<Email>(resolve => {
|
|
107
|
+
// @ts-expect-error - types are wrong
|
|
108
|
+
mailserver.once("new", email => {
|
|
109
|
+
resolve(email as Email)
|
|
110
|
+
cancel()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
const emailPromise = Promise.race([mailPromise, timeoutPromise])
|
|
114
|
+
try {
|
|
115
|
+
await f()
|
|
116
|
+
} finally {
|
|
117
|
+
cancel()
|
|
118
|
+
}
|
|
119
|
+
return await emailPromise
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function startMailserver(
|
|
123
|
+
config: TestConfiguration,
|
|
124
|
+
opts?: MailserverConfig
|
|
125
|
+
): Promise<Mailserver> {
|
|
126
|
+
if (!opts) {
|
|
127
|
+
opts = {}
|
|
128
|
+
}
|
|
129
|
+
if (!opts.smtp) {
|
|
130
|
+
opts.smtp = await getUnusedPort()
|
|
131
|
+
}
|
|
132
|
+
const mailserver = new MailDev(opts || {})
|
|
133
|
+
await new Promise((resolve, reject) => {
|
|
134
|
+
mailserver.listen(err => {
|
|
135
|
+
if (err) {
|
|
136
|
+
return reject(err)
|
|
137
|
+
}
|
|
138
|
+
resolve(mailserver)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
await config.saveSmtpConfig({
|
|
142
|
+
host: "localhost",
|
|
143
|
+
port: opts.smtp,
|
|
144
|
+
secure: false,
|
|
145
|
+
from: "test@example.com",
|
|
146
|
+
})
|
|
147
|
+
return mailserver
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function deleteAllEmail(mailserver: Mailserver) {
|
|
151
|
+
return promisify(mailserver.deleteAllEmail).bind(mailserver)()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function stopMailserver(mailserver: Mailserver) {
|
|
155
|
+
return promisify(mailserver.close).bind(mailserver)()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function getAttachment(
|
|
159
|
+
mailserver: Mailserver,
|
|
160
|
+
email: Email,
|
|
161
|
+
attachment: Attachment
|
|
162
|
+
) {
|
|
163
|
+
return new Promise<string>(resolve => {
|
|
164
|
+
// @ts-expect-error - types are wrong
|
|
165
|
+
mailserver.getEmailAttachment(
|
|
166
|
+
email.id,
|
|
167
|
+
attachment.generatedFileName,
|
|
168
|
+
(err: any, _contentType: string, stream: ReadableStream) => {
|
|
169
|
+
if (err) {
|
|
170
|
+
throw err
|
|
171
|
+
}
|
|
172
|
+
resolve(new Response(stream).text())
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function getAttachments(mailserver: Mailserver, email: Email) {
|
|
179
|
+
return Promise.all(
|
|
180
|
+
email.attachments.map(attachment =>
|
|
181
|
+
getAttachment(mailserver, email, attachment)
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
}
|
package/src/utilities/email.ts
CHANGED
|
@@ -4,16 +4,17 @@ import { getTemplateByPurpose, EmailTemplates } from "../constants/templates"
|
|
|
4
4
|
import { getSettingsTemplateContext } from "./templates"
|
|
5
5
|
import { processString } from "@budibase/string-templates"
|
|
6
6
|
import {
|
|
7
|
-
User,
|
|
8
7
|
SendEmailOpts,
|
|
9
8
|
SMTPInnerConfig,
|
|
10
9
|
EmailTemplatePurpose,
|
|
10
|
+
User,
|
|
11
11
|
} from "@budibase/types"
|
|
12
|
-
import { configs, cache, objectStore } from "@budibase/backend-core"
|
|
12
|
+
import { configs, cache, objectStore, HTTPError } from "@budibase/backend-core"
|
|
13
13
|
import ical from "ical-generator"
|
|
14
14
|
import _ from "lodash"
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
import nodemailer from "nodemailer"
|
|
17
|
+
import SMTPTransport from "nodemailer/lib/smtp-transport"
|
|
17
18
|
|
|
18
19
|
const TEST_MODE = env.ENABLE_EMAIL_TEST_MODE && env.isDev()
|
|
19
20
|
const TYPE = TemplateType.EMAIL
|
|
@@ -26,7 +27,7 @@ const FULL_EMAIL_PURPOSES = [
|
|
|
26
27
|
]
|
|
27
28
|
|
|
28
29
|
function createSMTPTransport(config?: SMTPInnerConfig) {
|
|
29
|
-
let options:
|
|
30
|
+
let options: SMTPTransport.Options
|
|
30
31
|
let secure = config?.secure
|
|
31
32
|
// default it if not specified
|
|
32
33
|
if (secure == null) {
|
|
@@ -59,22 +60,6 @@ function createSMTPTransport(config?: SMTPInnerConfig) {
|
|
|
59
60
|
return nodemailer.createTransport(options)
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
async function getLinkCode(
|
|
63
|
-
purpose: EmailTemplatePurpose,
|
|
64
|
-
email: string,
|
|
65
|
-
user: User,
|
|
66
|
-
info: any = null
|
|
67
|
-
) {
|
|
68
|
-
switch (purpose) {
|
|
69
|
-
case EmailTemplatePurpose.PASSWORD_RECOVERY:
|
|
70
|
-
return cache.passwordReset.createCode(user._id!, info)
|
|
71
|
-
case EmailTemplatePurpose.INVITATION:
|
|
72
|
-
return cache.invite.createCode(email, info)
|
|
73
|
-
default:
|
|
74
|
-
return null
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
63
|
/**
|
|
79
64
|
* Builds an email using handlebars and the templates found in the system (default or otherwise).
|
|
80
65
|
* @param purpose the purpose of the email being built, e.g. invitation, password reset.
|
|
@@ -87,8 +72,8 @@ async function getLinkCode(
|
|
|
87
72
|
async function buildEmail(
|
|
88
73
|
purpose: EmailTemplatePurpose,
|
|
89
74
|
email: string,
|
|
90
|
-
context: any
|
|
91
|
-
{ user, contents }:
|
|
75
|
+
context: Record<string, any>,
|
|
76
|
+
{ user, contents }: { user?: User; contents?: string } = {}
|
|
92
77
|
) {
|
|
93
78
|
// this isn't a full email
|
|
94
79
|
if (FULL_EMAIL_PURPOSES.indexOf(purpose) === -1) {
|
|
@@ -106,8 +91,8 @@ async function buildEmail(
|
|
|
106
91
|
throw "Unable to build email, missing base components"
|
|
107
92
|
}
|
|
108
93
|
|
|
109
|
-
let name
|
|
110
|
-
if (user &&
|
|
94
|
+
let name: string | undefined
|
|
95
|
+
if (user && user.firstName) {
|
|
111
96
|
name = user.lastName ? `${user.firstName} ${user.lastName}` : user.firstName
|
|
112
97
|
}
|
|
113
98
|
context = {
|
|
@@ -158,10 +143,21 @@ export async function sendEmail(
|
|
|
158
143
|
}
|
|
159
144
|
const transport = createSMTPTransport(config)
|
|
160
145
|
// if there is a link code needed this will retrieve it
|
|
161
|
-
|
|
146
|
+
let code: string | null = null
|
|
147
|
+
switch (purpose) {
|
|
148
|
+
case EmailTemplatePurpose.PASSWORD_RECOVERY:
|
|
149
|
+
if (!opts.user || !opts.user._id) {
|
|
150
|
+
throw new HTTPError("User must be provided for password recovery.", 400)
|
|
151
|
+
}
|
|
152
|
+
code = await cache.passwordReset.createCode(opts.user._id, opts.info)
|
|
153
|
+
break
|
|
154
|
+
case EmailTemplatePurpose.INVITATION:
|
|
155
|
+
code = await cache.invite.createCode(email, opts.info)
|
|
156
|
+
break
|
|
157
|
+
}
|
|
162
158
|
let context = await getSettingsTemplateContext(purpose, code)
|
|
163
159
|
|
|
164
|
-
let message:
|
|
160
|
+
let message: Parameters<typeof transport.sendMail>[0] = {
|
|
165
161
|
from: opts?.from || config?.from,
|
|
166
162
|
html: await buildEmail(purpose, email, context, {
|
|
167
163
|
user: opts?.user,
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
jest.unmock("node-fetch")
|
|
2
|
-
import { TestConfiguration } from "../../../../tests"
|
|
3
|
-
import { objectStore } from "@budibase/backend-core"
|
|
4
|
-
import { helpers } from "@budibase/shared-core"
|
|
5
|
-
|
|
6
|
-
import tk from "timekeeper"
|
|
7
|
-
import { EmailAttachment, EmailTemplatePurpose } from "@budibase/types"
|
|
8
|
-
|
|
9
|
-
const fetch = require("node-fetch")
|
|
10
|
-
|
|
11
|
-
const nodemailer = require("nodemailer")
|
|
12
|
-
|
|
13
|
-
// for the real email tests give them a long time to try complete/fail
|
|
14
|
-
jest.setTimeout(30000)
|
|
15
|
-
|
|
16
|
-
describe("/api/global/email", () => {
|
|
17
|
-
const config = new TestConfiguration()
|
|
18
|
-
|
|
19
|
-
beforeAll(async () => {
|
|
20
|
-
tk.reset()
|
|
21
|
-
await config.beforeAll()
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
afterAll(async () => {
|
|
25
|
-
await config.afterAll()
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
async function sendRealEmail(
|
|
29
|
-
purpose: string,
|
|
30
|
-
attachments?: EmailAttachment[]
|
|
31
|
-
) {
|
|
32
|
-
let response, text
|
|
33
|
-
try {
|
|
34
|
-
await helpers.withTimeout(20000, () => config.saveEtherealSmtpConfig())
|
|
35
|
-
await helpers.withTimeout(20000, () => config.saveSettingsConfig())
|
|
36
|
-
let res
|
|
37
|
-
if (attachments) {
|
|
38
|
-
res = await config.api.emails
|
|
39
|
-
.sendEmail(purpose, attachments)
|
|
40
|
-
.timeout(20000)
|
|
41
|
-
} else {
|
|
42
|
-
res = await config.api.emails.sendEmail(purpose).timeout(20000)
|
|
43
|
-
}
|
|
44
|
-
// ethereal hiccup, can't test right now
|
|
45
|
-
if (res.status >= 300) {
|
|
46
|
-
return
|
|
47
|
-
}
|
|
48
|
-
expect(res.body.message).toBeDefined()
|
|
49
|
-
const testUrl = nodemailer.getTestMessageUrl(res.body)
|
|
50
|
-
expect(testUrl).toBeDefined()
|
|
51
|
-
response = await fetch(testUrl)
|
|
52
|
-
text = await response.text()
|
|
53
|
-
} catch (err: any) {
|
|
54
|
-
// ethereal hiccup, can't test right now
|
|
55
|
-
if (parseInt(err.status) >= 300 || (err && err.errno === "ETIME")) {
|
|
56
|
-
return
|
|
57
|
-
} else {
|
|
58
|
-
throw err
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
let toCheckFor
|
|
62
|
-
switch (purpose) {
|
|
63
|
-
case EmailTemplatePurpose.WELCOME:
|
|
64
|
-
toCheckFor = `Thanks for getting started with Budibase's Budibase platform.`
|
|
65
|
-
break
|
|
66
|
-
case EmailTemplatePurpose.INVITATION:
|
|
67
|
-
toCheckFor = `Use the button below to set up your account and get started:`
|
|
68
|
-
break
|
|
69
|
-
case EmailTemplatePurpose.PASSWORD_RECOVERY:
|
|
70
|
-
toCheckFor = `You recently requested to reset your password for your Budibase account in your Budibase platform`
|
|
71
|
-
break
|
|
72
|
-
}
|
|
73
|
-
expect(text).toContain(toCheckFor)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
it("should be able to send a welcome email", async () => {
|
|
77
|
-
await sendRealEmail(EmailTemplatePurpose.WELCOME)
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it("should be able to send a invitation email", async () => {
|
|
81
|
-
await sendRealEmail(EmailTemplatePurpose.INVITATION)
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
it("should be able to send a password recovery email", async () => {
|
|
85
|
-
await sendRealEmail(EmailTemplatePurpose.PASSWORD_RECOVERY)
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it("should be able to send an email with attachments", async () => {
|
|
89
|
-
let bucket = "testbucket"
|
|
90
|
-
let filename = "test.txt"
|
|
91
|
-
await objectStore.upload({
|
|
92
|
-
bucket,
|
|
93
|
-
filename,
|
|
94
|
-
body: Buffer.from("test data"),
|
|
95
|
-
})
|
|
96
|
-
let presignedUrl = await objectStore.getPresignedUrl(
|
|
97
|
-
bucket,
|
|
98
|
-
filename,
|
|
99
|
-
60000
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
let attachmentObject = {
|
|
103
|
-
url: presignedUrl,
|
|
104
|
-
filename,
|
|
105
|
-
}
|
|
106
|
-
await sendRealEmail(EmailTemplatePurpose.WELCOME, [attachmentObject])
|
|
107
|
-
})
|
|
108
|
-
})
|