@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 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,2 @@
1
+ export * from "./models/EmailAddress"
2
+ export * from "./classes/Email"
@@ -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
+ }