@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,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
|
+
}
|
package/src/database.ts
ADDED
|
@@ -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
|
+
}
|