@bernierllc/email-tracking 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 +24 -0
- package/README.md +478 -0
- package/__mocks__/neverhub-adapter.ts +29 -0
- package/__tests__/EmailTracking.test.ts +334 -0
- package/__tests__/campaign-stats.test.ts +224 -0
- package/__tests__/config.test.ts +108 -0
- package/__tests__/integration.test.ts +244 -0
- package/__tests__/recipient-history.test.ts +210 -0
- package/__tests__/status-calculator.test.ts +141 -0
- package/coverage/clover.xml +187 -0
- package/coverage/coverage-final.json +6 -0
- package/coverage/lcov-report/EmailTracking.ts.html +973 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/config.ts.html +163 -0
- package/coverage/lcov-report/database.ts.html +622 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +176 -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/status-calculator.ts.html +214 -0
- package/coverage/lcov-report/types.ts.html +430 -0
- package/coverage/lcov.info +356 -0
- package/dist/EmailTracking.d.ts +22 -0
- package/dist/EmailTracking.d.ts.map +1 -0
- package/dist/EmailTracking.js +253 -0
- package/dist/EmailTracking.js.map +1 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +27 -0
- package/dist/config.js.map +1 -0
- package/dist/database.d.ts +22 -0
- package/dist/database.d.ts.map +1 -0
- package/dist/database.js +156 -0
- package/dist/database.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/status-calculator.d.ts +4 -0
- package/dist/status-calculator.d.ts.map +1 -0
- package/dist/status-calculator.js +40 -0
- package/dist/status-calculator.js.map +1 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +22 -0
- package/dist/types.js.map +1 -0
- package/jest.config.cjs +32 -0
- package/package.json +54 -0
- package/src/EmailTracking.ts +296 -0
- package/src/config.ts +26 -0
- package/src/database.ts +179 -0
- package/src/index.ts +12 -0
- package/src/status-calculator.ts +43 -0
- package/src/types.ts +115 -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 { EmailTracking } from '../src/EmailTracking';
|
|
10
|
+
import { EmailDeliveryStatus } from '../src/types';
|
|
11
|
+
|
|
12
|
+
describe('Integration', () => {
|
|
13
|
+
let tracking: EmailTracking;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
tracking = new EmailTracking();
|
|
17
|
+
await tracking.initialize();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await tracking.clearDatabase();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should handle complete email lifecycle', async () => {
|
|
25
|
+
// Send email
|
|
26
|
+
const sendResult = await tracking.recordEmailSent({
|
|
27
|
+
messageId: 'integration_msg',
|
|
28
|
+
provider: 'sendgrid',
|
|
29
|
+
from: 'sender@example.com',
|
|
30
|
+
to: ['recipient@example.com'],
|
|
31
|
+
subject: 'Integration Test',
|
|
32
|
+
campaignId: 'integration_campaign'
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(sendResult.success).toBe(true);
|
|
36
|
+
expect(sendResult.data?.currentStatus).toBe(EmailDeliveryStatus.SENT);
|
|
37
|
+
|
|
38
|
+
// Delivered
|
|
39
|
+
await tracking.processWebhookEvent({
|
|
40
|
+
messageId: 'integration_msg',
|
|
41
|
+
eventType: 'delivered',
|
|
42
|
+
timestamp: new Date().toISOString()
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Opened
|
|
46
|
+
await tracking.processWebhookEvent({
|
|
47
|
+
messageId: 'integration_msg',
|
|
48
|
+
eventType: 'open',
|
|
49
|
+
timestamp: new Date().toISOString()
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Clicked
|
|
53
|
+
await tracking.processWebhookEvent({
|
|
54
|
+
messageId: 'integration_msg',
|
|
55
|
+
eventType: 'click',
|
|
56
|
+
timestamp: new Date().toISOString()
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Check timeline
|
|
60
|
+
const timeline = await tracking.getTimeline(sendResult.data!.id);
|
|
61
|
+
expect(timeline.success).toBe(true);
|
|
62
|
+
expect(timeline.data?.events).toHaveLength(4);
|
|
63
|
+
expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.CLICKED);
|
|
64
|
+
|
|
65
|
+
// Check recipient history
|
|
66
|
+
const history = await tracking.getRecipientHistory('recipient@example.com');
|
|
67
|
+
expect(history.success).toBe(true);
|
|
68
|
+
expect(history.data?.totalSent).toBe(1);
|
|
69
|
+
expect(history.data?.totalClicked).toBe(1);
|
|
70
|
+
|
|
71
|
+
// Check campaign stats
|
|
72
|
+
const stats = await tracking.getCampaignStats('integration_campaign');
|
|
73
|
+
expect(stats.success).toBe(true);
|
|
74
|
+
expect(stats.data?.totalSent).toBe(1);
|
|
75
|
+
expect(stats.data?.totalClicked).toBe(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should work without NeverHub', async () => {
|
|
79
|
+
// Tests already run with NeverHub mocked to return false
|
|
80
|
+
const trackingNoHub = new EmailTracking();
|
|
81
|
+
await trackingNoHub.initialize();
|
|
82
|
+
|
|
83
|
+
const result = await trackingNoHub.recordEmailSent({
|
|
84
|
+
messageId: 'no_hub_msg',
|
|
85
|
+
provider: 'sendgrid',
|
|
86
|
+
from: 'sender@example.com',
|
|
87
|
+
to: ['test@example.com'],
|
|
88
|
+
subject: 'No Hub Test'
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(result.success).toBe(true);
|
|
92
|
+
expect(result.data?.id).toBeDefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should handle edge cases', async () => {
|
|
96
|
+
// Empty campaign stats
|
|
97
|
+
const emptyStats = await tracking.getCampaignStats('nonexistent_campaign');
|
|
98
|
+
expect(emptyStats.success).toBe(true);
|
|
99
|
+
expect(emptyStats.data?.totalSent).toBe(0);
|
|
100
|
+
|
|
101
|
+
// Unknown timeline
|
|
102
|
+
const unknownTimeline = await tracking.getTimeline('unknown_id');
|
|
103
|
+
expect(unknownTimeline.success).toBe(false);
|
|
104
|
+
|
|
105
|
+
// Unknown recipient
|
|
106
|
+
const unknownHistory = await tracking.getRecipientHistory('unknown@example.com');
|
|
107
|
+
expect(unknownHistory.success).toBe(true);
|
|
108
|
+
expect(unknownHistory.data?.totalSent).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should handle multiple initialization calls', async () => {
|
|
112
|
+
const tracker = new EmailTracking();
|
|
113
|
+
await tracker.initialize();
|
|
114
|
+
await tracker.initialize(); // Should not throw
|
|
115
|
+
|
|
116
|
+
const result = await tracker.recordEmailSent({
|
|
117
|
+
messageId: 'multi_init_msg',
|
|
118
|
+
provider: 'sendgrid',
|
|
119
|
+
from: 'sender@example.com',
|
|
120
|
+
to: ['test@example.com'],
|
|
121
|
+
subject: 'Multi Init Test'
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(result.success).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should handle all provider types', async () => {
|
|
128
|
+
const providers = ['sendgrid', 'mailgun', 'ses', 'postmark'];
|
|
129
|
+
|
|
130
|
+
for (const provider of providers) {
|
|
131
|
+
const result = await tracking.recordEmailSent({
|
|
132
|
+
messageId: `${provider}_msg`,
|
|
133
|
+
provider,
|
|
134
|
+
from: 'sender@example.com',
|
|
135
|
+
to: ['test@example.com'],
|
|
136
|
+
subject: `${provider} Test`
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(result.success).toBe(true);
|
|
140
|
+
expect(result.data?.provider).toBe(provider);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should handle different event type formats', async () => {
|
|
145
|
+
// Send email
|
|
146
|
+
await tracking.recordEmailSent({
|
|
147
|
+
messageId: 'format_test',
|
|
148
|
+
provider: 'sendgrid',
|
|
149
|
+
from: 'sender@example.com',
|
|
150
|
+
to: ['test@example.com'],
|
|
151
|
+
subject: 'Format Test'
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Test case-insensitive event types
|
|
155
|
+
const eventTypes = ['Delivered', 'BOUNCE', 'Open', 'CLICK'];
|
|
156
|
+
|
|
157
|
+
for (const eventType of eventTypes) {
|
|
158
|
+
await tracking.processWebhookEvent({
|
|
159
|
+
messageId: 'format_test',
|
|
160
|
+
eventType,
|
|
161
|
+
timestamp: new Date().toISOString()
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Should handle all events without error
|
|
166
|
+
// Process should not throw even with unknown message IDs
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should calculate reputation scores correctly', async () => {
|
|
170
|
+
const recipient = 'reputation@example.com';
|
|
171
|
+
|
|
172
|
+
// Send and deliver
|
|
173
|
+
await tracking.recordEmailSent({
|
|
174
|
+
messageId: 'rep_1',
|
|
175
|
+
provider: 'sendgrid',
|
|
176
|
+
from: 'sender@example.com',
|
|
177
|
+
to: [recipient],
|
|
178
|
+
subject: 'Rep Test 1'
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await tracking.processWebhookEvent({
|
|
182
|
+
messageId: 'rep_1',
|
|
183
|
+
eventType: 'delivered',
|
|
184
|
+
timestamp: new Date().toISOString()
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
let history = await tracking.getRecipientHistory(recipient);
|
|
188
|
+
expect(history.data?.reputationScore).toBe(100);
|
|
189
|
+
|
|
190
|
+
// Bounce - reduces by 10
|
|
191
|
+
await tracking.recordEmailSent({
|
|
192
|
+
messageId: 'rep_2',
|
|
193
|
+
provider: 'sendgrid',
|
|
194
|
+
from: 'sender@example.com',
|
|
195
|
+
to: [recipient],
|
|
196
|
+
subject: 'Rep Test 2'
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await tracking.processWebhookEvent({
|
|
200
|
+
messageId: 'rep_2',
|
|
201
|
+
eventType: 'bounce',
|
|
202
|
+
timestamp: new Date().toISOString()
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
history = await tracking.getRecipientHistory(recipient);
|
|
206
|
+
expect(history.data?.reputationScore).toBe(90);
|
|
207
|
+
|
|
208
|
+
// Spam complaint - reduces by 25
|
|
209
|
+
await tracking.recordEmailSent({
|
|
210
|
+
messageId: 'rep_3',
|
|
211
|
+
provider: 'sendgrid',
|
|
212
|
+
from: 'sender@example.com',
|
|
213
|
+
to: [recipient],
|
|
214
|
+
subject: 'Rep Test 3'
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await tracking.processWebhookEvent({
|
|
218
|
+
messageId: 'rep_3',
|
|
219
|
+
eventType: 'spamreport',
|
|
220
|
+
timestamp: new Date().toISOString()
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
history = await tracking.getRecipientHistory(recipient);
|
|
224
|
+
expect(history.data?.reputationScore).toBe(65);
|
|
225
|
+
|
|
226
|
+
// Unsubscribe - reduces by 5
|
|
227
|
+
await tracking.recordEmailSent({
|
|
228
|
+
messageId: 'rep_4',
|
|
229
|
+
provider: 'sendgrid',
|
|
230
|
+
from: 'sender@example.com',
|
|
231
|
+
to: [recipient],
|
|
232
|
+
subject: 'Rep Test 4'
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await tracking.processWebhookEvent({
|
|
236
|
+
messageId: 'rep_4',
|
|
237
|
+
eventType: 'unsubscribe',
|
|
238
|
+
timestamp: new Date().toISOString()
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
history = await tracking.getRecipientHistory(recipient);
|
|
242
|
+
expect(history.data?.reputationScore).toBe(60);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
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 { EmailTracking } from '../src/EmailTracking';
|
|
10
|
+
|
|
11
|
+
describe('RecipientHistory', () => {
|
|
12
|
+
let tracking: EmailTracking;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
tracking = new EmailTracking({ aggregateStats: true });
|
|
16
|
+
await tracking.initialize();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await tracking.clearDatabase();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should track recipient history across multiple emails', async () => {
|
|
24
|
+
const recipient = 'frequent@example.com';
|
|
25
|
+
|
|
26
|
+
// Send multiple emails
|
|
27
|
+
for (let i = 0; i < 5; i++) {
|
|
28
|
+
await tracking.recordEmailSent({
|
|
29
|
+
messageId: `msg_${i}`,
|
|
30
|
+
provider: 'sendgrid',
|
|
31
|
+
from: 'sender@example.com',
|
|
32
|
+
to: [recipient],
|
|
33
|
+
subject: `Test Email ${i}`
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Simulate delivery
|
|
37
|
+
await tracking.processWebhookEvent({
|
|
38
|
+
messageId: `msg_${i}`,
|
|
39
|
+
eventType: 'delivered',
|
|
40
|
+
timestamp: new Date(Date.now() + i * 1000).toISOString()
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Simulate opens (3 out of 5)
|
|
44
|
+
if (i < 3) {
|
|
45
|
+
await tracking.processWebhookEvent({
|
|
46
|
+
messageId: `msg_${i}`,
|
|
47
|
+
eventType: 'open',
|
|
48
|
+
timestamp: new Date(Date.now() + (i + 1) * 1000).toISOString()
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const history = await tracking.getRecipientHistory(recipient);
|
|
54
|
+
expect(history.success).toBe(true);
|
|
55
|
+
expect(history.data?.totalSent).toBe(5);
|
|
56
|
+
expect(history.data?.totalDelivered).toBe(5);
|
|
57
|
+
expect(history.data?.totalOpened).toBe(3);
|
|
58
|
+
expect(history.data?.emailAddress).toBe(recipient);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should track bounces and reduce reputation score', async () => {
|
|
62
|
+
const recipient = 'bouncy@example.com';
|
|
63
|
+
|
|
64
|
+
// Send email
|
|
65
|
+
await tracking.recordEmailSent({
|
|
66
|
+
messageId: 'msg_bounce',
|
|
67
|
+
provider: 'sendgrid',
|
|
68
|
+
from: 'sender@example.com',
|
|
69
|
+
to: [recipient],
|
|
70
|
+
subject: 'Bounce Test'
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Simulate bounce
|
|
74
|
+
await tracking.processWebhookEvent({
|
|
75
|
+
messageId: 'msg_bounce',
|
|
76
|
+
eventType: 'bounce',
|
|
77
|
+
timestamp: new Date().toISOString()
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const history = await tracking.getRecipientHistory(recipient);
|
|
81
|
+
expect(history.success).toBe(true);
|
|
82
|
+
expect(history.data?.totalBounced).toBe(1);
|
|
83
|
+
expect(history.data?.reputationScore).toBeLessThan(100);
|
|
84
|
+
expect(history.data?.lastBouncedAt).toBeDefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should track spam complaints and severely reduce reputation', async () => {
|
|
88
|
+
const recipient = 'complainer@example.com';
|
|
89
|
+
|
|
90
|
+
await tracking.recordEmailSent({
|
|
91
|
+
messageId: 'msg_spam',
|
|
92
|
+
provider: 'sendgrid',
|
|
93
|
+
from: 'sender@example.com',
|
|
94
|
+
to: [recipient],
|
|
95
|
+
subject: 'Spam Test'
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await tracking.processWebhookEvent({
|
|
99
|
+
messageId: 'msg_spam',
|
|
100
|
+
eventType: 'spamreport',
|
|
101
|
+
timestamp: new Date().toISOString()
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const history = await tracking.getRecipientHistory(recipient);
|
|
105
|
+
expect(history.success).toBe(true);
|
|
106
|
+
expect(history.data?.totalComplained).toBe(1);
|
|
107
|
+
expect(history.data?.reputationScore).toBeLessThan(80); // -25 penalty
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should track clicks separately', async () => {
|
|
111
|
+
const recipient = 'clicker@example.com';
|
|
112
|
+
|
|
113
|
+
for (let i = 0; i < 3; i++) {
|
|
114
|
+
await tracking.recordEmailSent({
|
|
115
|
+
messageId: `msg_click_${i}`,
|
|
116
|
+
provider: 'sendgrid',
|
|
117
|
+
from: 'sender@example.com',
|
|
118
|
+
to: [recipient],
|
|
119
|
+
subject: `Click Test ${i}`
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await tracking.processWebhookEvent({
|
|
123
|
+
messageId: `msg_click_${i}`,
|
|
124
|
+
eventType: 'delivered',
|
|
125
|
+
timestamp: new Date(Date.now() + i * 1000).toISOString()
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await tracking.processWebhookEvent({
|
|
129
|
+
messageId: `msg_click_${i}`,
|
|
130
|
+
eventType: 'click',
|
|
131
|
+
timestamp: new Date(Date.now() + (i + 1) * 1000).toISOString()
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const history = await tracking.getRecipientHistory(recipient);
|
|
136
|
+
expect(history.success).toBe(true);
|
|
137
|
+
expect(history.data?.totalSent).toBe(3);
|
|
138
|
+
expect(history.data?.totalClicked).toBe(3);
|
|
139
|
+
expect(history.data?.lastClickedAt).toBeDefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should return default history for unknown recipient', async () => {
|
|
143
|
+
const history = await tracking.getRecipientHistory('unknown@example.com');
|
|
144
|
+
expect(history.success).toBe(true);
|
|
145
|
+
expect(history.data?.totalSent).toBe(0);
|
|
146
|
+
expect(history.data?.totalDelivered).toBe(0);
|
|
147
|
+
expect(history.data?.reputationScore).toBe(100);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should track unsubscribes', async () => {
|
|
151
|
+
const recipient = 'unsubscribe@example.com';
|
|
152
|
+
|
|
153
|
+
await tracking.recordEmailSent({
|
|
154
|
+
messageId: 'msg_unsub',
|
|
155
|
+
provider: 'sendgrid',
|
|
156
|
+
from: 'sender@example.com',
|
|
157
|
+
to: [recipient],
|
|
158
|
+
subject: 'Unsubscribe Test'
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await tracking.processWebhookEvent({
|
|
162
|
+
messageId: 'msg_unsub',
|
|
163
|
+
eventType: 'unsubscribe',
|
|
164
|
+
timestamp: new Date().toISOString()
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const history = await tracking.getRecipientHistory(recipient);
|
|
168
|
+
expect(history.success).toBe(true);
|
|
169
|
+
expect(history.data?.totalUnsubscribed).toBe(1);
|
|
170
|
+
expect(history.data?.reputationScore).toBeLessThan(100); // -5 penalty
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should handle multiple recipients in single email', async () => {
|
|
174
|
+
const recipients = ['user1@example.com', 'user2@example.com', 'user3@example.com'];
|
|
175
|
+
|
|
176
|
+
await tracking.recordEmailSent({
|
|
177
|
+
messageId: 'msg_multi',
|
|
178
|
+
provider: 'sendgrid',
|
|
179
|
+
from: 'sender@example.com',
|
|
180
|
+
to: recipients,
|
|
181
|
+
subject: 'Multi Recipient Test'
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Check all recipients have updated history
|
|
185
|
+
for (const recipient of recipients) {
|
|
186
|
+
const history = await tracking.getRecipientHistory(recipient);
|
|
187
|
+
expect(history.success).toBe(true);
|
|
188
|
+
expect(history.data?.totalSent).toBe(1);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should not aggregate stats when disabled', async () => {
|
|
193
|
+
const trackingNoStats = new EmailTracking({ aggregateStats: false });
|
|
194
|
+
await trackingNoStats.initialize();
|
|
195
|
+
|
|
196
|
+
const recipient = 'nostats@example.com';
|
|
197
|
+
|
|
198
|
+
await trackingNoStats.recordEmailSent({
|
|
199
|
+
messageId: 'msg_nostats',
|
|
200
|
+
provider: 'sendgrid',
|
|
201
|
+
from: 'sender@example.com',
|
|
202
|
+
to: [recipient],
|
|
203
|
+
subject: 'No Stats Test'
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const history = await trackingNoStats.getRecipientHistory(recipient);
|
|
207
|
+
expect(history.success).toBe(true);
|
|
208
|
+
expect(history.data?.totalSent).toBe(0); // No stats aggregated
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
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 { calculateNewStatus, mapWebhookEventType } from '../src/status-calculator';
|
|
10
|
+
import { EmailDeliveryStatus } from '../src/types';
|
|
11
|
+
|
|
12
|
+
describe('StatusCalculator', () => {
|
|
13
|
+
describe('calculateNewStatus', () => {
|
|
14
|
+
it('should upgrade from sent to delivered', () => {
|
|
15
|
+
const newStatus = calculateNewStatus(
|
|
16
|
+
EmailDeliveryStatus.SENT,
|
|
17
|
+
EmailDeliveryStatus.DELIVERED
|
|
18
|
+
);
|
|
19
|
+
expect(newStatus).toBe(EmailDeliveryStatus.DELIVERED);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should upgrade from delivered to opened', () => {
|
|
23
|
+
const newStatus = calculateNewStatus(
|
|
24
|
+
EmailDeliveryStatus.DELIVERED,
|
|
25
|
+
EmailDeliveryStatus.OPENED
|
|
26
|
+
);
|
|
27
|
+
expect(newStatus).toBe(EmailDeliveryStatus.OPENED);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should upgrade from opened to clicked', () => {
|
|
31
|
+
const newStatus = calculateNewStatus(
|
|
32
|
+
EmailDeliveryStatus.OPENED,
|
|
33
|
+
EmailDeliveryStatus.CLICKED
|
|
34
|
+
);
|
|
35
|
+
expect(newStatus).toBe(EmailDeliveryStatus.CLICKED);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should not downgrade from clicked to delivered', () => {
|
|
39
|
+
const newStatus = calculateNewStatus(
|
|
40
|
+
EmailDeliveryStatus.CLICKED,
|
|
41
|
+
EmailDeliveryStatus.DELIVERED
|
|
42
|
+
);
|
|
43
|
+
expect(newStatus).toBe(EmailDeliveryStatus.CLICKED);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should not downgrade from opened to delivered', () => {
|
|
47
|
+
const newStatus = calculateNewStatus(
|
|
48
|
+
EmailDeliveryStatus.OPENED,
|
|
49
|
+
EmailDeliveryStatus.DELIVERED
|
|
50
|
+
);
|
|
51
|
+
expect(newStatus).toBe(EmailDeliveryStatus.OPENED);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should upgrade to bounced from any lower status', () => {
|
|
55
|
+
expect(calculateNewStatus(EmailDeliveryStatus.SENT, EmailDeliveryStatus.BOUNCED))
|
|
56
|
+
.toBe(EmailDeliveryStatus.BOUNCED);
|
|
57
|
+
expect(calculateNewStatus(EmailDeliveryStatus.DELIVERED, EmailDeliveryStatus.BOUNCED))
|
|
58
|
+
.toBe(EmailDeliveryStatus.BOUNCED);
|
|
59
|
+
expect(calculateNewStatus(EmailDeliveryStatus.OPENED, EmailDeliveryStatus.BOUNCED))
|
|
60
|
+
.toBe(EmailDeliveryStatus.BOUNCED);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should upgrade to complained from any lower status', () => {
|
|
64
|
+
expect(calculateNewStatus(EmailDeliveryStatus.SENT, EmailDeliveryStatus.COMPLAINED))
|
|
65
|
+
.toBe(EmailDeliveryStatus.COMPLAINED);
|
|
66
|
+
expect(calculateNewStatus(EmailDeliveryStatus.CLICKED, EmailDeliveryStatus.COMPLAINED))
|
|
67
|
+
.toBe(EmailDeliveryStatus.COMPLAINED);
|
|
68
|
+
expect(calculateNewStatus(EmailDeliveryStatus.BOUNCED, EmailDeliveryStatus.COMPLAINED))
|
|
69
|
+
.toBe(EmailDeliveryStatus.COMPLAINED);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should not downgrade from complained', () => {
|
|
73
|
+
expect(calculateNewStatus(EmailDeliveryStatus.COMPLAINED, EmailDeliveryStatus.CLICKED))
|
|
74
|
+
.toBe(EmailDeliveryStatus.COMPLAINED);
|
|
75
|
+
expect(calculateNewStatus(EmailDeliveryStatus.COMPLAINED, EmailDeliveryStatus.BOUNCED))
|
|
76
|
+
.toBe(EmailDeliveryStatus.COMPLAINED);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should upgrade to unsubscribed from lower statuses', () => {
|
|
80
|
+
expect(calculateNewStatus(EmailDeliveryStatus.SENT, EmailDeliveryStatus.UNSUBSCRIBED))
|
|
81
|
+
.toBe(EmailDeliveryStatus.UNSUBSCRIBED);
|
|
82
|
+
expect(calculateNewStatus(EmailDeliveryStatus.CLICKED, EmailDeliveryStatus.UNSUBSCRIBED))
|
|
83
|
+
.toBe(EmailDeliveryStatus.UNSUBSCRIBED);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should not downgrade from unsubscribed except to complained', () => {
|
|
87
|
+
expect(calculateNewStatus(EmailDeliveryStatus.UNSUBSCRIBED, EmailDeliveryStatus.CLICKED))
|
|
88
|
+
.toBe(EmailDeliveryStatus.UNSUBSCRIBED);
|
|
89
|
+
expect(calculateNewStatus(EmailDeliveryStatus.UNSUBSCRIBED, EmailDeliveryStatus.COMPLAINED))
|
|
90
|
+
.toBe(EmailDeliveryStatus.COMPLAINED);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should handle failed status', () => {
|
|
94
|
+
expect(calculateNewStatus(EmailDeliveryStatus.SENT, EmailDeliveryStatus.FAILED))
|
|
95
|
+
.toBe(EmailDeliveryStatus.FAILED);
|
|
96
|
+
expect(calculateNewStatus(EmailDeliveryStatus.FAILED, EmailDeliveryStatus.BOUNCED))
|
|
97
|
+
.toBe(EmailDeliveryStatus.BOUNCED);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('mapWebhookEventType', () => {
|
|
102
|
+
it('should map delivered event', () => {
|
|
103
|
+
expect(mapWebhookEventType('delivered')).toBe(EmailDeliveryStatus.DELIVERED);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should map bounce event', () => {
|
|
107
|
+
expect(mapWebhookEventType('bounce')).toBe(EmailDeliveryStatus.BOUNCED);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should map open event', () => {
|
|
111
|
+
expect(mapWebhookEventType('open')).toBe(EmailDeliveryStatus.OPENED);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should map click event', () => {
|
|
115
|
+
expect(mapWebhookEventType('click')).toBe(EmailDeliveryStatus.CLICKED);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should map spamreport event', () => {
|
|
119
|
+
expect(mapWebhookEventType('spamreport')).toBe(EmailDeliveryStatus.COMPLAINED);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should map unsubscribe event', () => {
|
|
123
|
+
expect(mapWebhookEventType('unsubscribe')).toBe(EmailDeliveryStatus.UNSUBSCRIBED);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should map failed event', () => {
|
|
127
|
+
expect(mapWebhookEventType('failed')).toBe(EmailDeliveryStatus.FAILED);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should handle case-insensitive mapping', () => {
|
|
131
|
+
expect(mapWebhookEventType('DELIVERED')).toBe(EmailDeliveryStatus.DELIVERED);
|
|
132
|
+
expect(mapWebhookEventType('Bounce')).toBe(EmailDeliveryStatus.BOUNCED);
|
|
133
|
+
expect(mapWebhookEventType('SpamReport')).toBe(EmailDeliveryStatus.COMPLAINED);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should default to SENT for unknown event types', () => {
|
|
137
|
+
expect(mapWebhookEventType('unknown')).toBe(EmailDeliveryStatus.SENT);
|
|
138
|
+
expect(mapWebhookEventType('invalid_event')).toBe(EmailDeliveryStatus.SENT);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|