@stamhoofd/email 2.1.1
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 +25 -0
- package/src/classes/Email.test.ts +28 -0
- package/src/classes/Email.ts +374 -0
- package/src/index.ts +2 -0
- package/src/migrations/1603433451-email-addresses.sql +14 -0
- package/src/models/EmailAddress.ts +144 -0
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stamhoofd/email",
|
|
3
|
+
"version": "2.1.1",
|
|
4
|
+
"main": "./dist/index.js",
|
|
5
|
+
"types": "./dist/index.d.ts",
|
|
6
|
+
"license": "UNLICENCED",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": [
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -b && mkdir -p ./dist/migrations && cp ./src/migrations/*.sql ./dist/migrations",
|
|
13
|
+
"build:full": "rm -rf ./dist && yarn build"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"@simonbackx/simple-errors": "^1.4"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/nodemailer": "6.4.14"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"html-to-text": "^8.1.0",
|
|
23
|
+
"nodemailer": "6.9.13"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Email } from "./Email"
|
|
2
|
+
|
|
3
|
+
describe("Email", () => {
|
|
4
|
+
it("should parse e-mail strings correctly", () => {
|
|
5
|
+
expect(Email.parseEmailStr('"My crazy name" <hallo@stamhoofd.be>')).toEqual(["hallo@stamhoofd.be"])
|
|
6
|
+
expect(Email.parseEmailStr('hallo@stamhoofd.be')).toEqual(["hallo@stamhoofd.be"])
|
|
7
|
+
expect(Email.parseEmailStr('hallo@stamhoofd.be ')).toEqual(["hallo@stamhoofd.be"])
|
|
8
|
+
expect(Email.parseEmailStr('"hallo@stamhoofd.be"')).toEqual(["hallo@stamhoofd.be"])
|
|
9
|
+
expect(Email.parseEmailStr('<"hallo@stamhoofd.be">')).toEqual(["hallo@stamhoofd.be"])
|
|
10
|
+
expect(Email.parseEmailStr('Gekke naam <"hallo@stamhoofd.be">')).toEqual(["hallo@stamhoofd.be"])
|
|
11
|
+
expect(Email.parseEmailStr('<"hallo@stam\\"hoofd.be">')).toEqual([])
|
|
12
|
+
expect(Email.parseEmailStr('<"hallo@stam,hoofd.be">')).toEqual([])
|
|
13
|
+
expect(Email.parseEmailStr('<hallo@stam,hoofd.be>')).toEqual([])
|
|
14
|
+
expect(Email.parseEmailStr('"Hallo <dit is een test>" <hallo@stamhoofd.be>')).toEqual(["hallo@stamhoofd.be"])
|
|
15
|
+
expect(Email.parseEmailStr('"Hallo 👋🏽" <hallo@stamhoofd.be>')).toEqual(["hallo@stamhoofd.be"])
|
|
16
|
+
expect(Email.parseEmailStr('Hallo <hallo@stam.hoofd.be>')).toEqual(["hallo@stam.hoofd.be"])
|
|
17
|
+
expect(Email.parseEmailStr('Hallo <hallo@stam.hoofd.be')).toEqual([])
|
|
18
|
+
|
|
19
|
+
expect(Email.parseEmailStr('"Hallo <dit is \\" een test>" <hallo@stamhoofd.be>')).toEqual(["hallo@stamhoofd.be"])
|
|
20
|
+
expect(Email.parseEmailStr('"Hallo <dit is \\" een test佐>" <hallo@stamhoofd.be>')).toEqual(["hallo@stamhoofd.be"])
|
|
21
|
+
|
|
22
|
+
expect(Email.parseEmailStr('"Voornaam \'l Achternaam, en nog iets" <jwz@netscape.com>')).toEqual(["jwz@netscape.com"])
|
|
23
|
+
expect(Email.parseEmailStr('"Voornaam \'l Achternaam, en nog iets" <jwz@netscape.com>, "My crazy name" <hallo@stamhoofd.be>')).toEqual(["jwz@netscape.com", "hallo@stamhoofd.be"])
|
|
24
|
+
|
|
25
|
+
expect(Email.parseEmailStr('hallo@example.com, test@test.com')).toEqual(["hallo@example.com", "test@test.com"])
|
|
26
|
+
expect(Email.parseEmailStr('hallo@example.com, invalid, test@test.com')).toEqual(["hallo@example.com", "test@test.com"])
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { DataValidator, Formatter } from '@stamhoofd/utility';
|
|
2
|
+
import nodemailer from "nodemailer"
|
|
3
|
+
import Mail from 'nodemailer/lib/mailer';
|
|
4
|
+
import { EmailAddress } from '../models/EmailAddress';
|
|
5
|
+
import htmlToText from 'html-to-text';
|
|
6
|
+
import { sleep } from '@stamhoofd/utility';
|
|
7
|
+
import { I18n } from "@stamhoofd/backend-i18n"
|
|
8
|
+
|
|
9
|
+
export type EmailInterfaceRecipient = {
|
|
10
|
+
name?: string|null;
|
|
11
|
+
email: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type EmailInterfaceBase = {
|
|
15
|
+
to: string|EmailInterfaceRecipient[];
|
|
16
|
+
bcc?: string;
|
|
17
|
+
replyTo?: string;
|
|
18
|
+
subject: string;
|
|
19
|
+
text?: string;
|
|
20
|
+
html?: string;
|
|
21
|
+
attachments?: { filename: string; path?: string; href?: string; content?: string|Buffer; contentType?: string }[];
|
|
22
|
+
retryCount?: number;
|
|
23
|
+
type?: "transactional" | "broadcast",
|
|
24
|
+
headers?: Record<string, string>|null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type EmailInterface = EmailInterfaceBase & {
|
|
28
|
+
from: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// An email builder is called until it returns undefined. This allows to reduce memory usage for an e-mail with multiple recipients
|
|
32
|
+
export type EmailBuilder = () => EmailInterface | undefined
|
|
33
|
+
|
|
34
|
+
class EmailStatic {
|
|
35
|
+
transporter: Mail;
|
|
36
|
+
transactionalTransporter: Mail;
|
|
37
|
+
rps = 14
|
|
38
|
+
|
|
39
|
+
currentQueue: EmailBuilder[] = []
|
|
40
|
+
sending = false
|
|
41
|
+
|
|
42
|
+
setupIfNeeded() {
|
|
43
|
+
if (this.transporter) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!STAMHOOFD.SMTP_HOST || !STAMHOOFD.SMTP_PORT) {
|
|
47
|
+
throw new Error("Missing environment variables to send emails");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// create reusable transporter object using the default SMTP transport
|
|
52
|
+
this.transporter = nodemailer.createTransport({
|
|
53
|
+
pool: true,
|
|
54
|
+
host: STAMHOOFD.SMTP_HOST,
|
|
55
|
+
port: STAMHOOFD.SMTP_PORT,
|
|
56
|
+
auth: {
|
|
57
|
+
user: STAMHOOFD.SMTP_USERNAME, // generated ethereal user
|
|
58
|
+
pass: STAMHOOFD.SMTP_PASSWORD // generated ethereal password
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// create reusable transporter object using the default SMTP transport
|
|
63
|
+
this.transactionalTransporter = nodemailer.createTransport({
|
|
64
|
+
pool: true,
|
|
65
|
+
host: STAMHOOFD.TRANSACTIONAL_SMTP_HOST,
|
|
66
|
+
port: STAMHOOFD.TRANSACTIONAL_SMTP_PORT,
|
|
67
|
+
auth: {
|
|
68
|
+
user: STAMHOOFD.TRANSACTIONAL_SMTP_USERNAME, // generated ethereal user
|
|
69
|
+
pass: STAMHOOFD.TRANSACTIONAL_SMTP_PASSWORD // generated ethereal password
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// verify connection configuration
|
|
74
|
+
this.transporter.verify((error) => {
|
|
75
|
+
if (error) {
|
|
76
|
+
console.error("SMTP server not working", error);
|
|
77
|
+
} else {
|
|
78
|
+
console.log("SMTP server is ready to take our messages");
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// verify connection configuration
|
|
83
|
+
this.transactionalTransporter.verify((error) => {
|
|
84
|
+
if (error) {
|
|
85
|
+
console.error("Transactinoal SMTP server not working", error);
|
|
86
|
+
} else {
|
|
87
|
+
console.log("Transactinoal SMTP server is ready to take our messages");
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private sendNextIfNeeded() {
|
|
93
|
+
if (!this.sending) {
|
|
94
|
+
if (this.currentQueue.length == 0) {
|
|
95
|
+
console.log("mail queue is empty")
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
let next = this.currentQueue[0]()
|
|
99
|
+
|
|
100
|
+
while (next === undefined) {
|
|
101
|
+
this.currentQueue.shift()
|
|
102
|
+
if (this.currentQueue.length == 0) {
|
|
103
|
+
console.log("mail queue is empty")
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
next = this.currentQueue[0]()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.sending = true;
|
|
110
|
+
this.doSend(next).catch(e => {
|
|
111
|
+
console.error(e)
|
|
112
|
+
}).finally(() => {
|
|
113
|
+
this.sending = false
|
|
114
|
+
this.sendNextIfNeeded()
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
parseTo(to: string|EmailInterfaceRecipient[]): EmailInterfaceRecipient[] {
|
|
120
|
+
if (typeof to === "string") {
|
|
121
|
+
return this.parseEmailStr(to).map(email => ({ email }))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Filter invalid email addresses
|
|
125
|
+
return to.filter(r => DataValidator.isEmailValid(r.email))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get the raw email
|
|
130
|
+
*/
|
|
131
|
+
parseEmailStr(emailStr: string): string[] {
|
|
132
|
+
let insideQuote = false
|
|
133
|
+
let escaped = false
|
|
134
|
+
let inAddr = false
|
|
135
|
+
let email = ""
|
|
136
|
+
let didFindAddr = false
|
|
137
|
+
let cleanedStr = ""
|
|
138
|
+
|
|
139
|
+
const addresses: string[] = []
|
|
140
|
+
|
|
141
|
+
function endAddress() {
|
|
142
|
+
let m: string
|
|
143
|
+
if (didFindAddr) {
|
|
144
|
+
m = email.trim()
|
|
145
|
+
} else {
|
|
146
|
+
m = cleanedStr.trim()
|
|
147
|
+
}
|
|
148
|
+
if (DataValidator.isEmailValid(m)) {
|
|
149
|
+
addresses.push(m)
|
|
150
|
+
}
|
|
151
|
+
didFindAddr = false
|
|
152
|
+
email = ""
|
|
153
|
+
inAddr = false
|
|
154
|
+
insideQuote = false
|
|
155
|
+
escaped = false
|
|
156
|
+
cleanedStr = ""
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
|
160
|
+
for (let index = 0; index < emailStr.length; index++) {
|
|
161
|
+
const shouldEscape = escaped
|
|
162
|
+
if (escaped) {
|
|
163
|
+
escaped = false
|
|
164
|
+
}
|
|
165
|
+
const character = emailStr[index];
|
|
166
|
+
if (insideQuote) {
|
|
167
|
+
if (character === "\\") {
|
|
168
|
+
escaped = true
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!shouldEscape) {
|
|
174
|
+
if (character === "\"") {
|
|
175
|
+
if (insideQuote) {
|
|
176
|
+
insideQuote = false
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
insideQuote = true
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!insideQuote) {
|
|
184
|
+
if (character === "<") {
|
|
185
|
+
inAddr = true
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (character === ">") {
|
|
190
|
+
inAddr = false
|
|
191
|
+
didFindAddr = true
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (character === ",") {
|
|
196
|
+
// End previous address
|
|
197
|
+
endAddress()
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (inAddr) {
|
|
204
|
+
email += character
|
|
205
|
+
}
|
|
206
|
+
cleanedStr += character
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
endAddress()
|
|
210
|
+
return addresses
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private async doSend(data: EmailInterface) {
|
|
214
|
+
if (STAMHOOFD.environment === 'test') {
|
|
215
|
+
// Do not send any emails
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Check if this email is not marked as spam
|
|
220
|
+
// Filter recipients if bounced or spam
|
|
221
|
+
let recipients = this.parseTo(data.to)
|
|
222
|
+
if (recipients.length === 0) {
|
|
223
|
+
// Invalid string
|
|
224
|
+
console.warn("Invalid e-mail string: '"+data.to+"'. E-mail skipped")
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Check spam and bounces
|
|
229
|
+
recipients = await EmailAddress.filterSendTo(recipients)
|
|
230
|
+
|
|
231
|
+
if (recipients.length === 0) {
|
|
232
|
+
// Invalid string
|
|
233
|
+
console.warn("Filtered all emails due hard bounce or spam '"+data.to+"'. E-mail skipped")
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Filter by environment
|
|
238
|
+
if (STAMHOOFD.environment === 'staging') {
|
|
239
|
+
recipients = recipients.filter(mail => !mail.email.includes("geen-email"))
|
|
240
|
+
}
|
|
241
|
+
if (STAMHOOFD.environment === 'development') {
|
|
242
|
+
recipients = recipients.filter(mail => mail.email.endsWith("@stamhoofd.be") || mail.email.endsWith("@bounce-testing.postmarkapp.com"))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (recipients.length === 0) {
|
|
246
|
+
// Invalid string
|
|
247
|
+
console.warn("Filtered all emails due to environment filter '"+data.to+"'. E-mail skipped")
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Rebuild to
|
|
252
|
+
const to = recipients.map((recipient) => {
|
|
253
|
+
if (!recipient.name) {
|
|
254
|
+
return recipient.email
|
|
255
|
+
}
|
|
256
|
+
const cleanedName = Formatter.emailSenderName(recipient.name)
|
|
257
|
+
if (cleanedName.length < 2) {
|
|
258
|
+
return recipient.email
|
|
259
|
+
}
|
|
260
|
+
return '"'+cleanedName+'" <'+recipient.email+'>'
|
|
261
|
+
}).join(", ")
|
|
262
|
+
|
|
263
|
+
this.setupIfNeeded();
|
|
264
|
+
|
|
265
|
+
// send mail with defined transport object
|
|
266
|
+
const mail: any = {
|
|
267
|
+
from: data.from, // sender address
|
|
268
|
+
bcc: (STAMHOOFD.environment === "production" || !data.bcc) ? data.bcc : "simon@stamhoofd.be",
|
|
269
|
+
replyTo: data.replyTo,
|
|
270
|
+
to,
|
|
271
|
+
subject: data.subject, // Subject line
|
|
272
|
+
text: data.text, // plain text body
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
if (data.attachments) {
|
|
276
|
+
mail.attachments = data.attachments;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (data.headers) {
|
|
280
|
+
mail.headers = data.headers;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (data.html) {
|
|
284
|
+
mail.html = data.html;
|
|
285
|
+
|
|
286
|
+
if (!data.text) {
|
|
287
|
+
mail.text = htmlToText.fromString(data.html, {
|
|
288
|
+
wordwrap: null,
|
|
289
|
+
unorderedListItemPrefix: " - "
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
if (!data.from.includes('@stamhoofd.be') && !data.from.includes('@stamhoofd.nl')) {
|
|
296
|
+
// Not supported
|
|
297
|
+
data.type = 'broadcast'
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const transporter = (data.type === "transactional") ? this.transactionalTransporter : this.transporter
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
const info = await transporter.sendMail(mail);
|
|
304
|
+
console.log("Message sent:", to, data.subject, info.messageId, data.type);
|
|
305
|
+
} catch (e) {
|
|
306
|
+
console.error("Failed to send e-mail:")
|
|
307
|
+
console.error(e);
|
|
308
|
+
console.error(mail);
|
|
309
|
+
|
|
310
|
+
if (STAMHOOFD.environment === 'development') {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Sleep 1 second to give servers some time to fix possible rate limits
|
|
315
|
+
await sleep(1000);
|
|
316
|
+
|
|
317
|
+
// Reschedule twice (at maximum) to fix temporary connection issues
|
|
318
|
+
data.retryCount = (data.retryCount ?? 0) + 1;
|
|
319
|
+
|
|
320
|
+
if (data.retryCount <= 2) {
|
|
321
|
+
this.send(data);
|
|
322
|
+
} else {
|
|
323
|
+
// Email address is not verified.
|
|
324
|
+
if (!data.from.includes("hallo@stamhoofd.be")) {
|
|
325
|
+
this.sendInternal({
|
|
326
|
+
to: "hallo@stamhoofd.be",
|
|
327
|
+
subject: "E-mail kon niet worden verzonden",
|
|
328
|
+
text: "Een e-mail vanaf "+data.from+" kon niet worden verstuurd aan "+mail.to+": \n\n"+e+"\n\n"+(mail.text ?? ""),
|
|
329
|
+
type: (data.type === "transactional") ? "broadcast" : "transactional"
|
|
330
|
+
}, new I18n("nl", "BE"))
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
getInternalEmailFor(i18n: I18n) {
|
|
337
|
+
return '"Stamhoofd" <'+ (i18n.$t("shared.emails.general")) +'>'
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
getPersonalEmailFor(i18n: I18n) {
|
|
341
|
+
return '"Simon Backx" <'+ (i18n.$t("shared.emails.personal")) +'>'
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Send an internal e-mail (from stamhoofd)
|
|
346
|
+
*/
|
|
347
|
+
sendInternal(data: EmailInterfaceBase, i18n: I18n) {
|
|
348
|
+
const mail = Object.assign(data, { from: this.getInternalEmailFor(i18n) })
|
|
349
|
+
this.send(mail)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
send(data: EmailInterface) {
|
|
353
|
+
if (STAMHOOFD.environment === 'test') {
|
|
354
|
+
// Do not send any emails
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
let didSend = false
|
|
358
|
+
|
|
359
|
+
this.schedule(() => {
|
|
360
|
+
if (didSend) {
|
|
361
|
+
return undefined
|
|
362
|
+
}
|
|
363
|
+
didSend = true;
|
|
364
|
+
return data
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
schedule(builder: EmailBuilder) {
|
|
369
|
+
this.currentQueue.push(builder)
|
|
370
|
+
this.sendNextIfNeeded()
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export const Email = new EmailStatic();
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
CREATE TABLE `email_addresses` (
|
|
2
|
+
`id` varchar(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
|
3
|
+
`email` varchar(255) DEFAULT NULL,
|
|
4
|
+
`organizationId` varchar(36) CHARACTER SET ascii COLLATE ascii_general_ci DEFAULT NULL,
|
|
5
|
+
`markedAsSpam` tinyint(1) DEFAULT NULL,
|
|
6
|
+
`hardBounce` tinyint(1) DEFAULT NULL,
|
|
7
|
+
`unsubscribedMarketing` tinyint(1) DEFAULT NULL,
|
|
8
|
+
`unsubscribedAll` tinyint(1) DEFAULT NULL,
|
|
9
|
+
`token` varchar(255) CHARACTER SET ascii COLLATE ascii_general_ci DEFAULT NULL,
|
|
10
|
+
`createdAt` datetime NOT NULL,
|
|
11
|
+
`updatedAt` datetime NOT NULL,
|
|
12
|
+
PRIMARY KEY (`id`),
|
|
13
|
+
UNIQUE KEY `email` (`email`,`organizationId`) USING BTREE
|
|
14
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { column, Database, Model } from '@simonbackx/simple-database';
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import { v4 as uuidv4 } from "uuid";
|
|
4
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
5
|
+
import { EmailInterfaceRecipient } from '../classes/Email';
|
|
6
|
+
|
|
7
|
+
async function randomBytes(size: number): Promise<Buffer> {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
crypto.randomBytes(size, (err: Error | null, buf: Buffer) => {
|
|
10
|
+
if (err) {
|
|
11
|
+
reject(err);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
resolve(buf);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class EmailAddress extends Model {
|
|
20
|
+
static table = "email_addresses";
|
|
21
|
+
|
|
22
|
+
@column({
|
|
23
|
+
primary: true, type: "string", beforeSave(value) {
|
|
24
|
+
return value ?? uuidv4();
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
id!: string;
|
|
28
|
+
|
|
29
|
+
@column({ type: "string", nullable: true })
|
|
30
|
+
organizationId: string | null = null;
|
|
31
|
+
|
|
32
|
+
// Columns
|
|
33
|
+
@column({ type: "string" })
|
|
34
|
+
email: string;
|
|
35
|
+
|
|
36
|
+
@column({ type: "boolean" })
|
|
37
|
+
markedAsSpam = false;
|
|
38
|
+
|
|
39
|
+
@column({ type: "boolean" })
|
|
40
|
+
hardBounce = false;
|
|
41
|
+
|
|
42
|
+
@column({ type: "boolean" })
|
|
43
|
+
unsubscribedMarketing = false;
|
|
44
|
+
|
|
45
|
+
@column({ type: "boolean" })
|
|
46
|
+
unsubscribedAll = false;
|
|
47
|
+
|
|
48
|
+
@column({ type: "string", nullable: true })
|
|
49
|
+
token: string | null;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* createdAt behaves more like createdAt for Challenge. Since every save is considered to have a new challenge
|
|
53
|
+
*/
|
|
54
|
+
@column({
|
|
55
|
+
type: "datetime", beforeSave() {
|
|
56
|
+
const date = new Date()
|
|
57
|
+
date.setMilliseconds(0)
|
|
58
|
+
return date
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
createdAt: Date
|
|
62
|
+
|
|
63
|
+
@column({
|
|
64
|
+
type: "datetime", beforeSave() {
|
|
65
|
+
const date = new Date()
|
|
66
|
+
date.setMilliseconds(0)
|
|
67
|
+
return date
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
updatedAt: Date
|
|
71
|
+
|
|
72
|
+
static async getOrCreate(email: string, organizationId: string | null): Promise<EmailAddress> {
|
|
73
|
+
// Prevent race conditions when checking the same email address at the same time, and creating a new one
|
|
74
|
+
return await QueueHandler.schedule("email-address/create-"+email+'-'+organizationId, async () => {
|
|
75
|
+
const existing = await this.getByEmail(email, organizationId)
|
|
76
|
+
if (existing) {
|
|
77
|
+
return existing
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const n = new EmailAddress()
|
|
81
|
+
n.organizationId = organizationId
|
|
82
|
+
n.email = email
|
|
83
|
+
n.token = (await randomBytes(64)).toString("base64").toUpperCase();
|
|
84
|
+
|
|
85
|
+
await n.save()
|
|
86
|
+
|
|
87
|
+
return n
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Methods
|
|
92
|
+
static async getByEmails(emails: string[], organizationId: string | null): Promise<EmailAddress[]> {
|
|
93
|
+
if (emails.length > 30) {
|
|
94
|
+
// Normally an organization will never have so much bounces, so we'll request all emails and filter in them
|
|
95
|
+
const all = await this.where({ organizationId }, { limit: 1000 })
|
|
96
|
+
return all.filter(e => emails.includes(e.email))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (emails.length == 0) {
|
|
100
|
+
return []
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (organizationId === null) {
|
|
104
|
+
const [rows] = await Database.select(
|
|
105
|
+
`SELECT ${this.getDefaultSelect()} FROM ${this.table} WHERE \`email\` IN (?) AND \`organizationId\` is NULL`,
|
|
106
|
+
[emails]
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return this.fromRows(rows, this.table);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const [rows] = await Database.select(
|
|
113
|
+
`SELECT ${this.getDefaultSelect()} FROM ${this.table} WHERE \`email\` IN (?) AND \`organizationId\` = ?`,
|
|
114
|
+
[emails, organizationId]
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return this.fromRows(rows, this.table);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Methods
|
|
121
|
+
static async getByEmail(email: string, organizationId: string | null): Promise<EmailAddress | undefined> {
|
|
122
|
+
return (await this.where({ email, organizationId }, { limit: 1 }))[0]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Methods
|
|
126
|
+
static async filterSendTo(recipients: EmailInterfaceRecipient[]): Promise<EmailInterfaceRecipient[]> {
|
|
127
|
+
if (recipients.length == 0) {
|
|
128
|
+
return []
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const emails = recipients.map(r => r.email)
|
|
132
|
+
const [results] = await Database.select(
|
|
133
|
+
`SELECT email FROM ${this.table} WHERE \`email\` IN (?) AND (\`hardBounce\` = 1 OR \`markedAsSpam\` = 1)`,
|
|
134
|
+
[emails]
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const remove = results.map(r => r[this.table]['email'])
|
|
138
|
+
if (remove.length == 0) {
|
|
139
|
+
return recipients
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return recipients.filter(r => !remove.includes(r.email))
|
|
143
|
+
}
|
|
144
|
+
}
|