@stamhoofd/email 2.119.0 → 2.120.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.
@@ -1,145 +0,0 @@
1
- import { TestUtils } from '@stamhoofd/test-utils';
2
- import { Email, InternalEmailData } from './Email.js';
3
-
4
- type MockedCallback = (data: InternalEmailData) => Promise<void> | void;
5
- type MockedResponse = { error?: Error | null; callback?: MockedCallback };
6
-
7
- export class EmailMocker {
8
- sentEmails: InternalEmailData[] = [];
9
- failedEmails: InternalEmailData[] = [];
10
-
11
- responseQueue: MockedResponse[] = [];
12
- mode: 'transactional' | 'broadcast' = 'transactional';
13
-
14
- constructor(mode: 'transactional' | 'broadcast' = 'transactional') {
15
- this.mode = mode;
16
- }
17
-
18
- static transactional = new EmailMocker('transactional');
19
- static broadcast = new EmailMocker('broadcast');
20
-
21
- static infect() {
22
- if (STAMHOOFD.environment !== 'test') {
23
- throw new Error('EmailMocker can only be used in test environment');
24
- }
25
-
26
- const handler = async (mocker: EmailMocker, data: InternalEmailData) => {
27
- const nextHandler = mocker.responseQueue.shift();
28
-
29
- try {
30
- if (nextHandler) {
31
- if (nextHandler.callback) {
32
- await nextHandler.callback(data);
33
- }
34
- if (nextHandler.error) {
35
- throw nextHandler.error;
36
- }
37
- }
38
- }
39
- catch (e) {
40
- mocker.failedEmails.push(data);
41
- throw e;
42
- }
43
-
44
- mocker.sentEmails.push(data);
45
-
46
- return {
47
- messageId: 'mocked-' + mocker.sentEmails.length,
48
- };
49
- };
50
-
51
- TestUtils.addBeforeAll(async () => {
52
- // We load async here because this is a dev dependency (otherwise Node will reach this and complain, breaking the boot)
53
- const sinon = await import('sinon');
54
-
55
- sinon.stub(Email, 'setupIfNeeded').callsFake(function (this: typeof Email) {
56
- this.transporter = {
57
- sendMail: async (data: InternalEmailData) => {
58
- return handler(EmailMocker.broadcast, data);
59
- },
60
- } as any;
61
-
62
- this.transactionalTransporter = {
63
- sendMail: async (data: InternalEmailData) => {
64
- return handler(EmailMocker.transactional, data);
65
- },
66
- } as any;
67
- });
68
- });
69
-
70
- TestUtils.addAfterEach(() => {
71
- EmailMocker.afterAll();
72
- });
73
- }
74
-
75
- static afterAll() {
76
- // Clear
77
- EmailMocker.transactional.reset();
78
- EmailMocker.broadcast.reset();
79
- }
80
-
81
- reset() {
82
- this.sentEmails = [];
83
- this.failedEmails = [];
84
- this.responseQueue = [];
85
- }
86
-
87
- // Defining failure / success
88
- onNext(callback: MockedCallback) {
89
- this.responseQueue.push({ callback });
90
- }
91
-
92
- succeedNext() {
93
- this.responseQueue.push({});
94
- }
95
-
96
- failNext(error: Error) {
97
- this.responseQueue.push({ error });
98
- }
99
-
100
- // Helpers
101
- async getSucceededCount() {
102
- await Email.wait();
103
- return this.sentEmails.length;
104
- }
105
-
106
- async getFailedCount() {
107
- await Email.wait();
108
- return this.failedEmails.length;
109
- }
110
-
111
- static async getSucceededCount() {
112
- return await EmailMocker.transactional.getSucceededCount() + await EmailMocker.broadcast.getSucceededCount();
113
- }
114
-
115
- static async getFailedCount() {
116
- return await EmailMocker.transactional.getFailedCount() + await EmailMocker.broadcast.getFailedCount();
117
- }
118
-
119
- getSucceededEmail(index: number) {
120
- return this.sentEmails[index];
121
- }
122
-
123
- static getSucceededEmail(index: number) {
124
- const transactionalCount = EmailMocker.transactional.sentEmails.length;
125
- if (index < transactionalCount) {
126
- return EmailMocker.transactional.getSucceededEmail(index);
127
- }
128
- return EmailMocker.broadcast.getSucceededEmail(index - transactionalCount);
129
- }
130
-
131
- async getSucceededEmails() {
132
- await Email.wait();
133
- return this.sentEmails;
134
- }
135
-
136
- static async getSucceededEmails() {
137
- await Email.wait();
138
- return [...EmailMocker.transactional.sentEmails, ...EmailMocker.broadcast.sentEmails];
139
- }
140
-
141
- async getFailedEmails() {
142
- await Email.wait();
143
- return this.failedEmails;
144
- }
145
- }
package/src/index.ts DELETED
@@ -1,3 +0,0 @@
1
- export * from './models/EmailAddress';
2
- export * from './classes/Email';
3
- export * from './classes/EmailMocker';
@@ -1,14 +0,0 @@
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;
@@ -1,5 +0,0 @@
1
- set foreign_key_checks=0;
2
- ALTER TABLE `email_addresses` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
3
- ALTER TABLE `email_addresses` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
4
- ALTER DATABASE CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
5
- set foreign_key_checks=1;
@@ -1,160 +0,0 @@
1
- import { column, Database } from '@simonbackx/simple-database';
2
- import { QueueHandler } from '@stamhoofd/queues';
3
- import { QueryableModel, SQL } from '@stamhoofd/sql';
4
- import crypto from 'crypto';
5
- import { v4 as uuidv4 } from 'uuid';
6
- import { EmailInterfaceRecipient } from '../classes/Email.js';
7
-
8
- async function randomBytes(size: number): Promise<Buffer> {
9
- return new Promise((resolve, reject) => {
10
- crypto.randomBytes(size, (err: Error | null, buf: Buffer) => {
11
- if (err) {
12
- reject(err);
13
- return;
14
- }
15
- resolve(buf);
16
- });
17
- });
18
- }
19
-
20
- export class EmailAddress extends QueryableModel {
21
- static table = 'email_addresses';
22
-
23
- @column({
24
- primary: true, type: 'string', beforeSave(value) {
25
- return value ?? uuidv4();
26
- },
27
- })
28
- id!: string;
29
-
30
- @column({ type: 'string', nullable: true })
31
- organizationId: string | null = null;
32
-
33
- // Columns
34
- @column({ type: 'string' })
35
- email: string;
36
-
37
- @column({ type: 'boolean' })
38
- markedAsSpam = false;
39
-
40
- @column({ type: 'boolean' })
41
- hardBounce = false;
42
-
43
- @column({ type: 'boolean' })
44
- unsubscribedMarketing = false;
45
-
46
- @column({ type: 'boolean' })
47
- unsubscribedAll = false;
48
-
49
- @column({ type: 'string', nullable: true })
50
- token: string | null;
51
-
52
- /**
53
- * createdAt behaves more like createdAt for Challenge. Since every save is considered to have a new challenge
54
- */
55
- @column({
56
- type: 'datetime', beforeSave() {
57
- const date = new Date();
58
- date.setMilliseconds(0);
59
- return date;
60
- },
61
- })
62
- createdAt: Date;
63
-
64
- @column({
65
- type: 'datetime', beforeSave() {
66
- const date = new Date();
67
- date.setMilliseconds(0);
68
- return date;
69
- },
70
- })
71
- updatedAt: Date;
72
-
73
- static async getOrCreate(email: string, organizationId: string | null): Promise<EmailAddress> {
74
- // Prevent race conditions when checking the same email address at the same time, and creating a new one
75
- return await QueueHandler.schedule('email-address/create-' + email + '-' + organizationId, async () => {
76
- const existing = await this.getByEmail(email, organizationId);
77
- if (existing) {
78
- return existing;
79
- }
80
-
81
- const n = new EmailAddress();
82
- n.organizationId = organizationId;
83
- n.email = email;
84
- n.token = (await randomBytes(64)).toString('base64').toUpperCase();
85
-
86
- await n.save();
87
-
88
- return n;
89
- });
90
- }
91
-
92
- // Methods
93
- static async getByEmails(emails: string[], organizationId: string | null): Promise<EmailAddress[]> {
94
- if (emails.length > 1000) {
95
- // Normally an organization will never have so much bounces, so we'll request all emails and filter in them
96
- const all = await this.where({ organizationId }, { limit: 1000 });
97
- return all.filter(e => emails.includes(e.email));
98
- }
99
-
100
- if (emails.length === 0) {
101
- return [];
102
- }
103
-
104
- const query = EmailAddress.select().where(
105
- 'email', emails,
106
- );
107
- if (organizationId) {
108
- query.andWhere(
109
- SQL.where('organizationId', organizationId)
110
- .or('organizationId', null),
111
- );
112
- }
113
- else {
114
- query.andWhere(
115
- SQL.where('organizationId', null)
116
- .or('hardBounce', 1)
117
- .or('markedAsSpam', 1),
118
- );
119
- }
120
-
121
- return query.fetch();
122
- }
123
-
124
- // Methods
125
- static async getByEmail(email: string, organizationId: string | null): Promise<EmailAddress | undefined> {
126
- return (await this.where({ email, organizationId }, { limit: 1 }))[0];
127
- }
128
-
129
- /**
130
- * Search organization wide if this email has been marked as spam or hard bounced
131
- */
132
- static async getWhereHardBounceOrSpam(email: string): Promise<EmailAddress | null> {
133
- return await this.select().where(
134
- 'email', email,
135
- ).where(
136
- SQL.where('hardBounce', 1)
137
- .or('markedAsSpam', 1),
138
- ).first(false);
139
- }
140
-
141
- // Methods
142
- static async filterSendTo(recipients: EmailInterfaceRecipient[]): Promise<EmailInterfaceRecipient[]> {
143
- if (recipients.length === 0) {
144
- return [];
145
- }
146
-
147
- const emails = recipients.map(r => r.email);
148
- const [results] = await Database.select(
149
- `SELECT email FROM ${this.table} WHERE \`email\` IN (?) AND (\`hardBounce\` = 1 OR \`markedAsSpam\` = 1)`,
150
- [emails],
151
- );
152
-
153
- const remove = results.map(r => r[this.table]['email']);
154
- if (remove.length === 0) {
155
- return recipients;
156
- }
157
-
158
- return recipients.filter(r => !remove.includes(r.email));
159
- }
160
- }