@bernierllc/email-webhook-events 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 +29 -0
- package/README.md +349 -0
- package/__tests__/EmailWebhookEventsService.test.ts +247 -0
- package/__tests__/analytics/AnalyticsEngine.test.ts +244 -0
- package/__tests__/normalizers/MailgunNormalizer.test.ts +149 -0
- package/__tests__/normalizers/PostmarkNormalizer.test.ts +112 -0
- package/__tests__/normalizers/SESNormalizer.test.ts +168 -0
- package/__tests__/normalizers/SMTP2GONormalizer.test.ts +83 -0
- package/__tests__/normalizers/SendGridNormalizer.test.ts +181 -0
- package/__tests__/persistence/PersistenceAdapter.test.ts +103 -0
- package/coverage/clover.xml +328 -0
- package/coverage/coverage-final.json +10 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +161 -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/src/EmailWebhookEventsService.ts.html +826 -0
- package/coverage/lcov-report/src/analytics/AnalyticsEngine.ts.html +775 -0
- package/coverage/lcov-report/src/analytics/index.html +116 -0
- package/coverage/lcov-report/src/index.html +131 -0
- package/coverage/lcov-report/src/normalizers/MailgunNormalizer.ts.html +301 -0
- package/coverage/lcov-report/src/normalizers/PostmarkNormalizer.ts.html +301 -0
- package/coverage/lcov-report/src/normalizers/SESNormalizer.ts.html +436 -0
- package/coverage/lcov-report/src/normalizers/SMTP2GONormalizer.ts.html +247 -0
- package/coverage/lcov-report/src/normalizers/SendGridNormalizer.ts.html +274 -0
- package/coverage/lcov-report/src/normalizers/index.html +176 -0
- package/coverage/lcov-report/src/persistence/InMemoryPersistenceAdapter.ts.html +289 -0
- package/coverage/lcov-report/src/persistence/index.html +116 -0
- package/coverage/lcov-report/src/types.ts.html +823 -0
- package/coverage/lcov.info +710 -0
- package/dist/EmailWebhookEventsService.d.ts +53 -0
- package/dist/EmailWebhookEventsService.d.ts.map +1 -0
- package/dist/EmailWebhookEventsService.js +198 -0
- package/dist/EmailWebhookEventsService.js.map +1 -0
- package/dist/analytics/AnalyticsEngine.d.ts +20 -0
- package/dist/analytics/AnalyticsEngine.d.ts.map +1 -0
- package/dist/analytics/AnalyticsEngine.js +160 -0
- package/dist/analytics/AnalyticsEngine.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/normalizers/EventNormalizer.d.ts +5 -0
- package/dist/normalizers/EventNormalizer.d.ts.map +1 -0
- package/dist/normalizers/EventNormalizer.js +10 -0
- package/dist/normalizers/EventNormalizer.js.map +1 -0
- package/dist/normalizers/MailgunNormalizer.d.ts +9 -0
- package/dist/normalizers/MailgunNormalizer.d.ts.map +1 -0
- package/dist/normalizers/MailgunNormalizer.js +73 -0
- package/dist/normalizers/MailgunNormalizer.js.map +1 -0
- package/dist/normalizers/PostmarkNormalizer.d.ts +10 -0
- package/dist/normalizers/PostmarkNormalizer.d.ts.map +1 -0
- package/dist/normalizers/PostmarkNormalizer.js +75 -0
- package/dist/normalizers/PostmarkNormalizer.js.map +1 -0
- package/dist/normalizers/SESNormalizer.d.ts +16 -0
- package/dist/normalizers/SESNormalizer.d.ts.map +1 -0
- package/dist/normalizers/SESNormalizer.js +107 -0
- package/dist/normalizers/SESNormalizer.js.map +1 -0
- package/dist/normalizers/SMTP2GONormalizer.d.ts +9 -0
- package/dist/normalizers/SMTP2GONormalizer.d.ts.map +1 -0
- package/dist/normalizers/SMTP2GONormalizer.js +55 -0
- package/dist/normalizers/SMTP2GONormalizer.js.map +1 -0
- package/dist/normalizers/SendGridNormalizer.d.ts +9 -0
- package/dist/normalizers/SendGridNormalizer.d.ts.map +1 -0
- package/dist/normalizers/SendGridNormalizer.js +64 -0
- package/dist/normalizers/SendGridNormalizer.js.map +1 -0
- package/dist/persistence/InMemoryPersistenceAdapter.d.ts +12 -0
- package/dist/persistence/InMemoryPersistenceAdapter.d.ts.map +1 -0
- package/dist/persistence/InMemoryPersistenceAdapter.js +58 -0
- package/dist/persistence/InMemoryPersistenceAdapter.js.map +1 -0
- package/dist/persistence/PersistenceAdapter.d.ts +7 -0
- package/dist/persistence/PersistenceAdapter.d.ts.map +1 -0
- package/dist/persistence/PersistenceAdapter.js +10 -0
- package/dist/persistence/PersistenceAdapter.js.map +1 -0
- package/dist/types.d.ts +178 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +41 -0
- package/dist/types.js.map +1 -0
- package/jest.config.cjs +29 -0
- package/package.json +51 -0
- package/src/EmailWebhookEventsService.ts +247 -0
- package/src/analytics/AnalyticsEngine.ts +230 -0
- package/src/index.ts +39 -0
- package/src/normalizers/EventNormalizer.ts +13 -0
- package/src/normalizers/MailgunNormalizer.ts +72 -0
- package/src/normalizers/PostmarkNormalizer.ts +72 -0
- package/src/normalizers/SESNormalizer.ts +117 -0
- package/src/normalizers/SMTP2GONormalizer.ts +54 -0
- package/src/normalizers/SendGridNormalizer.ts +63 -0
- package/src/persistence/InMemoryPersistenceAdapter.ts +68 -0
- package/src/persistence/PersistenceAdapter.ts +15 -0
- package/src/types.ts +246 -0
- package/tsconfig.json +31 -0
package/.eslintrc.cjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
module.exports = {
|
|
10
|
+
parser: '@typescript-eslint/parser',
|
|
11
|
+
parserOptions: {
|
|
12
|
+
ecmaVersion: 2020,
|
|
13
|
+
sourceType: 'module',
|
|
14
|
+
project: './tsconfig.json',
|
|
15
|
+
},
|
|
16
|
+
plugins: ['@typescript-eslint'],
|
|
17
|
+
extends: [
|
|
18
|
+
'eslint:recommended',
|
|
19
|
+
'plugin:@typescript-eslint/recommended',
|
|
20
|
+
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
|
21
|
+
],
|
|
22
|
+
rules: {
|
|
23
|
+
'@typescript-eslint/explicit-function-return-type': 'warn',
|
|
24
|
+
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
|
25
|
+
'@typescript-eslint/no-explicit-any': 'error',
|
|
26
|
+
'@typescript-eslint/strict-boolean-expressions': 'off',
|
|
27
|
+
},
|
|
28
|
+
ignorePatterns: ['dist/', 'node_modules/', '*.cjs'],
|
|
29
|
+
};
|
package/README.md
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# @bernierllc/email-webhook-events
|
|
2
|
+
|
|
3
|
+
Email event tracking, normalization, and analytics service for processing webhooks from all email providers (SendGrid, Mailgun, AWS SES, Postmark, SMTP2GO). Provides unified event format, real-time notifications, and comprehensive analytics aggregation.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @bernierllc/email-webhook-events
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Basic Setup
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { EmailWebhookEventsService, EmailProvider } from '@bernierllc/email-webhook-events';
|
|
17
|
+
|
|
18
|
+
const service = new EmailWebhookEventsService({
|
|
19
|
+
providers: [
|
|
20
|
+
{
|
|
21
|
+
provider: EmailProvider.SENDGRID,
|
|
22
|
+
enabled: true,
|
|
23
|
+
webhookUrl: '/webhooks/sendgrid',
|
|
24
|
+
secretKey: process.env.SENDGRID_WEBHOOK_SECRET!,
|
|
25
|
+
signatureHeader: 'X-Twilio-Email-Event-Webhook-Signature',
|
|
26
|
+
signatureAlgorithm: 'sha256'
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
provider: EmailProvider.MAILGUN,
|
|
30
|
+
enabled: true,
|
|
31
|
+
webhookUrl: '/webhooks/mailgun',
|
|
32
|
+
secretKey: process.env.MAILGUN_WEBHOOK_SECRET!,
|
|
33
|
+
signatureHeader: 'X-Mailgun-Signature',
|
|
34
|
+
signatureAlgorithm: 'sha256'
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
persistence: {
|
|
38
|
+
enabled: true,
|
|
39
|
+
adapter: 'memory' // or 'supabase', 'postgresql', 'mongodb'
|
|
40
|
+
},
|
|
41
|
+
analytics: {
|
|
42
|
+
realTimeEnabled: true,
|
|
43
|
+
aggregationInterval: 5000,
|
|
44
|
+
retentionDays: 90
|
|
45
|
+
},
|
|
46
|
+
notifications: {
|
|
47
|
+
enabled: true,
|
|
48
|
+
criticalEvents: ['bounced', 'complained', 'failed'],
|
|
49
|
+
channels: [
|
|
50
|
+
{ type: 'neverhub', config: {} }
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await service.initialize();
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Processing Webhooks
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
// Express.js example
|
|
62
|
+
app.post('/webhooks/sendgrid', async (req, res) => {
|
|
63
|
+
const webhook = {
|
|
64
|
+
provider: EmailProvider.SENDGRID,
|
|
65
|
+
signature: req.headers['x-twilio-email-event-webhook-signature'],
|
|
66
|
+
payload: req.body,
|
|
67
|
+
headers: req.headers
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const result = await service.processWebhook(webhook);
|
|
71
|
+
|
|
72
|
+
if (result.success) {
|
|
73
|
+
res.status(200).json({ message: 'Event processed' });
|
|
74
|
+
} else {
|
|
75
|
+
res.status(400).json({ error: result.error });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Querying Analytics
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
// Get analytics for the last 30 days
|
|
84
|
+
const startDate = new Date();
|
|
85
|
+
startDate.setDate(startDate.getDate() - 30);
|
|
86
|
+
const endDate = new Date();
|
|
87
|
+
|
|
88
|
+
const analytics = await service.getAnalytics(startDate, endDate);
|
|
89
|
+
|
|
90
|
+
console.log(`Delivery rate: ${(analytics.deliveryRate * 100).toFixed(2)}%`);
|
|
91
|
+
console.log(`Open rate: ${(analytics.openRate * 100).toFixed(2)}%`);
|
|
92
|
+
console.log(`Click rate: ${(analytics.clickRate * 100).toFixed(2)}%`);
|
|
93
|
+
console.log(`Bounce rate: ${(analytics.bounceRate * 100).toFixed(2)}%`);
|
|
94
|
+
|
|
95
|
+
// Filter by provider
|
|
96
|
+
const sendgridAnalytics = await service.getAnalytics(
|
|
97
|
+
startDate,
|
|
98
|
+
endDate,
|
|
99
|
+
{ provider: EmailProvider.SENDGRID }
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Filter by event type
|
|
103
|
+
const bounceAnalytics = await service.getAnalytics(
|
|
104
|
+
startDate,
|
|
105
|
+
endDate,
|
|
106
|
+
{ eventTypes: ['bounced'] }
|
|
107
|
+
);
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Querying Events
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
// Get all events for a specific email
|
|
114
|
+
const emailEvents = await service.getEventsForEmail('email-123');
|
|
115
|
+
|
|
116
|
+
// Query events with filters
|
|
117
|
+
const events = await service.queryEvents({
|
|
118
|
+
recipient: 'user@example.com',
|
|
119
|
+
eventTypes: ['opened', 'clicked'],
|
|
120
|
+
startDate: new Date('2025-01-01'),
|
|
121
|
+
endDate: new Date('2025-12-31'),
|
|
122
|
+
limit: 100
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## API Reference
|
|
127
|
+
|
|
128
|
+
### EmailWebhookEventsService
|
|
129
|
+
|
|
130
|
+
#### `constructor(config: EmailWebhookEventsConfig)`
|
|
131
|
+
|
|
132
|
+
Creates a new EmailWebhookEventsService instance.
|
|
133
|
+
|
|
134
|
+
**Parameters:**
|
|
135
|
+
- `config.providers` - Array of provider configurations
|
|
136
|
+
- `config.persistence` - Persistence settings (optional)
|
|
137
|
+
- `config.analytics` - Analytics settings (optional)
|
|
138
|
+
- `config.notifications` - Notification settings (optional)
|
|
139
|
+
- `config.rateLimiting` - Rate limiting settings (optional)
|
|
140
|
+
|
|
141
|
+
#### `async initialize(): Promise<void>`
|
|
142
|
+
|
|
143
|
+
Initializes the service. Must be called before processing webhooks.
|
|
144
|
+
|
|
145
|
+
#### `async processWebhook(webhook: WebhookPayload): Promise<EmailWebhookResult>`
|
|
146
|
+
|
|
147
|
+
Processes an incoming webhook from an email provider.
|
|
148
|
+
|
|
149
|
+
**Parameters:**
|
|
150
|
+
- `webhook.provider` - Email provider (sendgrid, mailgun, aws_ses, etc.)
|
|
151
|
+
- `webhook.signature` - Webhook signature for validation
|
|
152
|
+
- `webhook.payload` - Raw webhook payload
|
|
153
|
+
- `webhook.headers` - Request headers
|
|
154
|
+
|
|
155
|
+
**Returns:** `EmailWebhookResult` with success status and normalized event
|
|
156
|
+
|
|
157
|
+
#### `async getAnalytics(startDate: Date, endDate: Date, filters?: AnalyticsFilters): Promise<EmailAnalytics>`
|
|
158
|
+
|
|
159
|
+
Retrieves analytics for the specified date range.
|
|
160
|
+
|
|
161
|
+
**Parameters:**
|
|
162
|
+
- `startDate` - Start of date range
|
|
163
|
+
- `endDate` - End of date range
|
|
164
|
+
- `filters` - Optional filters (provider, emailId, recipient, eventTypes)
|
|
165
|
+
|
|
166
|
+
**Returns:** `EmailAnalytics` with counts, rates, and breakdowns
|
|
167
|
+
|
|
168
|
+
#### `async queryEvents(query: EventQuery): Promise<EmailEvent[]>`
|
|
169
|
+
|
|
170
|
+
Queries events with various filters.
|
|
171
|
+
|
|
172
|
+
**Parameters:**
|
|
173
|
+
- `query.emailId` - Filter by email ID
|
|
174
|
+
- `query.recipient` - Filter by recipient
|
|
175
|
+
- `query.eventTypes` - Filter by event types
|
|
176
|
+
- `query.startDate` - Filter by start date
|
|
177
|
+
- `query.endDate` - Filter by end date
|
|
178
|
+
- `query.limit` - Maximum number of results
|
|
179
|
+
- `query.offset` - Offset for pagination
|
|
180
|
+
|
|
181
|
+
**Returns:** Array of `EmailEvent`
|
|
182
|
+
|
|
183
|
+
#### `async getEventsForEmail(emailId: string): Promise<EmailEvent[]>`
|
|
184
|
+
|
|
185
|
+
Gets all events for a specific email.
|
|
186
|
+
|
|
187
|
+
**Parameters:**
|
|
188
|
+
- `emailId` - Email identifier
|
|
189
|
+
|
|
190
|
+
**Returns:** Array of `EmailEvent`
|
|
191
|
+
|
|
192
|
+
## Supported Providers
|
|
193
|
+
|
|
194
|
+
### SendGrid
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
{
|
|
198
|
+
provider: EmailProvider.SENDGRID,
|
|
199
|
+
signatureHeader: 'X-Twilio-Email-Event-Webhook-Signature',
|
|
200
|
+
signatureAlgorithm: 'sha256'
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Supported Events:** delivered, open, click, bounce, spamreport, unsubscribe, deferred, dropped
|
|
205
|
+
|
|
206
|
+
### Mailgun
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
{
|
|
210
|
+
provider: EmailProvider.MAILGUN,
|
|
211
|
+
signatureHeader: 'X-Mailgun-Signature',
|
|
212
|
+
signatureAlgorithm: 'sha256'
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Supported Events:** delivered, opened, clicked, bounced, complained, unsubscribed, failed
|
|
217
|
+
|
|
218
|
+
### AWS SES
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
{
|
|
222
|
+
provider: EmailProvider.AWS_SES,
|
|
223
|
+
signatureHeader: 'X-Amz-Sns-Message-Type',
|
|
224
|
+
signatureAlgorithm: 'sha256'
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**Supported Events:** Delivery, Open, Click, Bounce, Complaint, Reject
|
|
229
|
+
|
|
230
|
+
### Postmark
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
{
|
|
234
|
+
provider: EmailProvider.POSTMARK,
|
|
235
|
+
signatureHeader: 'X-Postmark-Signature',
|
|
236
|
+
signatureAlgorithm: 'sha256'
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**Supported Events:** Delivery, Open, Click, Bounce, SpamComplaint, SubscriptionChange
|
|
241
|
+
|
|
242
|
+
### SMTP2GO
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
{
|
|
246
|
+
provider: EmailProvider.SMTP2GO,
|
|
247
|
+
signatureHeader: 'X-Smtp2go-Signature',
|
|
248
|
+
signatureAlgorithm: 'sha256'
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
**Supported Events:** delivered, open, click, bounce, failed
|
|
253
|
+
|
|
254
|
+
## Event Types
|
|
255
|
+
|
|
256
|
+
All provider events are normalized to these unified types:
|
|
257
|
+
|
|
258
|
+
- `DELIVERED` - Email successfully delivered
|
|
259
|
+
- `OPENED` - Email opened by recipient
|
|
260
|
+
- `CLICKED` - Link clicked in email
|
|
261
|
+
- `BOUNCED` - Email bounced (hard or soft)
|
|
262
|
+
- `COMPLAINED` - Spam complaint received
|
|
263
|
+
- `UNSUBSCRIBED` - Recipient unsubscribed
|
|
264
|
+
- `FAILED` - Email delivery failed
|
|
265
|
+
- `DEFERRED` - Email delivery deferred
|
|
266
|
+
- `DROPPED` - Email dropped by provider
|
|
267
|
+
|
|
268
|
+
## Analytics Metrics
|
|
269
|
+
|
|
270
|
+
### Counts
|
|
271
|
+
- `totalSent` - Total emails sent
|
|
272
|
+
- `totalDelivered` - Total emails delivered
|
|
273
|
+
- `totalOpened` - Total emails opened
|
|
274
|
+
- `totalClicked` - Total links clicked
|
|
275
|
+
- `totalBounced` - Total emails bounced
|
|
276
|
+
- `totalComplained` - Total spam complaints
|
|
277
|
+
- `totalUnsubscribed` - Total unsubscribes
|
|
278
|
+
- `totalFailed` - Total delivery failures
|
|
279
|
+
|
|
280
|
+
### Rates
|
|
281
|
+
- `deliveryRate` - delivered / sent
|
|
282
|
+
- `openRate` - opened / delivered
|
|
283
|
+
- `clickRate` - clicked / delivered
|
|
284
|
+
- `clickToOpenRate` - clicked / opened
|
|
285
|
+
- `bounceRate` - bounced / sent
|
|
286
|
+
- `complaintRate` - complained / sent
|
|
287
|
+
- `unsubscribeRate` - unsubscribed / delivered
|
|
288
|
+
|
|
289
|
+
### Breakdowns
|
|
290
|
+
- `byProvider` - Analytics per provider
|
|
291
|
+
- `byEventType` - Counts per event type
|
|
292
|
+
- `byBounceType` - Hard vs soft bounces
|
|
293
|
+
- `topLinks` - Most clicked links
|
|
294
|
+
- `byDevice` - Desktop, mobile, tablet breakdown
|
|
295
|
+
|
|
296
|
+
## Configuration
|
|
297
|
+
|
|
298
|
+
### Environment Variables
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
# Provider webhook secrets
|
|
302
|
+
EMAIL_WEBHOOK_SENDGRID_SECRET=your-sendgrid-webhook-secret
|
|
303
|
+
EMAIL_WEBHOOK_MAILGUN_SECRET=your-mailgun-webhook-secret
|
|
304
|
+
EMAIL_WEBHOOK_SES_SECRET=your-ses-sns-topic-subscription-secret
|
|
305
|
+
EMAIL_WEBHOOK_POSTMARK_SECRET=your-postmark-webhook-secret
|
|
306
|
+
|
|
307
|
+
# Persistence
|
|
308
|
+
EMAIL_WEBHOOK_PERSISTENCE_ENABLED=true
|
|
309
|
+
EMAIL_WEBHOOK_PERSISTENCE_ADAPTER=memory
|
|
310
|
+
EMAIL_WEBHOOK_PERSISTENCE_RETENTION_DAYS=90
|
|
311
|
+
|
|
312
|
+
# Analytics
|
|
313
|
+
EMAIL_WEBHOOK_ANALYTICS_REALTIME=true
|
|
314
|
+
EMAIL_WEBHOOK_ANALYTICS_INTERVAL=5000
|
|
315
|
+
|
|
316
|
+
# Notifications
|
|
317
|
+
EMAIL_WEBHOOK_NOTIFICATIONS_ENABLED=true
|
|
318
|
+
|
|
319
|
+
# Rate limiting
|
|
320
|
+
EMAIL_WEBHOOK_RATE_LIMIT_ENABLED=false
|
|
321
|
+
EMAIL_WEBHOOK_RATE_LIMIT_MAX_PER_SECOND=100
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## Integration Status
|
|
325
|
+
|
|
326
|
+
- **Logger**: integrated - Uses MockLogger (pending @bernierllc/logger publication)
|
|
327
|
+
- **Docs-Suite**: ready - TypeScript interfaces with JSDoc comments
|
|
328
|
+
- **NeverHub**: required - Event publishing and service discovery (pending implementation)
|
|
329
|
+
|
|
330
|
+
## Examples
|
|
331
|
+
|
|
332
|
+
See the `examples/` directory for complete usage examples:
|
|
333
|
+
|
|
334
|
+
- `setup-sendgrid.ts` - SendGrid webhook setup
|
|
335
|
+
- `setup-mailgun.ts` - Mailgun webhook setup
|
|
336
|
+
- `query-analytics.ts` - Analytics query examples
|
|
337
|
+
- `critical-events.ts` - Critical event handling
|
|
338
|
+
|
|
339
|
+
## License
|
|
340
|
+
|
|
341
|
+
Copyright (c) 2025 Bernier LLC. All rights reserved.
|
|
342
|
+
|
|
343
|
+
This package is licensed to the client under a limited-use license. The client may use and modify this code only within the scope of the project it was delivered for. Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
344
|
+
|
|
345
|
+
## See Also
|
|
346
|
+
|
|
347
|
+
- [@bernierllc/webhook-processor](../webhook-processor) - Generic webhook processing (pending)
|
|
348
|
+
- [@bernierllc/email-sender](../../core/email-sender) - Email sending service
|
|
349
|
+
- [@bernierllc/email-campaign-management](./email-campaign-management) - Campaign management (pending)
|
|
@@ -0,0 +1,247 @@
|
|
|
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 { EmailWebhookEventsService } from '../src/EmailWebhookEventsService';
|
|
10
|
+
import {
|
|
11
|
+
EmailEventType,
|
|
12
|
+
EmailProvider,
|
|
13
|
+
WebhookPayload,
|
|
14
|
+
ProviderWebhookConfig
|
|
15
|
+
} from '../src/types';
|
|
16
|
+
|
|
17
|
+
describe('EmailWebhookEventsService', () => {
|
|
18
|
+
let service: EmailWebhookEventsService;
|
|
19
|
+
|
|
20
|
+
const createProviderConfig = (): ProviderWebhookConfig => ({
|
|
21
|
+
provider: EmailProvider.SENDGRID,
|
|
22
|
+
enabled: true,
|
|
23
|
+
webhookUrl: '/webhooks/sendgrid',
|
|
24
|
+
secretKey: 'test-secret',
|
|
25
|
+
signatureHeader: 'X-Twilio-Email-Event-Webhook-Signature',
|
|
26
|
+
signatureAlgorithm: 'sha256'
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
service = new EmailWebhookEventsService({
|
|
31
|
+
providers: [createProviderConfig()],
|
|
32
|
+
persistence: {
|
|
33
|
+
enabled: true,
|
|
34
|
+
adapter: 'memory'
|
|
35
|
+
},
|
|
36
|
+
analytics: {
|
|
37
|
+
realTimeEnabled: true,
|
|
38
|
+
aggregationInterval: 5000,
|
|
39
|
+
retentionDays: 90
|
|
40
|
+
},
|
|
41
|
+
notifications: {
|
|
42
|
+
enabled: true,
|
|
43
|
+
criticalEvents: [EmailEventType.BOUNCED, EmailEventType.COMPLAINED],
|
|
44
|
+
channels: []
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await service.initialize();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const createWebhook = (eventType: string, overrides: Partial<WebhookPayload> = {}): WebhookPayload => ({
|
|
52
|
+
provider: EmailProvider.SENDGRID,
|
|
53
|
+
signature: 'test-signature',
|
|
54
|
+
payload: {
|
|
55
|
+
event: eventType,
|
|
56
|
+
email: 'user@example.com',
|
|
57
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
58
|
+
sg_message_id: 'msg-123',
|
|
59
|
+
email_id: 'email-456'
|
|
60
|
+
},
|
|
61
|
+
headers: {},
|
|
62
|
+
...overrides
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('processWebhook', () => {
|
|
66
|
+
it('should process delivered event successfully', async () => {
|
|
67
|
+
const webhook = createWebhook('delivered');
|
|
68
|
+
const result = await service.processWebhook(webhook);
|
|
69
|
+
|
|
70
|
+
expect(result.success).toBe(true);
|
|
71
|
+
expect(result.event).toBeDefined();
|
|
72
|
+
expect(result.event!.type).toBe(EmailEventType.DELIVERED);
|
|
73
|
+
expect(result.event!.recipient).toBe('user@example.com');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should process opened event successfully', async () => {
|
|
77
|
+
const webhook = createWebhook('open');
|
|
78
|
+
const result = await service.processWebhook(webhook);
|
|
79
|
+
|
|
80
|
+
expect(result.success).toBe(true);
|
|
81
|
+
expect(result.event!.type).toBe(EmailEventType.OPENED);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should process clicked event successfully', async () => {
|
|
85
|
+
const webhook = createWebhook('click', {
|
|
86
|
+
payload: {
|
|
87
|
+
event: 'click',
|
|
88
|
+
email: 'user@example.com',
|
|
89
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
90
|
+
sg_message_id: 'msg-123',
|
|
91
|
+
url: 'https://example.com/link'
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const result = await service.processWebhook(webhook);
|
|
96
|
+
|
|
97
|
+
expect(result.success).toBe(true);
|
|
98
|
+
expect(result.event!.type).toBe(EmailEventType.CLICKED);
|
|
99
|
+
expect(result.event!.metadata.url).toBe('https://example.com/link');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should process bounce event successfully', async () => {
|
|
103
|
+
const webhook = createWebhook('bounce', {
|
|
104
|
+
payload: {
|
|
105
|
+
event: 'bounce',
|
|
106
|
+
email: 'user@example.com',
|
|
107
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
108
|
+
sg_message_id: 'msg-123',
|
|
109
|
+
status: '5.1.1',
|
|
110
|
+
reason: 'User unknown'
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const result = await service.processWebhook(webhook);
|
|
115
|
+
|
|
116
|
+
expect(result.success).toBe(true);
|
|
117
|
+
expect(result.event!.type).toBe(EmailEventType.BOUNCED);
|
|
118
|
+
expect(result.event!.metadata.bounceType).toBe('hard');
|
|
119
|
+
expect(result.event!.metadata.bounceReason).toBe('User unknown');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should handle unsupported provider gracefully', async () => {
|
|
123
|
+
const webhook = createWebhook('delivered', {
|
|
124
|
+
provider: EmailProvider.GENERIC_SMTP
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const result = await service.processWebhook(webhook);
|
|
128
|
+
|
|
129
|
+
expect(result.success).toBe(false);
|
|
130
|
+
expect(result.error).toBe('Event normalization failed');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('getAnalytics', () => {
|
|
135
|
+
it('should return analytics for processed events', async () => {
|
|
136
|
+
// Process multiple events
|
|
137
|
+
await service.processWebhook(createWebhook('delivered'));
|
|
138
|
+
await service.processWebhook(createWebhook('delivered'));
|
|
139
|
+
await service.processWebhook(createWebhook('open'));
|
|
140
|
+
await service.processWebhook(createWebhook('click', {
|
|
141
|
+
payload: {
|
|
142
|
+
event: 'click',
|
|
143
|
+
email: 'user@example.com',
|
|
144
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
145
|
+
sg_message_id: 'msg-123',
|
|
146
|
+
url: 'https://example.com/link'
|
|
147
|
+
}
|
|
148
|
+
}));
|
|
149
|
+
|
|
150
|
+
const analytics = await service.getAnalytics(
|
|
151
|
+
new Date('2025-01-01'),
|
|
152
|
+
new Date('2025-12-31')
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(analytics.totalDelivered).toBe(2);
|
|
156
|
+
expect(analytics.totalOpened).toBe(1);
|
|
157
|
+
expect(analytics.totalClicked).toBe(1);
|
|
158
|
+
expect(analytics.openRate).toBe(0.5);
|
|
159
|
+
expect(analytics.clickRate).toBe(0.5);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should filter analytics by provider', async () => {
|
|
163
|
+
await service.processWebhook(createWebhook('delivered', {
|
|
164
|
+
provider: EmailProvider.SENDGRID
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
const analytics = await service.getAnalytics(
|
|
168
|
+
new Date('2025-01-01'),
|
|
169
|
+
new Date('2025-12-31'),
|
|
170
|
+
{ provider: EmailProvider.SENDGRID }
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
expect(analytics.totalDelivered).toBe(1);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('queryEvents', () => {
|
|
178
|
+
it('should query events by email ID', async () => {
|
|
179
|
+
await service.processWebhook(createWebhook('delivered', {
|
|
180
|
+
payload: {
|
|
181
|
+
event: 'delivered',
|
|
182
|
+
email: 'user@example.com',
|
|
183
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
184
|
+
sg_message_id: 'msg-123',
|
|
185
|
+
email_id: 'email-123'
|
|
186
|
+
}
|
|
187
|
+
}));
|
|
188
|
+
|
|
189
|
+
await service.processWebhook(createWebhook('delivered', {
|
|
190
|
+
payload: {
|
|
191
|
+
event: 'delivered',
|
|
192
|
+
email: 'user@example.com',
|
|
193
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
194
|
+
sg_message_id: 'msg-456',
|
|
195
|
+
email_id: 'email-456'
|
|
196
|
+
}
|
|
197
|
+
}));
|
|
198
|
+
|
|
199
|
+
const events = await service.queryEvents({ emailId: 'email-123' });
|
|
200
|
+
|
|
201
|
+
expect(events).toHaveLength(1);
|
|
202
|
+
expect(events[0].emailId).toBe('email-123');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should query events by event type', async () => {
|
|
206
|
+
await service.processWebhook(createWebhook('delivered'));
|
|
207
|
+
await service.processWebhook(createWebhook('open'));
|
|
208
|
+
|
|
209
|
+
const events = await service.queryEvents({
|
|
210
|
+
eventTypes: [EmailEventType.DELIVERED]
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(events).toHaveLength(1);
|
|
214
|
+
expect(events[0].type).toBe(EmailEventType.DELIVERED);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('getEventsForEmail', () => {
|
|
219
|
+
it('should return all events for a specific email', async () => {
|
|
220
|
+
await service.processWebhook(createWebhook('delivered', {
|
|
221
|
+
payload: {
|
|
222
|
+
event: 'delivered',
|
|
223
|
+
email: 'user@example.com',
|
|
224
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
225
|
+
sg_message_id: 'msg-123',
|
|
226
|
+
email_id: 'email-123'
|
|
227
|
+
}
|
|
228
|
+
}));
|
|
229
|
+
|
|
230
|
+
await service.processWebhook(createWebhook('open', {
|
|
231
|
+
payload: {
|
|
232
|
+
event: 'open',
|
|
233
|
+
email: 'user@example.com',
|
|
234
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
235
|
+
sg_message_id: 'msg-123',
|
|
236
|
+
email_id: 'email-123'
|
|
237
|
+
}
|
|
238
|
+
}));
|
|
239
|
+
|
|
240
|
+
const events = await service.getEventsForEmail('email-123');
|
|
241
|
+
|
|
242
|
+
expect(events).toHaveLength(2);
|
|
243
|
+
expect(events[0].emailId).toBe('email-123');
|
|
244
|
+
expect(events[1].emailId).toBe('email-123');
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
});
|