@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.
Files changed (58) hide show
  1. package/.eslintrc.cjs +24 -0
  2. package/README.md +478 -0
  3. package/__mocks__/neverhub-adapter.ts +29 -0
  4. package/__tests__/EmailTracking.test.ts +334 -0
  5. package/__tests__/campaign-stats.test.ts +224 -0
  6. package/__tests__/config.test.ts +108 -0
  7. package/__tests__/integration.test.ts +244 -0
  8. package/__tests__/recipient-history.test.ts +210 -0
  9. package/__tests__/status-calculator.test.ts +141 -0
  10. package/coverage/clover.xml +187 -0
  11. package/coverage/coverage-final.json +6 -0
  12. package/coverage/lcov-report/EmailTracking.ts.html +973 -0
  13. package/coverage/lcov-report/base.css +224 -0
  14. package/coverage/lcov-report/block-navigation.js +87 -0
  15. package/coverage/lcov-report/config.ts.html +163 -0
  16. package/coverage/lcov-report/database.ts.html +622 -0
  17. package/coverage/lcov-report/favicon.png +0 -0
  18. package/coverage/lcov-report/index.html +176 -0
  19. package/coverage/lcov-report/prettify.css +1 -0
  20. package/coverage/lcov-report/prettify.js +2 -0
  21. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  22. package/coverage/lcov-report/sorter.js +210 -0
  23. package/coverage/lcov-report/status-calculator.ts.html +214 -0
  24. package/coverage/lcov-report/types.ts.html +430 -0
  25. package/coverage/lcov.info +356 -0
  26. package/dist/EmailTracking.d.ts +22 -0
  27. package/dist/EmailTracking.d.ts.map +1 -0
  28. package/dist/EmailTracking.js +253 -0
  29. package/dist/EmailTracking.js.map +1 -0
  30. package/dist/config.d.ts +3 -0
  31. package/dist/config.d.ts.map +1 -0
  32. package/dist/config.js +27 -0
  33. package/dist/config.js.map +1 -0
  34. package/dist/database.d.ts +22 -0
  35. package/dist/database.d.ts.map +1 -0
  36. package/dist/database.js +156 -0
  37. package/dist/database.js.map +1 -0
  38. package/dist/index.d.ts +5 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +33 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/status-calculator.d.ts +4 -0
  43. package/dist/status-calculator.d.ts.map +1 -0
  44. package/dist/status-calculator.js +40 -0
  45. package/dist/status-calculator.js.map +1 -0
  46. package/dist/types.d.ts +98 -0
  47. package/dist/types.d.ts.map +1 -0
  48. package/dist/types.js +22 -0
  49. package/dist/types.js.map +1 -0
  50. package/jest.config.cjs +32 -0
  51. package/package.json +54 -0
  52. package/src/EmailTracking.ts +296 -0
  53. package/src/config.ts +26 -0
  54. package/src/database.ts +179 -0
  55. package/src/index.ts +12 -0
  56. package/src/status-calculator.ts +43 -0
  57. package/src/types.ts +115 -0
  58. 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
+ });