@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,334 @@
|
|
|
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('EmailTracking', () => {
|
|
13
|
+
let tracking: EmailTracking;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
tracking = new EmailTracking({
|
|
17
|
+
enableOpenTracking: true,
|
|
18
|
+
enableClickTracking: true,
|
|
19
|
+
eventRetentionDays: 90,
|
|
20
|
+
aggregateStats: true
|
|
21
|
+
});
|
|
22
|
+
await tracking.initialize();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
await tracking.clearDatabase();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('recordEmailSent', () => {
|
|
30
|
+
it('should record email send and create tracking ID', async () => {
|
|
31
|
+
const result = await tracking.recordEmailSent({
|
|
32
|
+
messageId: 'msg_123',
|
|
33
|
+
provider: 'sendgrid',
|
|
34
|
+
from: 'sender@example.com',
|
|
35
|
+
to: ['recipient@example.com'],
|
|
36
|
+
subject: 'Test Email'
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(result.success).toBe(true);
|
|
40
|
+
expect(result.data?.id).toMatch(/^track_/);
|
|
41
|
+
expect(result.data?.currentStatus).toBe(EmailDeliveryStatus.SENT);
|
|
42
|
+
expect(result.data?.messageId).toBe('msg_123');
|
|
43
|
+
expect(result.data?.provider).toBe('sendgrid');
|
|
44
|
+
expect(result.data?.from).toBe('sender@example.com');
|
|
45
|
+
expect(result.data?.to).toEqual(['recipient@example.com']);
|
|
46
|
+
expect(result.data?.subject).toBe('Test Email');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should record email with CC and BCC', async () => {
|
|
50
|
+
const result = await tracking.recordEmailSent({
|
|
51
|
+
messageId: 'msg_456',
|
|
52
|
+
provider: 'mailgun',
|
|
53
|
+
from: 'sender@example.com',
|
|
54
|
+
to: ['recipient@example.com'],
|
|
55
|
+
cc: ['cc@example.com'],
|
|
56
|
+
bcc: ['bcc@example.com'],
|
|
57
|
+
subject: 'Test Email with CC/BCC'
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(result.success).toBe(true);
|
|
61
|
+
expect(result.data?.cc).toEqual(['cc@example.com']);
|
|
62
|
+
expect(result.data?.bcc).toEqual(['bcc@example.com']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should record email with campaign ID and metadata', async () => {
|
|
66
|
+
const result = await tracking.recordEmailSent({
|
|
67
|
+
messageId: 'msg_789',
|
|
68
|
+
provider: 'ses',
|
|
69
|
+
from: 'sender@example.com',
|
|
70
|
+
to: ['recipient@example.com'],
|
|
71
|
+
subject: 'Campaign Email',
|
|
72
|
+
campaignId: 'campaign_123',
|
|
73
|
+
metadata: { source: 'marketing', segment: 'premium' }
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(result.success).toBe(true);
|
|
77
|
+
expect(result.data?.campaignId).toBe('campaign_123');
|
|
78
|
+
expect(result.data?.metadata).toEqual({ source: 'marketing', segment: 'premium' });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should handle errors gracefully', async () => {
|
|
82
|
+
// Force an error by using invalid provider
|
|
83
|
+
const result = await tracking.recordEmailSent({
|
|
84
|
+
messageId: '',
|
|
85
|
+
provider: '',
|
|
86
|
+
from: '',
|
|
87
|
+
to: [],
|
|
88
|
+
subject: ''
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Should still return a result (even if it's an empty tracking record)
|
|
92
|
+
expect(result.success).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('processWebhookEvent', () => {
|
|
97
|
+
it('should link webhook event to send record', async () => {
|
|
98
|
+
// Record send
|
|
99
|
+
const sendResult = await tracking.recordEmailSent({
|
|
100
|
+
messageId: 'msg_123',
|
|
101
|
+
provider: 'sendgrid',
|
|
102
|
+
from: 'sender@example.com',
|
|
103
|
+
to: ['recipient@example.com'],
|
|
104
|
+
subject: 'Test Email'
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Process webhook event
|
|
108
|
+
const webhookResult = await tracking.processWebhookEvent({
|
|
109
|
+
messageId: 'msg_123',
|
|
110
|
+
eventType: 'delivered',
|
|
111
|
+
timestamp: new Date().toISOString(),
|
|
112
|
+
metadata: { smtpResponse: '250 OK' }
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(webhookResult.success).toBe(true);
|
|
116
|
+
|
|
117
|
+
// Check timeline
|
|
118
|
+
const timeline = await tracking.getTimeline(sendResult.data!.id);
|
|
119
|
+
expect(timeline.success).toBe(true);
|
|
120
|
+
expect(timeline.data?.events).toHaveLength(2); // sent + delivered
|
|
121
|
+
expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.DELIVERED);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should handle missing send record gracefully', async () => {
|
|
125
|
+
const result = await tracking.processWebhookEvent({
|
|
126
|
+
messageId: 'unknown_msg',
|
|
127
|
+
eventType: 'delivered',
|
|
128
|
+
timestamp: new Date().toISOString()
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(result.success).toBe(false);
|
|
132
|
+
expect(result.error).toBe('Send record not found');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should map webhook event types correctly', async () => {
|
|
136
|
+
const sendResult = await tracking.recordEmailSent({
|
|
137
|
+
messageId: 'msg_456',
|
|
138
|
+
provider: 'sendgrid',
|
|
139
|
+
from: 'sender@example.com',
|
|
140
|
+
to: ['recipient@example.com'],
|
|
141
|
+
subject: 'Test Email'
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Test bounce event
|
|
145
|
+
await tracking.processWebhookEvent({
|
|
146
|
+
messageId: 'msg_456',
|
|
147
|
+
eventType: 'bounce',
|
|
148
|
+
timestamp: new Date().toISOString()
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const timeline = await tracking.getTimeline(sendResult.data!.id);
|
|
152
|
+
expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.BOUNCED);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should handle spam report event', async () => {
|
|
156
|
+
const sendResult = await tracking.recordEmailSent({
|
|
157
|
+
messageId: 'msg_789',
|
|
158
|
+
provider: 'sendgrid',
|
|
159
|
+
from: 'sender@example.com',
|
|
160
|
+
to: ['recipient@example.com'],
|
|
161
|
+
subject: 'Test Email'
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await tracking.processWebhookEvent({
|
|
165
|
+
messageId: 'msg_789',
|
|
166
|
+
eventType: 'spamreport',
|
|
167
|
+
timestamp: new Date().toISOString()
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const timeline = await tracking.getTimeline(sendResult.data!.id);
|
|
171
|
+
expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.COMPLAINED);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should handle unsubscribe event', async () => {
|
|
175
|
+
const sendResult = await tracking.recordEmailSent({
|
|
176
|
+
messageId: 'msg_101',
|
|
177
|
+
provider: 'sendgrid',
|
|
178
|
+
from: 'sender@example.com',
|
|
179
|
+
to: ['recipient@example.com'],
|
|
180
|
+
subject: 'Test Email'
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await tracking.processWebhookEvent({
|
|
184
|
+
messageId: 'msg_101',
|
|
185
|
+
eventType: 'unsubscribe',
|
|
186
|
+
timestamp: new Date().toISOString()
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const timeline = await tracking.getTimeline(sendResult.data!.id);
|
|
190
|
+
expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.UNSUBSCRIBED);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('status priority', () => {
|
|
195
|
+
it('should handle status priority correctly', async () => {
|
|
196
|
+
const sendResult = await tracking.recordEmailSent({
|
|
197
|
+
messageId: 'msg_456',
|
|
198
|
+
provider: 'sendgrid',
|
|
199
|
+
from: 'sender@example.com',
|
|
200
|
+
to: ['recipient@example.com'],
|
|
201
|
+
subject: 'Test Email'
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Delivered
|
|
205
|
+
await tracking.processWebhookEvent({
|
|
206
|
+
messageId: 'msg_456',
|
|
207
|
+
eventType: 'delivered',
|
|
208
|
+
timestamp: new Date().toISOString()
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
let timeline = await tracking.getTimeline(sendResult.data!.id);
|
|
212
|
+
expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.DELIVERED);
|
|
213
|
+
|
|
214
|
+
// Opened (higher priority than delivered)
|
|
215
|
+
await tracking.processWebhookEvent({
|
|
216
|
+
messageId: 'msg_456',
|
|
217
|
+
eventType: 'open',
|
|
218
|
+
timestamp: new Date(Date.now() + 1000).toISOString()
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
timeline = await tracking.getTimeline(sendResult.data!.id);
|
|
222
|
+
expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.OPENED);
|
|
223
|
+
|
|
224
|
+
// Clicked (higher priority than opened)
|
|
225
|
+
await tracking.processWebhookEvent({
|
|
226
|
+
messageId: 'msg_456',
|
|
227
|
+
eventType: 'click',
|
|
228
|
+
timestamp: new Date(Date.now() + 2000).toISOString()
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
timeline = await tracking.getTimeline(sendResult.data!.id);
|
|
232
|
+
expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.CLICKED);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should not downgrade status to lower priority', async () => {
|
|
236
|
+
const sendResult = await tracking.recordEmailSent({
|
|
237
|
+
messageId: 'msg_999',
|
|
238
|
+
provider: 'sendgrid',
|
|
239
|
+
from: 'sender@example.com',
|
|
240
|
+
to: ['recipient@example.com'],
|
|
241
|
+
subject: 'Test Email'
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Clicked (high priority)
|
|
245
|
+
await tracking.processWebhookEvent({
|
|
246
|
+
messageId: 'msg_999',
|
|
247
|
+
eventType: 'click',
|
|
248
|
+
timestamp: new Date().toISOString()
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Try to set to delivered (lower priority) - should not change
|
|
252
|
+
await tracking.processWebhookEvent({
|
|
253
|
+
messageId: 'msg_999',
|
|
254
|
+
eventType: 'delivered',
|
|
255
|
+
timestamp: new Date(Date.now() + 1000).toISOString()
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const timeline = await tracking.getTimeline(sendResult.data!.id);
|
|
259
|
+
expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.CLICKED);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should prioritize complained status highest', async () => {
|
|
263
|
+
const sendResult = await tracking.recordEmailSent({
|
|
264
|
+
messageId: 'msg_888',
|
|
265
|
+
provider: 'sendgrid',
|
|
266
|
+
from: 'sender@example.com',
|
|
267
|
+
to: ['recipient@example.com'],
|
|
268
|
+
subject: 'Test Email'
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Clicked
|
|
272
|
+
await tracking.processWebhookEvent({
|
|
273
|
+
messageId: 'msg_888',
|
|
274
|
+
eventType: 'click',
|
|
275
|
+
timestamp: new Date().toISOString()
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Spam complaint (highest priority)
|
|
279
|
+
await tracking.processWebhookEvent({
|
|
280
|
+
messageId: 'msg_888',
|
|
281
|
+
eventType: 'spamreport',
|
|
282
|
+
timestamp: new Date(Date.now() + 1000).toISOString()
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const timeline = await tracking.getTimeline(sendResult.data!.id);
|
|
286
|
+
expect(timeline.data?.currentStatus).toBe(EmailDeliveryStatus.COMPLAINED);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('getTimeline', () => {
|
|
291
|
+
it('should retrieve complete event timeline', async () => {
|
|
292
|
+
const sendResult = await tracking.recordEmailSent({
|
|
293
|
+
messageId: 'msg_timeline',
|
|
294
|
+
provider: 'sendgrid',
|
|
295
|
+
from: 'sender@example.com',
|
|
296
|
+
to: ['recipient@example.com'],
|
|
297
|
+
subject: 'Timeline Test'
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Add multiple events
|
|
301
|
+
await tracking.processWebhookEvent({
|
|
302
|
+
messageId: 'msg_timeline',
|
|
303
|
+
eventType: 'delivered',
|
|
304
|
+
timestamp: new Date(Date.now() + 1000).toISOString()
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await tracking.processWebhookEvent({
|
|
308
|
+
messageId: 'msg_timeline',
|
|
309
|
+
eventType: 'open',
|
|
310
|
+
timestamp: new Date(Date.now() + 2000).toISOString()
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await tracking.processWebhookEvent({
|
|
314
|
+
messageId: 'msg_timeline',
|
|
315
|
+
eventType: 'click',
|
|
316
|
+
timestamp: new Date(Date.now() + 3000).toISOString()
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const timeline = await tracking.getTimeline(sendResult.data!.id);
|
|
320
|
+
expect(timeline.success).toBe(true);
|
|
321
|
+
expect(timeline.data?.events).toHaveLength(4); // sent + delivered + open + click
|
|
322
|
+
expect(timeline.data?.events[0].eventType).toBe(EmailDeliveryStatus.SENT);
|
|
323
|
+
expect(timeline.data?.events[1].eventType).toBe(EmailDeliveryStatus.DELIVERED);
|
|
324
|
+
expect(timeline.data?.events[2].eventType).toBe(EmailDeliveryStatus.OPENED);
|
|
325
|
+
expect(timeline.data?.events[3].eventType).toBe(EmailDeliveryStatus.CLICKED);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should return error for unknown tracking ID', async () => {
|
|
329
|
+
const timeline = await tracking.getTimeline('unknown_id');
|
|
330
|
+
expect(timeline.success).toBe(false);
|
|
331
|
+
expect(timeline.error).toBe('Send record not found');
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
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('CampaignStatistics', () => {
|
|
12
|
+
let tracking: EmailTracking;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
tracking = new EmailTracking();
|
|
16
|
+
await tracking.initialize();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await tracking.clearDatabase();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should calculate campaign statistics correctly', async () => {
|
|
24
|
+
const campaignId = 'campaign_123';
|
|
25
|
+
|
|
26
|
+
// Send 10 emails for campaign
|
|
27
|
+
for (let i = 0; i < 10; i++) {
|
|
28
|
+
await tracking.recordEmailSent({
|
|
29
|
+
messageId: `msg_camp_${i}`,
|
|
30
|
+
provider: 'sendgrid',
|
|
31
|
+
from: 'sender@example.com',
|
|
32
|
+
to: [`recipient${i}@example.com`],
|
|
33
|
+
subject: 'Campaign Email',
|
|
34
|
+
campaignId
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// 8 delivered
|
|
38
|
+
if (i < 8) {
|
|
39
|
+
await tracking.processWebhookEvent({
|
|
40
|
+
messageId: `msg_camp_${i}`,
|
|
41
|
+
eventType: 'delivered',
|
|
42
|
+
timestamp: new Date(Date.now() + i * 1000).toISOString()
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 2 bounced
|
|
47
|
+
if (i >= 8) {
|
|
48
|
+
await tracking.processWebhookEvent({
|
|
49
|
+
messageId: `msg_camp_${i}`,
|
|
50
|
+
eventType: 'bounce',
|
|
51
|
+
timestamp: new Date(Date.now() + i * 1000).toISOString()
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 5 opened
|
|
56
|
+
if (i < 5) {
|
|
57
|
+
await tracking.processWebhookEvent({
|
|
58
|
+
messageId: `msg_camp_${i}`,
|
|
59
|
+
eventType: 'open',
|
|
60
|
+
timestamp: new Date(Date.now() + (i + 1) * 1000).toISOString()
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 2 clicked
|
|
65
|
+
if (i < 2) {
|
|
66
|
+
await tracking.processWebhookEvent({
|
|
67
|
+
messageId: `msg_camp_${i}`,
|
|
68
|
+
eventType: 'click',
|
|
69
|
+
timestamp: new Date(Date.now() + (i + 2) * 1000).toISOString()
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stats = await tracking.getCampaignStats(campaignId);
|
|
75
|
+
expect(stats.success).toBe(true);
|
|
76
|
+
expect(stats.data?.campaignId).toBe(campaignId);
|
|
77
|
+
expect(stats.data?.totalSent).toBe(10);
|
|
78
|
+
expect(stats.data?.totalDelivered).toBe(8); // includes opened and clicked
|
|
79
|
+
expect(stats.data?.totalBounced).toBe(2);
|
|
80
|
+
expect(stats.data?.totalOpened).toBe(5); // includes clicked
|
|
81
|
+
expect(stats.data?.totalClicked).toBe(2);
|
|
82
|
+
expect(stats.data?.deliveryRate).toBe(80); // 8/10 * 100
|
|
83
|
+
expect(stats.data?.openRate).toBe(62.5); // 5/8 * 100
|
|
84
|
+
expect(stats.data?.clickRate).toBe(40); // 2/5 * 100
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should handle campaign with no deliveries', async () => {
|
|
88
|
+
const campaignId = 'campaign_fail';
|
|
89
|
+
|
|
90
|
+
// All bounces
|
|
91
|
+
for (let i = 0; i < 5; i++) {
|
|
92
|
+
await tracking.recordEmailSent({
|
|
93
|
+
messageId: `msg_fail_${i}`,
|
|
94
|
+
provider: 'sendgrid',
|
|
95
|
+
from: 'sender@example.com',
|
|
96
|
+
to: [`bad${i}@example.com`],
|
|
97
|
+
subject: 'Failed Campaign',
|
|
98
|
+
campaignId
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await tracking.processWebhookEvent({
|
|
102
|
+
messageId: `msg_fail_${i}`,
|
|
103
|
+
eventType: 'bounce',
|
|
104
|
+
timestamp: new Date(Date.now() + i * 1000).toISOString()
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const stats = await tracking.getCampaignStats(campaignId);
|
|
109
|
+
expect(stats.success).toBe(true);
|
|
110
|
+
expect(stats.data?.totalSent).toBe(5);
|
|
111
|
+
expect(stats.data?.totalDelivered).toBe(0);
|
|
112
|
+
expect(stats.data?.totalBounced).toBe(5);
|
|
113
|
+
expect(stats.data?.deliveryRate).toBe(0);
|
|
114
|
+
expect(stats.data?.openRate).toBe(0);
|
|
115
|
+
expect(stats.data?.clickRate).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should handle empty campaign', async () => {
|
|
119
|
+
const stats = await tracking.getCampaignStats('empty_campaign');
|
|
120
|
+
expect(stats.success).toBe(true);
|
|
121
|
+
expect(stats.data?.totalSent).toBe(0);
|
|
122
|
+
expect(stats.data?.deliveryRate).toBe(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should track spam complaints in campaign', async () => {
|
|
126
|
+
const campaignId = 'campaign_spam';
|
|
127
|
+
|
|
128
|
+
for (let i = 0; i < 5; i++) {
|
|
129
|
+
await tracking.recordEmailSent({
|
|
130
|
+
messageId: `msg_spam_${i}`,
|
|
131
|
+
provider: 'sendgrid',
|
|
132
|
+
from: 'sender@example.com',
|
|
133
|
+
to: [`user${i}@example.com`],
|
|
134
|
+
subject: 'Spam Test',
|
|
135
|
+
campaignId
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await tracking.processWebhookEvent({
|
|
139
|
+
messageId: `msg_spam_${i}`,
|
|
140
|
+
eventType: 'delivered',
|
|
141
|
+
timestamp: new Date(Date.now() + i * 1000).toISOString()
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// 2 complaints
|
|
145
|
+
if (i < 2) {
|
|
146
|
+
await tracking.processWebhookEvent({
|
|
147
|
+
messageId: `msg_spam_${i}`,
|
|
148
|
+
eventType: 'spamreport',
|
|
149
|
+
timestamp: new Date(Date.now() + (i + 1) * 1000).toISOString()
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const stats = await tracking.getCampaignStats(campaignId);
|
|
155
|
+
expect(stats.success).toBe(true);
|
|
156
|
+
expect(stats.data?.totalComplained).toBe(2);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should track unsubscribes in campaign', async () => {
|
|
160
|
+
const campaignId = 'campaign_unsub';
|
|
161
|
+
|
|
162
|
+
for (let i = 0; i < 5; i++) {
|
|
163
|
+
await tracking.recordEmailSent({
|
|
164
|
+
messageId: `msg_unsub_${i}`,
|
|
165
|
+
provider: 'sendgrid',
|
|
166
|
+
from: 'sender@example.com',
|
|
167
|
+
to: [`user${i}@example.com`],
|
|
168
|
+
subject: 'Unsubscribe Test',
|
|
169
|
+
campaignId
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await tracking.processWebhookEvent({
|
|
173
|
+
messageId: `msg_unsub_${i}`,
|
|
174
|
+
eventType: 'delivered',
|
|
175
|
+
timestamp: new Date(Date.now() + i * 1000).toISOString()
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// 1 unsubscribe
|
|
179
|
+
if (i === 0) {
|
|
180
|
+
await tracking.processWebhookEvent({
|
|
181
|
+
messageId: `msg_unsub_${i}`,
|
|
182
|
+
eventType: 'unsubscribe',
|
|
183
|
+
timestamp: new Date(Date.now() + (i + 1) * 1000).toISOString()
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const stats = await tracking.getCampaignStats(campaignId);
|
|
189
|
+
expect(stats.success).toBe(true);
|
|
190
|
+
expect(stats.data?.totalUnsubscribed).toBe(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should only count emails for specified campaign', async () => {
|
|
194
|
+
// Campaign A
|
|
195
|
+
for (let i = 0; i < 3; i++) {
|
|
196
|
+
await tracking.recordEmailSent({
|
|
197
|
+
messageId: `msg_a_${i}`,
|
|
198
|
+
provider: 'sendgrid',
|
|
199
|
+
from: 'sender@example.com',
|
|
200
|
+
to: [`user${i}@example.com`],
|
|
201
|
+
subject: 'Campaign A',
|
|
202
|
+
campaignId: 'campaign_a'
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Campaign B
|
|
207
|
+
for (let i = 0; i < 5; i++) {
|
|
208
|
+
await tracking.recordEmailSent({
|
|
209
|
+
messageId: `msg_b_${i}`,
|
|
210
|
+
provider: 'sendgrid',
|
|
211
|
+
from: 'sender@example.com',
|
|
212
|
+
to: [`user${i}@example.com`],
|
|
213
|
+
subject: 'Campaign B',
|
|
214
|
+
campaignId: 'campaign_b'
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const statsA = await tracking.getCampaignStats('campaign_a');
|
|
219
|
+
const statsB = await tracking.getCampaignStats('campaign_b');
|
|
220
|
+
|
|
221
|
+
expect(statsA.data?.totalSent).toBe(3);
|
|
222
|
+
expect(statsB.data?.totalSent).toBe(5);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
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 { loadConfig } from '../src/config';
|
|
10
|
+
|
|
11
|
+
describe('Config', () => {
|
|
12
|
+
const originalEnv = process.env;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
jest.resetModules();
|
|
16
|
+
process.env = { ...originalEnv };
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
process.env = originalEnv;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should use default values when no config provided', () => {
|
|
24
|
+
const config = loadConfig();
|
|
25
|
+
expect(config.enableOpenTracking).toBe(true);
|
|
26
|
+
expect(config.enableClickTracking).toBe(true);
|
|
27
|
+
expect(config.eventRetentionDays).toBe(90);
|
|
28
|
+
expect(config.aggregateStats).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should use user config over defaults', () => {
|
|
32
|
+
const config = loadConfig({
|
|
33
|
+
enableOpenTracking: false,
|
|
34
|
+
enableClickTracking: false,
|
|
35
|
+
eventRetentionDays: 30,
|
|
36
|
+
aggregateStats: false
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(config.enableOpenTracking).toBe(false);
|
|
40
|
+
expect(config.enableClickTracking).toBe(false);
|
|
41
|
+
expect(config.eventRetentionDays).toBe(30);
|
|
42
|
+
expect(config.aggregateStats).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should read from environment variables', () => {
|
|
46
|
+
process.env.EMAIL_TRACKING_ENABLE_OPEN_TRACKING = 'false';
|
|
47
|
+
process.env.EMAIL_TRACKING_ENABLE_CLICK_TRACKING = 'false';
|
|
48
|
+
process.env.EMAIL_TRACKING_EVENT_RETENTION_DAYS = '60';
|
|
49
|
+
process.env.EMAIL_TRACKING_AGGREGATE_STATS = 'false';
|
|
50
|
+
|
|
51
|
+
const config = loadConfig();
|
|
52
|
+
expect(config.enableOpenTracking).toBe(false);
|
|
53
|
+
expect(config.enableClickTracking).toBe(false);
|
|
54
|
+
expect(config.eventRetentionDays).toBe(60);
|
|
55
|
+
expect(config.aggregateStats).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should prioritize user config over environment variables', () => {
|
|
59
|
+
process.env.EMAIL_TRACKING_ENABLE_OPEN_TRACKING = 'false';
|
|
60
|
+
process.env.EMAIL_TRACKING_EVENT_RETENTION_DAYS = '30';
|
|
61
|
+
|
|
62
|
+
const config = loadConfig({
|
|
63
|
+
enableOpenTracking: true,
|
|
64
|
+
eventRetentionDays: 120
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(config.enableOpenTracking).toBe(true);
|
|
68
|
+
expect(config.eventRetentionDays).toBe(120);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should handle partial user config', () => {
|
|
72
|
+
const config = loadConfig({
|
|
73
|
+
enableOpenTracking: false
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(config.enableOpenTracking).toBe(false);
|
|
77
|
+
expect(config.enableClickTracking).toBe(true); // Default
|
|
78
|
+
expect(config.eventRetentionDays).toBe(90); // Default
|
|
79
|
+
expect(config.aggregateStats).toBe(true); // Default
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should parse retention days as integer', () => {
|
|
83
|
+
process.env.EMAIL_TRACKING_EVENT_RETENTION_DAYS = '45';
|
|
84
|
+
const config = loadConfig();
|
|
85
|
+
expect(config.eventRetentionDays).toBe(45);
|
|
86
|
+
expect(typeof config.eventRetentionDays).toBe('number');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should handle invalid retention days gracefully', () => {
|
|
90
|
+
process.env.EMAIL_TRACKING_EVENT_RETENTION_DAYS = 'invalid';
|
|
91
|
+
const config = loadConfig();
|
|
92
|
+
expect(isNaN(config.eventRetentionDays)).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should handle boolean environment variables correctly', () => {
|
|
96
|
+
process.env.EMAIL_TRACKING_ENABLE_OPEN_TRACKING = 'true';
|
|
97
|
+
const config1 = loadConfig();
|
|
98
|
+
expect(config1.enableOpenTracking).toBe(true);
|
|
99
|
+
|
|
100
|
+
process.env.EMAIL_TRACKING_ENABLE_OPEN_TRACKING = 'false';
|
|
101
|
+
const config2 = loadConfig();
|
|
102
|
+
expect(config2.enableOpenTracking).toBe(false);
|
|
103
|
+
|
|
104
|
+
process.env.EMAIL_TRACKING_ENABLE_OPEN_TRACKING = 'anything';
|
|
105
|
+
const config3 = loadConfig();
|
|
106
|
+
expect(config3.enableOpenTracking).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|