@bernierllc/email-webhook-events 1.0.0
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/.eslintrc.cjs +29 -0
- package/README.md +349 -0
- package/__tests__/EmailWebhookEventsService.test.ts +247 -0
- package/__tests__/analytics/AnalyticsEngine.test.ts +244 -0
- package/__tests__/normalizers/MailgunNormalizer.test.ts +149 -0
- package/__tests__/normalizers/PostmarkNormalizer.test.ts +112 -0
- package/__tests__/normalizers/SESNormalizer.test.ts +168 -0
- package/__tests__/normalizers/SMTP2GONormalizer.test.ts +83 -0
- package/__tests__/normalizers/SendGridNormalizer.test.ts +181 -0
- package/__tests__/persistence/PersistenceAdapter.test.ts +103 -0
- package/coverage/clover.xml +328 -0
- package/coverage/coverage-final.json +10 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +161 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov-report/src/EmailWebhookEventsService.ts.html +826 -0
- package/coverage/lcov-report/src/analytics/AnalyticsEngine.ts.html +775 -0
- package/coverage/lcov-report/src/analytics/index.html +116 -0
- package/coverage/lcov-report/src/index.html +131 -0
- package/coverage/lcov-report/src/normalizers/MailgunNormalizer.ts.html +301 -0
- package/coverage/lcov-report/src/normalizers/PostmarkNormalizer.ts.html +301 -0
- package/coverage/lcov-report/src/normalizers/SESNormalizer.ts.html +436 -0
- package/coverage/lcov-report/src/normalizers/SMTP2GONormalizer.ts.html +247 -0
- package/coverage/lcov-report/src/normalizers/SendGridNormalizer.ts.html +274 -0
- package/coverage/lcov-report/src/normalizers/index.html +176 -0
- package/coverage/lcov-report/src/persistence/InMemoryPersistenceAdapter.ts.html +289 -0
- package/coverage/lcov-report/src/persistence/index.html +116 -0
- package/coverage/lcov-report/src/types.ts.html +823 -0
- package/coverage/lcov.info +710 -0
- package/dist/EmailWebhookEventsService.d.ts +53 -0
- package/dist/EmailWebhookEventsService.d.ts.map +1 -0
- package/dist/EmailWebhookEventsService.js +198 -0
- package/dist/EmailWebhookEventsService.js.map +1 -0
- package/dist/analytics/AnalyticsEngine.d.ts +20 -0
- package/dist/analytics/AnalyticsEngine.d.ts.map +1 -0
- package/dist/analytics/AnalyticsEngine.js +160 -0
- package/dist/analytics/AnalyticsEngine.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/normalizers/EventNormalizer.d.ts +5 -0
- package/dist/normalizers/EventNormalizer.d.ts.map +1 -0
- package/dist/normalizers/EventNormalizer.js +10 -0
- package/dist/normalizers/EventNormalizer.js.map +1 -0
- package/dist/normalizers/MailgunNormalizer.d.ts +9 -0
- package/dist/normalizers/MailgunNormalizer.d.ts.map +1 -0
- package/dist/normalizers/MailgunNormalizer.js +73 -0
- package/dist/normalizers/MailgunNormalizer.js.map +1 -0
- package/dist/normalizers/PostmarkNormalizer.d.ts +10 -0
- package/dist/normalizers/PostmarkNormalizer.d.ts.map +1 -0
- package/dist/normalizers/PostmarkNormalizer.js +75 -0
- package/dist/normalizers/PostmarkNormalizer.js.map +1 -0
- package/dist/normalizers/SESNormalizer.d.ts +16 -0
- package/dist/normalizers/SESNormalizer.d.ts.map +1 -0
- package/dist/normalizers/SESNormalizer.js +107 -0
- package/dist/normalizers/SESNormalizer.js.map +1 -0
- package/dist/normalizers/SMTP2GONormalizer.d.ts +9 -0
- package/dist/normalizers/SMTP2GONormalizer.d.ts.map +1 -0
- package/dist/normalizers/SMTP2GONormalizer.js +55 -0
- package/dist/normalizers/SMTP2GONormalizer.js.map +1 -0
- package/dist/normalizers/SendGridNormalizer.d.ts +9 -0
- package/dist/normalizers/SendGridNormalizer.d.ts.map +1 -0
- package/dist/normalizers/SendGridNormalizer.js +64 -0
- package/dist/normalizers/SendGridNormalizer.js.map +1 -0
- package/dist/persistence/InMemoryPersistenceAdapter.d.ts +12 -0
- package/dist/persistence/InMemoryPersistenceAdapter.d.ts.map +1 -0
- package/dist/persistence/InMemoryPersistenceAdapter.js +58 -0
- package/dist/persistence/InMemoryPersistenceAdapter.js.map +1 -0
- package/dist/persistence/PersistenceAdapter.d.ts +7 -0
- package/dist/persistence/PersistenceAdapter.d.ts.map +1 -0
- package/dist/persistence/PersistenceAdapter.js +10 -0
- package/dist/persistence/PersistenceAdapter.js.map +1 -0
- package/dist/types.d.ts +178 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +41 -0
- package/dist/types.js.map +1 -0
- package/jest.config.cjs +29 -0
- package/package.json +51 -0
- package/src/EmailWebhookEventsService.ts +247 -0
- package/src/analytics/AnalyticsEngine.ts +230 -0
- package/src/index.ts +39 -0
- package/src/normalizers/EventNormalizer.ts +13 -0
- package/src/normalizers/MailgunNormalizer.ts +72 -0
- package/src/normalizers/PostmarkNormalizer.ts +72 -0
- package/src/normalizers/SESNormalizer.ts +117 -0
- package/src/normalizers/SMTP2GONormalizer.ts +54 -0
- package/src/normalizers/SendGridNormalizer.ts +63 -0
- package/src/persistence/InMemoryPersistenceAdapter.ts +68 -0
- package/src/persistence/PersistenceAdapter.ts +15 -0
- package/src/types.ts +246 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { AnalyticsEngine } from '../../src/analytics/AnalyticsEngine';
|
|
10
|
+
import { EmailEvent, EmailEventType, EmailProvider } from '../../src/types';
|
|
11
|
+
|
|
12
|
+
describe('AnalyticsEngine', () => {
|
|
13
|
+
let engine: AnalyticsEngine;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
engine = new AnalyticsEngine({
|
|
17
|
+
realTimeEnabled: true,
|
|
18
|
+
aggregationInterval: 5000,
|
|
19
|
+
retentionDays: 90
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const createEvent = (type: EmailEventType, overrides: Partial<EmailEvent> = {}): EmailEvent => ({
|
|
24
|
+
id: `event-${Math.random()}`,
|
|
25
|
+
emailId: 'email-123',
|
|
26
|
+
messageId: 'msg-123',
|
|
27
|
+
type,
|
|
28
|
+
recipient: 'user@example.com',
|
|
29
|
+
timestamp: new Date(),
|
|
30
|
+
provider: EmailProvider.SENDGRID,
|
|
31
|
+
metadata: {},
|
|
32
|
+
...overrides
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should record events', async () => {
|
|
36
|
+
const event = createEvent(EmailEventType.DELIVERED);
|
|
37
|
+
await engine.recordEvent(event);
|
|
38
|
+
|
|
39
|
+
const analytics = await engine.getAnalytics(
|
|
40
|
+
new Date('2025-01-01'),
|
|
41
|
+
new Date('2025-12-31')
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect(analytics.totalDelivered).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should calculate delivery rate correctly', async () => {
|
|
48
|
+
await engine.recordEvent(createEvent(EmailEventType.DELIVERED));
|
|
49
|
+
await engine.recordEvent(createEvent(EmailEventType.DELIVERED));
|
|
50
|
+
await engine.recordEvent(createEvent(EmailEventType.BOUNCED));
|
|
51
|
+
|
|
52
|
+
const analytics = await engine.getAnalytics(
|
|
53
|
+
new Date('2025-01-01'),
|
|
54
|
+
new Date('2025-12-31')
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(analytics.totalSent).toBe(3);
|
|
58
|
+
expect(analytics.totalDelivered).toBe(2);
|
|
59
|
+
expect(analytics.deliveryRate).toBeCloseTo(2 / 3, 2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should calculate open rate correctly', async () => {
|
|
63
|
+
await engine.recordEvent(createEvent(EmailEventType.DELIVERED));
|
|
64
|
+
await engine.recordEvent(createEvent(EmailEventType.DELIVERED));
|
|
65
|
+
await engine.recordEvent(createEvent(EmailEventType.OPENED));
|
|
66
|
+
|
|
67
|
+
const analytics = await engine.getAnalytics(
|
|
68
|
+
new Date('2025-01-01'),
|
|
69
|
+
new Date('2025-12-31')
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(analytics.totalDelivered).toBe(2);
|
|
73
|
+
expect(analytics.totalOpened).toBe(1);
|
|
74
|
+
expect(analytics.openRate).toBe(0.5);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should calculate click-to-open rate correctly', async () => {
|
|
78
|
+
await engine.recordEvent(createEvent(EmailEventType.OPENED));
|
|
79
|
+
await engine.recordEvent(createEvent(EmailEventType.OPENED));
|
|
80
|
+
await engine.recordEvent(createEvent(EmailEventType.CLICKED));
|
|
81
|
+
|
|
82
|
+
const analytics = await engine.getAnalytics(
|
|
83
|
+
new Date('2025-01-01'),
|
|
84
|
+
new Date('2025-12-31')
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(analytics.totalOpened).toBe(2);
|
|
88
|
+
expect(analytics.totalClicked).toBe(1);
|
|
89
|
+
expect(analytics.clickToOpenRate).toBe(0.5);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should handle zero denominators gracefully', async () => {
|
|
93
|
+
const analytics = await engine.getAnalytics(
|
|
94
|
+
new Date('2025-01-01'),
|
|
95
|
+
new Date('2025-12-31')
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(analytics.deliveryRate).toBe(0);
|
|
99
|
+
expect(analytics.openRate).toBe(0);
|
|
100
|
+
expect(analytics.clickRate).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should calculate bounce rate correctly', async () => {
|
|
104
|
+
await engine.recordEvent(createEvent(EmailEventType.DELIVERED));
|
|
105
|
+
await engine.recordEvent(createEvent(EmailEventType.BOUNCED));
|
|
106
|
+
|
|
107
|
+
const analytics = await engine.getAnalytics(
|
|
108
|
+
new Date('2025-01-01'),
|
|
109
|
+
new Date('2025-12-31')
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(analytics.bounceRate).toBe(0.5);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should break down by provider', async () => {
|
|
116
|
+
await engine.recordEvent(createEvent(EmailEventType.DELIVERED, { provider: EmailProvider.SENDGRID }));
|
|
117
|
+
await engine.recordEvent(createEvent(EmailEventType.DELIVERED, { provider: EmailProvider.MAILGUN }));
|
|
118
|
+
|
|
119
|
+
const analytics = await engine.getAnalytics(
|
|
120
|
+
new Date('2025-01-01'),
|
|
121
|
+
new Date('2025-12-31')
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(analytics.byProvider[EmailProvider.SENDGRID].delivered).toBe(1);
|
|
125
|
+
expect(analytics.byProvider[EmailProvider.MAILGUN].delivered).toBe(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should break down by event type', async () => {
|
|
129
|
+
await engine.recordEvent(createEvent(EmailEventType.DELIVERED));
|
|
130
|
+
await engine.recordEvent(createEvent(EmailEventType.OPENED));
|
|
131
|
+
await engine.recordEvent(createEvent(EmailEventType.CLICKED));
|
|
132
|
+
|
|
133
|
+
const analytics = await engine.getAnalytics(
|
|
134
|
+
new Date('2025-01-01'),
|
|
135
|
+
new Date('2025-12-31')
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
expect(analytics.byEventType[EmailEventType.DELIVERED]).toBe(1);
|
|
139
|
+
expect(analytics.byEventType[EmailEventType.OPENED]).toBe(1);
|
|
140
|
+
expect(analytics.byEventType[EmailEventType.CLICKED]).toBe(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should break down bounces by type', async () => {
|
|
144
|
+
await engine.recordEvent(
|
|
145
|
+
createEvent(EmailEventType.BOUNCED, {
|
|
146
|
+
metadata: { bounceType: 'hard' }
|
|
147
|
+
})
|
|
148
|
+
);
|
|
149
|
+
await engine.recordEvent(
|
|
150
|
+
createEvent(EmailEventType.BOUNCED, {
|
|
151
|
+
metadata: { bounceType: 'soft' }
|
|
152
|
+
})
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const analytics = await engine.getAnalytics(
|
|
156
|
+
new Date('2025-01-01'),
|
|
157
|
+
new Date('2025-12-31')
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
expect(analytics.byBounceType).toBeDefined();
|
|
161
|
+
expect(analytics.byBounceType!.hard).toBe(1);
|
|
162
|
+
expect(analytics.byBounceType!.soft).toBe(1);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should calculate top links', async () => {
|
|
166
|
+
await engine.recordEvent(
|
|
167
|
+
createEvent(EmailEventType.CLICKED, {
|
|
168
|
+
metadata: { url: 'https://example.com/link1' },
|
|
169
|
+
recipient: 'user1@example.com'
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
await engine.recordEvent(
|
|
173
|
+
createEvent(EmailEventType.CLICKED, {
|
|
174
|
+
metadata: { url: 'https://example.com/link1' },
|
|
175
|
+
recipient: 'user2@example.com'
|
|
176
|
+
})
|
|
177
|
+
);
|
|
178
|
+
await engine.recordEvent(
|
|
179
|
+
createEvent(EmailEventType.CLICKED, {
|
|
180
|
+
metadata: { url: 'https://example.com/link2' },
|
|
181
|
+
recipient: 'user1@example.com'
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const analytics = await engine.getAnalytics(
|
|
186
|
+
new Date('2025-01-01'),
|
|
187
|
+
new Date('2025-12-31')
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
expect(analytics.topLinks).toBeDefined();
|
|
191
|
+
expect(analytics.topLinks![0].url).toBe('https://example.com/link1');
|
|
192
|
+
expect(analytics.topLinks![0].clicks).toBe(2);
|
|
193
|
+
expect(analytics.topLinks![0].uniqueClicks).toBe(2);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should break down by device type', async () => {
|
|
197
|
+
await engine.recordEvent(
|
|
198
|
+
createEvent(EmailEventType.OPENED, {
|
|
199
|
+
metadata: { deviceType: 'desktop' }
|
|
200
|
+
})
|
|
201
|
+
);
|
|
202
|
+
await engine.recordEvent(
|
|
203
|
+
createEvent(EmailEventType.OPENED, {
|
|
204
|
+
metadata: { deviceType: 'mobile' }
|
|
205
|
+
})
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const analytics = await engine.getAnalytics(
|
|
209
|
+
new Date('2025-01-01'),
|
|
210
|
+
new Date('2025-12-31')
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(analytics.byDevice).toBeDefined();
|
|
214
|
+
expect(analytics.byDevice!.desktop).toBe(1);
|
|
215
|
+
expect(analytics.byDevice!.mobile).toBe(1);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should filter analytics by provider', async () => {
|
|
219
|
+
await engine.recordEvent(createEvent(EmailEventType.DELIVERED, { provider: EmailProvider.SENDGRID }));
|
|
220
|
+
await engine.recordEvent(createEvent(EmailEventType.DELIVERED, { provider: EmailProvider.MAILGUN }));
|
|
221
|
+
|
|
222
|
+
const analytics = await engine.getAnalytics(
|
|
223
|
+
new Date('2025-01-01'),
|
|
224
|
+
new Date('2025-12-31'),
|
|
225
|
+
{ provider: EmailProvider.SENDGRID }
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
expect(analytics.totalDelivered).toBe(1);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should filter analytics by event type', async () => {
|
|
232
|
+
await engine.recordEvent(createEvent(EmailEventType.DELIVERED));
|
|
233
|
+
await engine.recordEvent(createEvent(EmailEventType.OPENED));
|
|
234
|
+
|
|
235
|
+
const analytics = await engine.getAnalytics(
|
|
236
|
+
new Date('2025-01-01'),
|
|
237
|
+
new Date('2025-12-31'),
|
|
238
|
+
{ eventTypes: [EmailEventType.DELIVERED] }
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
expect(analytics.totalDelivered).toBe(1);
|
|
242
|
+
expect(analytics.totalOpened).toBe(0);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { MailgunNormalizer } from '../../src/normalizers/MailgunNormalizer';
|
|
10
|
+
import { EmailEventType, EmailProvider, WebhookPayload } from '../../src/types';
|
|
11
|
+
|
|
12
|
+
describe('MailgunNormalizer', () => {
|
|
13
|
+
let normalizer: MailgunNormalizer;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
normalizer = new MailgunNormalizer();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should normalize delivered event', () => {
|
|
20
|
+
const webhook: WebhookPayload = {
|
|
21
|
+
provider: EmailProvider.MAILGUN,
|
|
22
|
+
signature: 'test-signature',
|
|
23
|
+
payload: {
|
|
24
|
+
'event-data': {
|
|
25
|
+
event: 'delivered',
|
|
26
|
+
recipient: 'user@example.com',
|
|
27
|
+
timestamp: 1234567890,
|
|
28
|
+
id: 'msg-123',
|
|
29
|
+
message: {
|
|
30
|
+
headers: {
|
|
31
|
+
'message-id': 'email-456'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
headers: {}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const result = normalizer.normalize(webhook);
|
|
40
|
+
|
|
41
|
+
expect(result.type).toBe(EmailEventType.DELIVERED);
|
|
42
|
+
expect(result.recipient).toBe('user@example.com');
|
|
43
|
+
expect(result.provider).toBe(EmailProvider.MAILGUN);
|
|
44
|
+
expect(result.messageId).toBe('msg-123');
|
|
45
|
+
expect(result.emailId).toBe('email-456');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should normalize bounce event with geolocation', () => {
|
|
49
|
+
const webhook: WebhookPayload = {
|
|
50
|
+
provider: EmailProvider.MAILGUN,
|
|
51
|
+
signature: 'test-signature',
|
|
52
|
+
payload: {
|
|
53
|
+
'event-data': {
|
|
54
|
+
event: 'bounced',
|
|
55
|
+
recipient: 'user@example.com',
|
|
56
|
+
timestamp: 1234567890,
|
|
57
|
+
id: 'msg-123',
|
|
58
|
+
severity: 'permanent',
|
|
59
|
+
'delivery-status': {
|
|
60
|
+
message: 'User unknown',
|
|
61
|
+
code: 550
|
|
62
|
+
},
|
|
63
|
+
message: {
|
|
64
|
+
headers: {
|
|
65
|
+
'message-id': 'email-456'
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
geolocation: {
|
|
69
|
+
country: 'US',
|
|
70
|
+
region: 'CA',
|
|
71
|
+
city: 'San Francisco'
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
headers: {}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const result = normalizer.normalize(webhook);
|
|
79
|
+
|
|
80
|
+
expect(result.type).toBe(EmailEventType.BOUNCED);
|
|
81
|
+
expect(result.metadata.bounceType).toBe('hard');
|
|
82
|
+
expect(result.metadata.bounceReason).toBe('User unknown');
|
|
83
|
+
expect(result.metadata.bounceCode).toBe('550');
|
|
84
|
+
expect(result.metadata.location).toEqual({
|
|
85
|
+
country: 'US',
|
|
86
|
+
region: 'CA',
|
|
87
|
+
city: 'San Francisco'
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should normalize soft bounce', () => {
|
|
92
|
+
const webhook: WebhookPayload = {
|
|
93
|
+
provider: EmailProvider.MAILGUN,
|
|
94
|
+
signature: 'test-signature',
|
|
95
|
+
payload: {
|
|
96
|
+
'event-data': {
|
|
97
|
+
event: 'bounced',
|
|
98
|
+
recipient: 'user@example.com',
|
|
99
|
+
timestamp: 1234567890,
|
|
100
|
+
id: 'msg-123',
|
|
101
|
+
severity: 'temporary',
|
|
102
|
+
message: {
|
|
103
|
+
headers: {
|
|
104
|
+
'message-id': 'email-456'
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
headers: {}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const result = normalizer.normalize(webhook);
|
|
113
|
+
|
|
114
|
+
expect(result.metadata.bounceType).toBe('soft');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should normalize click event', () => {
|
|
118
|
+
const webhook: WebhookPayload = {
|
|
119
|
+
provider: EmailProvider.MAILGUN,
|
|
120
|
+
signature: 'test-signature',
|
|
121
|
+
payload: {
|
|
122
|
+
'event-data': {
|
|
123
|
+
event: 'clicked',
|
|
124
|
+
recipient: 'user@example.com',
|
|
125
|
+
timestamp: 1234567890,
|
|
126
|
+
id: 'msg-123',
|
|
127
|
+
url: 'https://example.com/link',
|
|
128
|
+
'client-info': {
|
|
129
|
+
'client-ip': '192.168.1.1',
|
|
130
|
+
'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) mobile'
|
|
131
|
+
},
|
|
132
|
+
message: {
|
|
133
|
+
headers: {
|
|
134
|
+
'message-id': 'email-456'
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
headers: {}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const result = normalizer.normalize(webhook);
|
|
143
|
+
|
|
144
|
+
expect(result.type).toBe(EmailEventType.CLICKED);
|
|
145
|
+
expect(result.metadata.url).toBe('https://example.com/link');
|
|
146
|
+
expect(result.metadata.ipAddress).toBe('192.168.1.1');
|
|
147
|
+
expect(result.metadata.deviceType).toBe('mobile');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { PostmarkNormalizer } from '../../src/normalizers/PostmarkNormalizer';
|
|
10
|
+
import { EmailEventType, EmailProvider, WebhookPayload } from '../../src/types';
|
|
11
|
+
|
|
12
|
+
describe('PostmarkNormalizer', () => {
|
|
13
|
+
let normalizer: PostmarkNormalizer;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
normalizer = new PostmarkNormalizer();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should normalize delivery event', () => {
|
|
20
|
+
const webhook: WebhookPayload = {
|
|
21
|
+
provider: EmailProvider.POSTMARK,
|
|
22
|
+
signature: 'test-signature',
|
|
23
|
+
payload: {
|
|
24
|
+
RecordType: 'Delivery',
|
|
25
|
+
MessageID: 'msg-123',
|
|
26
|
+
Recipient: 'user@example.com',
|
|
27
|
+
ReceivedAt: '2025-01-01T00:00:00.000Z'
|
|
28
|
+
},
|
|
29
|
+
headers: {}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const result = normalizer.normalize(webhook);
|
|
33
|
+
|
|
34
|
+
expect(result.type).toBe(EmailEventType.DELIVERED);
|
|
35
|
+
expect(result.recipient).toBe('user@example.com');
|
|
36
|
+
expect(result.provider).toBe(EmailProvider.POSTMARK);
|
|
37
|
+
expect(result.messageId).toBe('msg-123');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should normalize bounce event', () => {
|
|
41
|
+
const webhook: WebhookPayload = {
|
|
42
|
+
provider: EmailProvider.POSTMARK,
|
|
43
|
+
signature: 'test-signature',
|
|
44
|
+
payload: {
|
|
45
|
+
RecordType: 'Bounce',
|
|
46
|
+
MessageID: 'msg-123',
|
|
47
|
+
Recipient: 'user@example.com',
|
|
48
|
+
ReceivedAt: '2025-01-01T00:00:00.000Z',
|
|
49
|
+
Type: 'HardBounce',
|
|
50
|
+
TypeCode: 1,
|
|
51
|
+
Description: 'User unknown'
|
|
52
|
+
},
|
|
53
|
+
headers: {}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const result = normalizer.normalize(webhook);
|
|
57
|
+
|
|
58
|
+
expect(result.type).toBe(EmailEventType.BOUNCED);
|
|
59
|
+
expect(result.metadata.bounceType).toBe('hard');
|
|
60
|
+
expect(result.metadata.bounceReason).toBe('User unknown');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should normalize open event', () => {
|
|
64
|
+
const webhook: WebhookPayload = {
|
|
65
|
+
provider: EmailProvider.POSTMARK,
|
|
66
|
+
signature: 'test-signature',
|
|
67
|
+
payload: {
|
|
68
|
+
RecordType: 'Open',
|
|
69
|
+
MessageID: 'msg-123',
|
|
70
|
+
Recipient: 'user@example.com',
|
|
71
|
+
ReceivedAt: '2025-01-01T00:00:00.000Z',
|
|
72
|
+
UserAgent: 'Mozilla/5.0',
|
|
73
|
+
Geo: {
|
|
74
|
+
Country: 'US',
|
|
75
|
+
Region: 'CA',
|
|
76
|
+
City: 'San Francisco'
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
headers: {}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const result = normalizer.normalize(webhook);
|
|
83
|
+
|
|
84
|
+
expect(result.type).toBe(EmailEventType.OPENED);
|
|
85
|
+
expect(result.metadata.location).toEqual({
|
|
86
|
+
country: 'US',
|
|
87
|
+
region: 'CA',
|
|
88
|
+
city: 'San Francisco'
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should normalize click event', () => {
|
|
93
|
+
const webhook: WebhookPayload = {
|
|
94
|
+
provider: EmailProvider.POSTMARK,
|
|
95
|
+
signature: 'test-signature',
|
|
96
|
+
payload: {
|
|
97
|
+
RecordType: 'Click',
|
|
98
|
+
MessageID: 'msg-123',
|
|
99
|
+
Recipient: 'user@example.com',
|
|
100
|
+
ReceivedAt: '2025-01-01T00:00:00.000Z',
|
|
101
|
+
OriginalLink: 'https://example.com/link',
|
|
102
|
+
UserAgent: 'Mozilla/5.0'
|
|
103
|
+
},
|
|
104
|
+
headers: {}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const result = normalizer.normalize(webhook);
|
|
108
|
+
|
|
109
|
+
expect(result.type).toBe(EmailEventType.CLICKED);
|
|
110
|
+
expect(result.metadata.url).toBe('https://example.com/link');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SESNormalizer } from '../../src/normalizers/SESNormalizer';
|
|
10
|
+
import { EmailEventType, EmailProvider, WebhookPayload } from '../../src/types';
|
|
11
|
+
|
|
12
|
+
describe('SESNormalizer', () => {
|
|
13
|
+
let normalizer: SESNormalizer;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
normalizer = new SESNormalizer();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should normalize delivery event', () => {
|
|
20
|
+
const webhook: WebhookPayload = {
|
|
21
|
+
provider: EmailProvider.AWS_SES,
|
|
22
|
+
signature: 'test-signature',
|
|
23
|
+
payload: {
|
|
24
|
+
Message: JSON.stringify({
|
|
25
|
+
eventType: 'Delivery',
|
|
26
|
+
mail: {
|
|
27
|
+
messageId: 'msg-123',
|
|
28
|
+
timestamp: '2025-01-01T00:00:00.000Z',
|
|
29
|
+
destination: ['user@example.com']
|
|
30
|
+
},
|
|
31
|
+
delivery: {
|
|
32
|
+
timestamp: '2025-01-01T00:00:00.000Z'
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
},
|
|
36
|
+
headers: {}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const result = normalizer.normalize(webhook);
|
|
40
|
+
|
|
41
|
+
expect(result.type).toBe(EmailEventType.DELIVERED);
|
|
42
|
+
expect(result.recipient).toBe('user@example.com');
|
|
43
|
+
expect(result.provider).toBe(EmailProvider.AWS_SES);
|
|
44
|
+
expect(result.messageId).toBe('msg-123');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should normalize bounce event', () => {
|
|
48
|
+
const webhook: WebhookPayload = {
|
|
49
|
+
provider: EmailProvider.AWS_SES,
|
|
50
|
+
signature: 'test-signature',
|
|
51
|
+
payload: {
|
|
52
|
+
Message: JSON.stringify({
|
|
53
|
+
eventType: 'Bounce',
|
|
54
|
+
mail: {
|
|
55
|
+
messageId: 'msg-123',
|
|
56
|
+
timestamp: '2025-01-01T00:00:00.000Z',
|
|
57
|
+
destination: ['user@example.com']
|
|
58
|
+
},
|
|
59
|
+
bounce: {
|
|
60
|
+
bounceType: 'Permanent',
|
|
61
|
+
bouncedRecipients: [
|
|
62
|
+
{
|
|
63
|
+
emailAddress: 'user@example.com',
|
|
64
|
+
diagnosticCode: 'smtp; 550 5.1.1 user unknown'
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
},
|
|
70
|
+
headers: {}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const result = normalizer.normalize(webhook);
|
|
74
|
+
|
|
75
|
+
expect(result.type).toBe(EmailEventType.BOUNCED);
|
|
76
|
+
expect(result.recipient).toBe('user@example.com');
|
|
77
|
+
expect(result.metadata.bounceType).toBe('permanent');
|
|
78
|
+
expect(result.metadata.bounceReason).toBe('smtp; 550 5.1.1 user unknown');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should normalize complaint event', () => {
|
|
82
|
+
const webhook: WebhookPayload = {
|
|
83
|
+
provider: EmailProvider.AWS_SES,
|
|
84
|
+
signature: 'test-signature',
|
|
85
|
+
payload: {
|
|
86
|
+
Message: JSON.stringify({
|
|
87
|
+
eventType: 'Complaint',
|
|
88
|
+
mail: {
|
|
89
|
+
messageId: 'msg-123',
|
|
90
|
+
timestamp: '2025-01-01T00:00:00.000Z',
|
|
91
|
+
destination: ['user@example.com']
|
|
92
|
+
},
|
|
93
|
+
complaint: {
|
|
94
|
+
complainedRecipients: [
|
|
95
|
+
{
|
|
96
|
+
emailAddress: 'user@example.com'
|
|
97
|
+
}
|
|
98
|
+
],
|
|
99
|
+
complaintFeedbackType: 'abuse'
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
},
|
|
103
|
+
headers: {}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const result = normalizer.normalize(webhook);
|
|
107
|
+
|
|
108
|
+
expect(result.type).toBe(EmailEventType.COMPLAINED);
|
|
109
|
+
expect(result.recipient).toBe('user@example.com');
|
|
110
|
+
expect(result.metadata.complaintFeedbackType).toBe('abuse');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should normalize open event', () => {
|
|
114
|
+
const webhook: WebhookPayload = {
|
|
115
|
+
provider: EmailProvider.AWS_SES,
|
|
116
|
+
signature: 'test-signature',
|
|
117
|
+
payload: {
|
|
118
|
+
Message: JSON.stringify({
|
|
119
|
+
eventType: 'Open',
|
|
120
|
+
mail: {
|
|
121
|
+
messageId: 'msg-123',
|
|
122
|
+
timestamp: '2025-01-01T00:00:00.000Z',
|
|
123
|
+
destination: ['user@example.com']
|
|
124
|
+
},
|
|
125
|
+
open: {
|
|
126
|
+
ipAddress: '192.168.1.1',
|
|
127
|
+
userAgent: 'Mozilla/5.0'
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
},
|
|
131
|
+
headers: {}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const result = normalizer.normalize(webhook);
|
|
135
|
+
|
|
136
|
+
expect(result.type).toBe(EmailEventType.OPENED);
|
|
137
|
+
expect(result.metadata.ipAddress).toBe('192.168.1.1');
|
|
138
|
+
expect(result.metadata.userAgent).toBe('Mozilla/5.0');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should normalize click event', () => {
|
|
142
|
+
const webhook: WebhookPayload = {
|
|
143
|
+
provider: EmailProvider.AWS_SES,
|
|
144
|
+
signature: 'test-signature',
|
|
145
|
+
payload: {
|
|
146
|
+
Message: JSON.stringify({
|
|
147
|
+
eventType: 'Click',
|
|
148
|
+
mail: {
|
|
149
|
+
messageId: 'msg-123',
|
|
150
|
+
timestamp: '2025-01-01T00:00:00.000Z',
|
|
151
|
+
destination: ['user@example.com']
|
|
152
|
+
},
|
|
153
|
+
click: {
|
|
154
|
+
link: 'https://example.com/link',
|
|
155
|
+
ipAddress: '192.168.1.1',
|
|
156
|
+
userAgent: 'Mozilla/5.0'
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
},
|
|
160
|
+
headers: {}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const result = normalizer.normalize(webhook);
|
|
164
|
+
|
|
165
|
+
expect(result.type).toBe(EmailEventType.CLICKED);
|
|
166
|
+
expect(result.metadata.url).toBe('https://example.com/link');
|
|
167
|
+
});
|
|
168
|
+
});
|