@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,296 @@
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 { NeverHubAdapter } from '@bernierllc/neverhub-adapter';
10
+ import {
11
+ EmailSendRecord,
12
+ EmailEvent,
13
+ EmailTimeline,
14
+ RecipientHistory,
15
+ TrackingConfig,
16
+ EmailTrackingResult,
17
+ RecordEmailSentData,
18
+ WebhookEventData,
19
+ EmailDeliveryStatus,
20
+ EmailProvider,
21
+ CampaignStatistics
22
+ } from './types.js';
23
+ import { loadConfig } from './config.js';
24
+ import { calculateNewStatus, mapWebhookEventType } from './status-calculator.js';
25
+ import { Database } from './database.js';
26
+
27
+ export class EmailTracking {
28
+ private neverhub?: NeverHubAdapter;
29
+ private logger?: Console;
30
+ private config: Required<TrackingConfig>;
31
+ private database: Database;
32
+ private initialized = false;
33
+
34
+ constructor(config: TrackingConfig = {}) {
35
+ this.config = loadConfig(config);
36
+ this.database = new Database();
37
+ }
38
+
39
+ async initialize(): Promise<void> {
40
+ if (this.initialized) {
41
+ return;
42
+ }
43
+
44
+ // Auto-detect NeverHub
45
+ if (await NeverHubAdapter.detect()) {
46
+ this.neverhub = new NeverHubAdapter();
47
+ await this.registerWithNeverHub();
48
+ await this.setupEventSubscriptions();
49
+ }
50
+
51
+ // Initialize logger
52
+ this.logger = this.neverhub
53
+ ? await this.neverhub.getService('logging')
54
+ : console;
55
+
56
+ await this.initializeCore();
57
+ this.initialized = true;
58
+ }
59
+
60
+ private async registerWithNeverHub(): Promise<void> {
61
+ if (!this.neverhub) return;
62
+
63
+ await this.neverhub.register({
64
+ type: 'email-tracking',
65
+ name: '@bernierllc/email-tracking',
66
+ version: '1.0.0',
67
+ dependencies: ['email-webhook-events', 'logging'],
68
+ capabilities: [
69
+ {
70
+ type: 'email',
71
+ name: 'delivery-tracking',
72
+ version: '1.0.0',
73
+ metadata: {
74
+ features: ['send-tracking', 'event-linking', 'timeline', 'recipient-history']
75
+ }
76
+ }
77
+ ],
78
+ discoveryEnabled: true,
79
+ discoverySubscriptions: [
80
+ {
81
+ pattern: 'capability.available',
82
+ handler: 'onCapabilityAvailable',
83
+ metadata: {
84
+ capabilityTypes: ['email', 'logging']
85
+ }
86
+ }
87
+ ]
88
+ });
89
+ }
90
+
91
+ private async setupEventSubscriptions(): Promise<void> {
92
+ if (!this.neverhub) return;
93
+
94
+ // Subscribe to email sent events
95
+ await this.neverhub.subscribe('email.sent', async (event: { data: RecordEmailSentData }) => {
96
+ await this.recordEmailSent(event.data);
97
+ });
98
+
99
+ // Subscribe to normalized webhook events
100
+ await this.neverhub.subscribe('email.webhook.*', async (event: { data: WebhookEventData }) => {
101
+ await this.processWebhookEvent(event.data);
102
+ });
103
+ }
104
+
105
+ async recordEmailSent(data: RecordEmailSentData): Promise<EmailTrackingResult<EmailSendRecord>> {
106
+ try {
107
+ const sendRecord: EmailSendRecord = {
108
+ id: this.generateTrackingId(),
109
+ messageId: data.messageId,
110
+ provider: data.provider as EmailProvider,
111
+ from: data.from,
112
+ to: data.to,
113
+ cc: data.cc,
114
+ bcc: data.bcc,
115
+ subject: data.subject,
116
+ campaignId: data.campaignId,
117
+ metadata: data.metadata,
118
+ sentAt: new Date(),
119
+ currentStatus: EmailDeliveryStatus.SENT,
120
+ updatedAt: new Date()
121
+ };
122
+
123
+ // Store send record
124
+ await this.database.storeSendRecord(sendRecord);
125
+
126
+ // Create initial "sent" event
127
+ const event: EmailEvent = {
128
+ id: this.generateEventId(),
129
+ sendRecordId: sendRecord.id,
130
+ eventType: EmailDeliveryStatus.SENT,
131
+ timestamp: sendRecord.sentAt,
132
+ metadata: { provider: data.provider }
133
+ };
134
+ await this.database.createEvent(event);
135
+
136
+ // Update recipient history
137
+ if (this.config.aggregateStats) {
138
+ await this.database.updateRecipientHistory(sendRecord.to, EmailDeliveryStatus.SENT);
139
+ }
140
+
141
+ this.logger?.log('Email send recorded', {
142
+ trackingId: sendRecord.id,
143
+ messageId: data.messageId,
144
+ recipients: data.to.length
145
+ });
146
+
147
+ return { success: true, data: sendRecord };
148
+ } catch (error) {
149
+ this.logger?.error('Failed to record email send', { error });
150
+ return {
151
+ success: false,
152
+ error: error instanceof Error ? error.message : 'Unknown error'
153
+ };
154
+ }
155
+ }
156
+
157
+ async processWebhookEvent(webhookEvent: WebhookEventData): Promise<EmailTrackingResult> {
158
+ try {
159
+ // Find matching send record by messageId
160
+ const sendRecord = await this.database.findSendRecordByMessageId(webhookEvent.messageId);
161
+
162
+ if (!sendRecord) {
163
+ this.logger?.warn('No send record found for webhook event', {
164
+ messageId: webhookEvent.messageId,
165
+ eventType: webhookEvent.eventType
166
+ });
167
+ return { success: false, error: 'Send record not found' };
168
+ }
169
+
170
+ // Map webhook event type to our enum
171
+ const eventType = mapWebhookEventType(webhookEvent.eventType);
172
+
173
+ // Create event
174
+ const event: EmailEvent = {
175
+ id: this.generateEventId(),
176
+ sendRecordId: sendRecord.id,
177
+ eventType,
178
+ timestamp: new Date(webhookEvent.timestamp),
179
+ metadata: webhookEvent.metadata,
180
+ sourceEvent: webhookEvent
181
+ };
182
+
183
+ await this.database.createEvent(event);
184
+
185
+ // Update send record status
186
+ const newStatus = calculateNewStatus(sendRecord.currentStatus, eventType);
187
+
188
+ if (newStatus !== sendRecord.currentStatus) {
189
+ await this.database.updateSendRecordStatus(sendRecord.id, newStatus);
190
+
191
+ // Publish status change event
192
+ if (this.neverhub) {
193
+ await this.neverhub.publishEvent({
194
+ type: 'email.status_changed',
195
+ data: {
196
+ trackingId: sendRecord.id,
197
+ messageId: sendRecord.messageId,
198
+ oldStatus: sendRecord.currentStatus,
199
+ newStatus: newStatus,
200
+ eventType: eventType,
201
+ recipient: sendRecord.to[0],
202
+ timestamp: event.timestamp
203
+ }
204
+ });
205
+ }
206
+ }
207
+
208
+ // Update recipient history
209
+ if (this.config.aggregateStats) {
210
+ await this.database.updateRecipientHistory(sendRecord.to, eventType);
211
+ }
212
+
213
+ this.logger?.log('Webhook event processed', {
214
+ trackingId: sendRecord.id,
215
+ eventType: eventType,
216
+ newStatus: newStatus
217
+ });
218
+
219
+ return { success: true };
220
+ } catch (error) {
221
+ this.logger?.error('Failed to process webhook event', { error });
222
+ return {
223
+ success: false,
224
+ error: error instanceof Error ? error.message : 'Unknown error'
225
+ };
226
+ }
227
+ }
228
+
229
+ async getTimeline(trackingId: string): Promise<EmailTrackingResult<EmailTimeline>> {
230
+ try {
231
+ const sendRecord = await this.database.getSendRecord(trackingId);
232
+ if (!sendRecord) {
233
+ return { success: false, error: 'Send record not found' };
234
+ }
235
+
236
+ const events = await this.database.getEventsBySendRecord(trackingId);
237
+ const lastEvent = events[events.length - 1];
238
+
239
+ const timeline: EmailTimeline = {
240
+ sendRecord,
241
+ events,
242
+ currentStatus: sendRecord.currentStatus,
243
+ lastEventAt: lastEvent ? lastEvent.timestamp : sendRecord.sentAt
244
+ };
245
+
246
+ return { success: true, data: timeline };
247
+ } catch (error) {
248
+ return {
249
+ success: false,
250
+ error: error instanceof Error ? error.message : 'Unknown error'
251
+ };
252
+ }
253
+ }
254
+
255
+ async getRecipientHistory(emailAddress: string): Promise<EmailTrackingResult<RecipientHistory>> {
256
+ try {
257
+ const history = await this.database.fetchRecipientHistory(emailAddress);
258
+ return { success: true, data: history };
259
+ } catch (error) {
260
+ return {
261
+ success: false,
262
+ error: error instanceof Error ? error.message : 'Unknown error'
263
+ };
264
+ }
265
+ }
266
+
267
+ async getCampaignStats(campaignId: string): Promise<EmailTrackingResult<CampaignStatistics>> {
268
+ try {
269
+ const stats = await this.database.aggregateCampaignStats(campaignId);
270
+ return { success: true, data: stats };
271
+ } catch (error) {
272
+ return {
273
+ success: false,
274
+ error: error instanceof Error ? error.message : 'Unknown error'
275
+ };
276
+ }
277
+ }
278
+
279
+ private generateTrackingId(): string {
280
+ return `track_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
281
+ }
282
+
283
+ private generateEventId(): string {
284
+ return `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
285
+ }
286
+
287
+ private async initializeCore(): Promise<void> {
288
+ // Core initialization (database connections, etc.)
289
+ // In production, this would set up real database connections
290
+ }
291
+
292
+ // Test helper to clear database
293
+ async clearDatabase(): Promise<void> {
294
+ await this.database.clear();
295
+ }
296
+ }
package/src/config.ts ADDED
@@ -0,0 +1,26 @@
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 { TrackingConfig } from './types.js';
10
+
11
+ export function loadConfig(userConfig: TrackingConfig = {}): Required<TrackingConfig> {
12
+ return {
13
+ enableOpenTracking: userConfig.enableOpenTracking !== undefined
14
+ ? userConfig.enableOpenTracking
15
+ : (process.env.EMAIL_TRACKING_ENABLE_OPEN_TRACKING === 'true' || process.env.EMAIL_TRACKING_ENABLE_OPEN_TRACKING === undefined),
16
+ enableClickTracking: userConfig.enableClickTracking !== undefined
17
+ ? userConfig.enableClickTracking
18
+ : (process.env.EMAIL_TRACKING_ENABLE_CLICK_TRACKING === 'true' || process.env.EMAIL_TRACKING_ENABLE_CLICK_TRACKING === undefined),
19
+ eventRetentionDays: userConfig.eventRetentionDays !== undefined
20
+ ? userConfig.eventRetentionDays
21
+ : parseInt(process.env.EMAIL_TRACKING_EVENT_RETENTION_DAYS || '90', 10),
22
+ aggregateStats: userConfig.aggregateStats !== undefined
23
+ ? userConfig.aggregateStats
24
+ : (process.env.EMAIL_TRACKING_AGGREGATE_STATS === 'true' || process.env.EMAIL_TRACKING_AGGREGATE_STATS === undefined)
25
+ };
26
+ }
@@ -0,0 +1,179 @@
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 {
10
+ EmailSendRecord,
11
+ EmailEvent,
12
+ RecipientHistory,
13
+ EmailDeliveryStatus,
14
+ CampaignStatistics
15
+ } from './types.js';
16
+
17
+ /**
18
+ * In-memory database implementation for email tracking.
19
+ * In production, this would be replaced with a real database (PostgreSQL, etc.)
20
+ */
21
+ export class Database {
22
+ private sendRecords: Map<string, EmailSendRecord> = new Map();
23
+ private messageIdIndex: Map<string, string> = new Map(); // messageId -> trackingId
24
+ private events: Map<string, EmailEvent[]> = new Map(); // sendRecordId -> events[]
25
+ private recipientHistory: Map<string, RecipientHistory> = new Map();
26
+
27
+ async storeSendRecord(record: EmailSendRecord): Promise<void> {
28
+ this.sendRecords.set(record.id, record);
29
+ this.messageIdIndex.set(record.messageId, record.id);
30
+ }
31
+
32
+ async getSendRecord(trackingId: string): Promise<EmailSendRecord | null> {
33
+ return this.sendRecords.get(trackingId) || null;
34
+ }
35
+
36
+ async findSendRecordByMessageId(messageId: string): Promise<EmailSendRecord | null> {
37
+ const trackingId = this.messageIdIndex.get(messageId);
38
+ if (!trackingId) return null;
39
+ return this.getSendRecord(trackingId);
40
+ }
41
+
42
+ async updateSendRecordStatus(trackingId: string, status: EmailDeliveryStatus): Promise<void> {
43
+ const record = this.sendRecords.get(trackingId);
44
+ if (record) {
45
+ record.currentStatus = status;
46
+ record.updatedAt = new Date();
47
+ this.sendRecords.set(trackingId, record);
48
+ }
49
+ }
50
+
51
+ async createEvent(event: EmailEvent): Promise<void> {
52
+ const existingEvents = this.events.get(event.sendRecordId) || [];
53
+ existingEvents.push(event);
54
+ this.events.set(event.sendRecordId, existingEvents);
55
+ }
56
+
57
+ async getEventsBySendRecord(sendRecordId: string): Promise<EmailEvent[]> {
58
+ return this.events.get(sendRecordId) || [];
59
+ }
60
+
61
+ async updateRecipientHistory(recipients: string[], eventType: EmailDeliveryStatus): Promise<void> {
62
+ for (const recipient of recipients) {
63
+ let history = this.recipientHistory.get(recipient);
64
+
65
+ if (!history) {
66
+ history = {
67
+ emailAddress: recipient,
68
+ totalSent: 0,
69
+ totalDelivered: 0,
70
+ totalBounced: 0,
71
+ totalOpened: 0,
72
+ totalClicked: 0,
73
+ totalComplained: 0,
74
+ totalUnsubscribed: 0,
75
+ reputationScore: 100
76
+ };
77
+ }
78
+
79
+ const now = new Date();
80
+
81
+ switch (eventType) {
82
+ case EmailDeliveryStatus.SENT:
83
+ history.totalSent++;
84
+ history.lastSentAt = now;
85
+ break;
86
+ case EmailDeliveryStatus.DELIVERED:
87
+ history.totalDelivered++;
88
+ history.lastDeliveredAt = now;
89
+ break;
90
+ case EmailDeliveryStatus.BOUNCED:
91
+ history.totalBounced++;
92
+ history.lastBouncedAt = now;
93
+ history.reputationScore = Math.max(0, history.reputationScore - 10);
94
+ break;
95
+ case EmailDeliveryStatus.OPENED:
96
+ history.totalOpened++;
97
+ history.lastOpenedAt = now;
98
+ break;
99
+ case EmailDeliveryStatus.CLICKED:
100
+ history.totalClicked++;
101
+ history.lastClickedAt = now;
102
+ break;
103
+ case EmailDeliveryStatus.COMPLAINED:
104
+ history.totalComplained++;
105
+ history.reputationScore = Math.max(0, history.reputationScore - 25);
106
+ break;
107
+ case EmailDeliveryStatus.UNSUBSCRIBED:
108
+ history.totalUnsubscribed++;
109
+ history.reputationScore = Math.max(0, history.reputationScore - 5);
110
+ break;
111
+ }
112
+
113
+ this.recipientHistory.set(recipient, history);
114
+ }
115
+ }
116
+
117
+ async fetchRecipientHistory(emailAddress: string): Promise<RecipientHistory> {
118
+ const history = this.recipientHistory.get(emailAddress);
119
+
120
+ if (!history) {
121
+ return {
122
+ emailAddress,
123
+ totalSent: 0,
124
+ totalDelivered: 0,
125
+ totalBounced: 0,
126
+ totalOpened: 0,
127
+ totalClicked: 0,
128
+ totalComplained: 0,
129
+ totalUnsubscribed: 0,
130
+ reputationScore: 100
131
+ };
132
+ }
133
+
134
+ return history;
135
+ }
136
+
137
+ async aggregateCampaignStats(campaignId: string): Promise<CampaignStatistics> {
138
+ const campaignRecords = Array.from(this.sendRecords.values())
139
+ .filter(record => record.campaignId === campaignId);
140
+
141
+ const totalSent = campaignRecords.length;
142
+ const totalDelivered = campaignRecords.filter(r =>
143
+ [EmailDeliveryStatus.DELIVERED, EmailDeliveryStatus.OPENED, EmailDeliveryStatus.CLICKED]
144
+ .includes(r.currentStatus)
145
+ ).length;
146
+ const totalBounced = campaignRecords.filter(r => r.currentStatus === EmailDeliveryStatus.BOUNCED).length;
147
+ const totalOpened = campaignRecords.filter(r =>
148
+ [EmailDeliveryStatus.OPENED, EmailDeliveryStatus.CLICKED].includes(r.currentStatus)
149
+ ).length;
150
+ const totalClicked = campaignRecords.filter(r => r.currentStatus === EmailDeliveryStatus.CLICKED).length;
151
+ const totalComplained = campaignRecords.filter(r => r.currentStatus === EmailDeliveryStatus.COMPLAINED).length;
152
+ const totalUnsubscribed = campaignRecords.filter(r => r.currentStatus === EmailDeliveryStatus.UNSUBSCRIBED).length;
153
+
154
+ const deliveryRate = totalSent > 0 ? (totalDelivered / totalSent) * 100 : 0;
155
+ const openRate = totalDelivered > 0 ? (totalOpened / totalDelivered) * 100 : 0;
156
+ const clickRate = totalOpened > 0 ? (totalClicked / totalOpened) * 100 : 0;
157
+
158
+ return {
159
+ campaignId,
160
+ totalSent,
161
+ totalDelivered,
162
+ totalBounced,
163
+ totalOpened,
164
+ totalClicked,
165
+ totalComplained,
166
+ totalUnsubscribed,
167
+ deliveryRate: Math.round(deliveryRate * 100) / 100,
168
+ openRate: Math.round(openRate * 100) / 100,
169
+ clickRate: Math.round(clickRate * 100) / 100
170
+ };
171
+ }
172
+
173
+ async clear(): Promise<void> {
174
+ this.sendRecords.clear();
175
+ this.messageIdIndex.clear();
176
+ this.events.clear();
177
+ this.recipientHistory.clear();
178
+ }
179
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
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
+ export { EmailTracking } from './EmailTracking.js';
10
+ export * from './types.js';
11
+ export { calculateNewStatus, mapWebhookEventType } from './status-calculator.js';
12
+ export { loadConfig } from './config.js';
@@ -0,0 +1,43 @@
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 { EmailDeliveryStatus } from './types.js';
10
+
11
+ const STATUS_PRIORITY: Record<EmailDeliveryStatus, number> = {
12
+ [EmailDeliveryStatus.COMPLAINED]: 7,
13
+ [EmailDeliveryStatus.UNSUBSCRIBED]: 6,
14
+ [EmailDeliveryStatus.BOUNCED]: 5,
15
+ [EmailDeliveryStatus.FAILED]: 4,
16
+ [EmailDeliveryStatus.CLICKED]: 3,
17
+ [EmailDeliveryStatus.OPENED]: 2,
18
+ [EmailDeliveryStatus.DELIVERED]: 1,
19
+ [EmailDeliveryStatus.SENT]: 0
20
+ };
21
+
22
+ export function calculateNewStatus(
23
+ currentStatus: EmailDeliveryStatus,
24
+ eventType: EmailDeliveryStatus
25
+ ): EmailDeliveryStatus {
26
+ return STATUS_PRIORITY[eventType] > STATUS_PRIORITY[currentStatus]
27
+ ? eventType
28
+ : currentStatus;
29
+ }
30
+
31
+ export function mapWebhookEventType(webhookEventType: string): EmailDeliveryStatus {
32
+ const mapping: Record<string, EmailDeliveryStatus> = {
33
+ delivered: EmailDeliveryStatus.DELIVERED,
34
+ bounce: EmailDeliveryStatus.BOUNCED,
35
+ open: EmailDeliveryStatus.OPENED,
36
+ click: EmailDeliveryStatus.CLICKED,
37
+ spamreport: EmailDeliveryStatus.COMPLAINED,
38
+ unsubscribe: EmailDeliveryStatus.UNSUBSCRIBED,
39
+ failed: EmailDeliveryStatus.FAILED
40
+ };
41
+
42
+ return mapping[webhookEventType.toLowerCase()] || EmailDeliveryStatus.SENT;
43
+ }
package/src/types.ts ADDED
@@ -0,0 +1,115 @@
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
+ export enum EmailDeliveryStatus {
10
+ SENT = 'sent',
11
+ DELIVERED = 'delivered',
12
+ BOUNCED = 'bounced',
13
+ OPENED = 'opened',
14
+ CLICKED = 'clicked',
15
+ COMPLAINED = 'complained',
16
+ UNSUBSCRIBED = 'unsubscribed',
17
+ FAILED = 'failed'
18
+ }
19
+
20
+ export type EmailProvider = 'sendgrid' | 'mailgun' | 'ses' | 'postmark';
21
+
22
+ export interface EmailSendRecord {
23
+ id: string;
24
+ messageId: string;
25
+ provider: EmailProvider;
26
+ from: string;
27
+ to: string[];
28
+ cc?: string[];
29
+ bcc?: string[];
30
+ subject: string;
31
+ campaignId?: string;
32
+ metadata?: Record<string, unknown>;
33
+ sentAt: Date;
34
+ currentStatus: EmailDeliveryStatus;
35
+ updatedAt: Date;
36
+ }
37
+
38
+ export interface EmailEvent {
39
+ id: string;
40
+ sendRecordId: string;
41
+ eventType: EmailDeliveryStatus;
42
+ timestamp: Date;
43
+ metadata?: Record<string, unknown>;
44
+ sourceEvent?: unknown;
45
+ }
46
+
47
+ export interface EmailTimeline {
48
+ sendRecord: EmailSendRecord;
49
+ events: EmailEvent[];
50
+ currentStatus: EmailDeliveryStatus;
51
+ lastEventAt: Date;
52
+ }
53
+
54
+ export interface RecipientHistory {
55
+ emailAddress: string;
56
+ totalSent: number;
57
+ totalDelivered: number;
58
+ totalBounced: number;
59
+ totalOpened: number;
60
+ totalClicked: number;
61
+ totalComplained: number;
62
+ totalUnsubscribed: number;
63
+ lastSentAt?: Date;
64
+ lastDeliveredAt?: Date;
65
+ lastBouncedAt?: Date;
66
+ lastOpenedAt?: Date;
67
+ lastClickedAt?: Date;
68
+ reputationScore: number;
69
+ }
70
+
71
+ export interface TrackingConfig {
72
+ enableOpenTracking?: boolean;
73
+ enableClickTracking?: boolean;
74
+ eventRetentionDays?: number;
75
+ aggregateStats?: boolean;
76
+ }
77
+
78
+ export interface EmailTrackingResult<T = unknown> {
79
+ success: boolean;
80
+ data?: T;
81
+ error?: string;
82
+ }
83
+
84
+ export interface RecordEmailSentData {
85
+ messageId: string;
86
+ provider: string;
87
+ from: string;
88
+ to: string[];
89
+ cc?: string[];
90
+ bcc?: string[];
91
+ subject: string;
92
+ campaignId?: string;
93
+ metadata?: Record<string, unknown>;
94
+ }
95
+
96
+ export interface WebhookEventData {
97
+ messageId: string;
98
+ eventType: string;
99
+ timestamp: string | Date;
100
+ metadata?: Record<string, unknown>;
101
+ }
102
+
103
+ export interface CampaignStatistics {
104
+ campaignId: string;
105
+ totalSent: number;
106
+ totalDelivered: number;
107
+ totalBounced: number;
108
+ totalOpened: number;
109
+ totalClicked: number;
110
+ totalComplained: number;
111
+ totalUnsubscribed: number;
112
+ deliveryRate: number;
113
+ openRate: number;
114
+ clickRate: number;
115
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "commonjs",
5
+ "lib": ["ES2020"],
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "declaration": true,
9
+ "declarationMap": true,
10
+ "sourceMap": true,
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "resolveJsonModule": true,
16
+ "moduleResolution": "node",
17
+ "noImplicitAny": true,
18
+ "strictNullChecks": true,
19
+ "strictFunctionTypes": true,
20
+ "strictBindCallApply": true,
21
+ "strictPropertyInitialization": true,
22
+ "noImplicitThis": true,
23
+ "alwaysStrict": true,
24
+ "noUnusedLocals": true,
25
+ "noUnusedParameters": true,
26
+ "noImplicitReturns": true,
27
+ "noFallthroughCasesInSwitch": true
28
+ },
29
+ "include": ["src/**/*"],
30
+ "exclude": ["node_modules", "dist", "__tests__"]
31
+ }