@bernierllc/email-webhook-events 1.0.0 → 1.2.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 (40) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +42 -0
  3. package/__tests__/fixtures/sendgrid-webhooks/.gitkeep +0 -0
  4. package/__tests__/fixtures/sendgrid-webhooks/README.md +63 -0
  5. package/__tests__/fixtures/sendgrid-webhooks/deferred.json +31 -0
  6. package/__tests__/fixtures/sendgrid-webhooks/delivered.json +83 -0
  7. package/__tests__/fixtures/sendgrid-webhooks/open.json +83 -0
  8. package/__tests__/integration/ngrok-tunnel-manager.ts +88 -0
  9. package/__tests__/integration/sendgrid-webhook-integration.test.ts +272 -0
  10. package/__tests__/integration/webhook-server.ts +188 -0
  11. package/__tests__/mocks/README.md +172 -0
  12. package/__tests__/mocks/index.ts +94 -0
  13. package/__tests__/mocks/sendgrid-api-responses.ts +391 -0
  14. package/__tests__/mocks/sendgrid-webhook-payloads.ts +463 -0
  15. package/jest.config.cjs +16 -7
  16. package/package.json +23 -15
  17. package/coverage/clover.xml +0 -328
  18. package/coverage/coverage-final.json +0 -10
  19. package/coverage/lcov-report/base.css +0 -224
  20. package/coverage/lcov-report/block-navigation.js +0 -87
  21. package/coverage/lcov-report/favicon.png +0 -0
  22. package/coverage/lcov-report/index.html +0 -161
  23. package/coverage/lcov-report/prettify.css +0 -1
  24. package/coverage/lcov-report/prettify.js +0 -2
  25. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  26. package/coverage/lcov-report/sorter.js +0 -210
  27. package/coverage/lcov-report/src/EmailWebhookEventsService.ts.html +0 -826
  28. package/coverage/lcov-report/src/analytics/AnalyticsEngine.ts.html +0 -775
  29. package/coverage/lcov-report/src/analytics/index.html +0 -116
  30. package/coverage/lcov-report/src/index.html +0 -131
  31. package/coverage/lcov-report/src/normalizers/MailgunNormalizer.ts.html +0 -301
  32. package/coverage/lcov-report/src/normalizers/PostmarkNormalizer.ts.html +0 -301
  33. package/coverage/lcov-report/src/normalizers/SESNormalizer.ts.html +0 -436
  34. package/coverage/lcov-report/src/normalizers/SMTP2GONormalizer.ts.html +0 -247
  35. package/coverage/lcov-report/src/normalizers/SendGridNormalizer.ts.html +0 -274
  36. package/coverage/lcov-report/src/normalizers/index.html +0 -176
  37. package/coverage/lcov-report/src/persistence/InMemoryPersistenceAdapter.ts.html +0 -289
  38. package/coverage/lcov-report/src/persistence/index.html +0 -116
  39. package/coverage/lcov-report/src/types.ts.html +0 -823
  40. package/coverage/lcov.info +0 -710
package/CHANGELOG.md ADDED
@@ -0,0 +1,50 @@
1
+ # Change Log
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
+
6
+ # 1.2.0 (2025-12-25)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **ci:** add lerna version step before publish ([3d20300](https://github.com/bernierllc/tools/commit/3d203002143bf353fffafe4f8a78a99009567347))
12
+ * update neverhub-adapter deps to workspace:* and publish 0.1.2 ([f0e3d04](https://github.com/bernierllc/tools/commit/f0e3d04d8d4f094e3bb899ddf81e93243d16e2c2))
13
+
14
+
15
+ ### Features
16
+
17
+ * **email-webhook-events:** migrate to Vitest and add integration testing ([c45c75c](https://github.com/bernierllc/tools/commit/c45c75ce2db9cc9f45fe1be6c73e8018a427727c))
18
+
19
+
20
+
21
+
22
+
23
+ # 1.1.0 (2025-12-25)
24
+
25
+
26
+ ### Bug Fixes
27
+
28
+ * update neverhub-adapter deps to workspace:* and publish 0.1.2 ([f0e3d04](https://github.com/bernierllc/tools/commit/f0e3d04d8d4f094e3bb899ddf81e93243d16e2c2))
29
+
30
+
31
+ ### Features
32
+
33
+ * **email-webhook-events:** migrate to Vitest and add integration testing ([c45c75c](https://github.com/bernierllc/tools/commit/c45c75ce2db9cc9f45fe1be6c73e8018a427727c))
34
+
35
+
36
+
37
+
38
+
39
+ # @bernierllc/email-webhook-events
40
+
41
+ ## 1.0.1
42
+
43
+ ### Patch Changes
44
+
45
+ - [`c45c75c`](https://github.com/bernierllc/tools/commit/c45c75ce2db9cc9f45fe1be6c73e8018a427727c) - Migrate test infrastructure from Jest to Vitest and add integration testing support
46
+ - Switch from Jest to Vitest for improved ESM compatibility
47
+ - Add integration test infrastructure using ngrok for real SendGrid webhook testing
48
+ - Add @bernierllc/email-manager as dependency for integration tests
49
+ - Capture real webhook payloads for accurate test fixtures
50
+ - Update README with integration testing documentation
package/README.md CHANGED
@@ -321,6 +321,48 @@ EMAIL_WEBHOOK_RATE_LIMIT_ENABLED=false
321
321
  EMAIL_WEBHOOK_RATE_LIMIT_MAX_PER_SECOND=100
322
322
  ```
323
323
 
324
+ ## Integration Testing
325
+
326
+ This package includes integration tests that use real SendGrid webhooks.
327
+
328
+ ### Prerequisites
329
+
330
+ 1. SendGrid account with API key
331
+ 2. Paid ngrok account with auth token
332
+ 3. Test email address
333
+ 4. `.env.test` file configured with:
334
+ - `SENDGRID_API_KEY`
335
+ - `SENDGRID_WEBHOOK_SECRET` (optional)
336
+ - `TEST_EMAIL`
337
+ - `NGROK_AUTH_TOKEN`
338
+ - `NGROK_RESERVED_DOMAIN` (optional, for consistent URLs)
339
+ - `WEBHOOK_SERVER_PORT` (optional, defaults to 3000)
340
+
341
+ ### Running Integration Tests
342
+
343
+ 1. Ensure `.env.test` file is configured with all required variables
344
+ 2. Set `RUN_INTEGRATION_TESTS=true` environment variable
345
+ 3. Configure SendGrid webhook URL (first time only, or use reserved domain):
346
+ - Run test once to get webhook URL from output, or
347
+ - Use reserved domain: `https://your-reserved-domain.ngrok.io/webhooks/sendgrid`
348
+ - Go to SendGrid dashboard → Settings → Mail Settings → Event Webhook
349
+ - Set webhook URL and enable all event types
350
+ 4. Run tests: `npm test -- sendgrid-webhook-integration`
351
+
352
+ Tests automatically:
353
+ - Start webhook server
354
+ - Create ngrok tunnel (programmatically)
355
+ - Send test emails via @bernierllc/email-manager
356
+ - Capture and process webhooks
357
+ - Save payloads to `__tests__/fixtures/sendgrid-webhooks/`
358
+
359
+ ### Captured Payloads
360
+
361
+ Real webhook payloads are captured in `__tests__/fixtures/sendgrid-webhooks/`
362
+ and used to generate accurate mocks for unit tests.
363
+
364
+ See `__tests__/fixtures/sendgrid-webhooks/README.md` for more details.
365
+
324
366
  ## Integration Status
325
367
 
326
368
  - **Logger**: integrated - Uses MockLogger (pending @bernierllc/logger publication)
File without changes
@@ -0,0 +1,63 @@
1
+ # SendGrid Webhook Fixtures
2
+
3
+ This directory contains webhook payloads based on REAL SendGrid webhook structures captured during integration testing.
4
+
5
+ > ⚠️ **SANITIZED DATA**: All email addresses, IPs, message IDs, signatures, and other identifiable data have been replaced with fake test values. The **structure** matches real SendGrid payloads but all values are safe for version control.
6
+
7
+ ## Structure
8
+
9
+ Each event type has its own JSON file containing an array of captured webhook payloads:
10
+
11
+ - `delivered.json` - Email delivered events
12
+ - `open.json` - Email opened events
13
+ - `click.json` - Link clicked events
14
+ - `bounce.json` - Email bounced events
15
+ - `spamreport.json` - Spam complaint events
16
+ - `unsubscribe.json` - Unsubscribe events
17
+ - `deferred.json` - Email delivery deferred events
18
+ - `dropped.json` - Email dropped events
19
+
20
+ ## File Format
21
+
22
+ Each file contains an array of captured webhook objects:
23
+
24
+ ```json
25
+ [
26
+ {
27
+ "headers": {
28
+ "x-twilio-email-event-webhook-signature": "...",
29
+ "content-type": "application/json",
30
+ ...
31
+ },
32
+ "body": {
33
+ "event": "delivered",
34
+ "email": "user@example.com",
35
+ "timestamp": 1234567890,
36
+ "sg_message_id": "...",
37
+ ...
38
+ },
39
+ "timestamp": "2025-01-27T12:00:00.000Z",
40
+ "eventType": "delivered"
41
+ }
42
+ ]
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ These fixtures are automatically generated during integration tests and can be used to:
48
+
49
+ 1. Generate accurate mocks for unit tests
50
+ 2. Validate webhook payload structure
51
+ 3. Test normalizer implementations with real data
52
+ 4. Debug webhook processing issues
53
+
54
+ ## Generation
55
+
56
+ Fixtures are automatically captured when running integration tests:
57
+
58
+ ```bash
59
+ npm test -- sendgrid-webhook-integration
60
+ ```
61
+
62
+ The webhook server automatically saves all received webhooks to the appropriate fixture files.
63
+
@@ -0,0 +1,31 @@
1
+ [
2
+ {
3
+ "headers": {
4
+ "host": "example-webhook.ngrok.app",
5
+ "user-agent": "SendGrid Event API",
6
+ "content-length": "498",
7
+ "accept-encoding": "gzip",
8
+ "content-type": "application/json;charset=utf-8",
9
+ "x-forwarded-for": "192.0.2.30",
10
+ "x-forwarded-host": "example-webhook.ngrok.app",
11
+ "x-forwarded-proto": "https",
12
+ "x-twilio-email-event-webhook-signature": "FAKE_SIGNATURE_MEQCIG5UlgMJ_DO_NOT_USE_IN_PRODUCTION=",
13
+ "x-twilio-email-event-webhook-timestamp": "1700000300"
14
+ },
15
+ "body": {
16
+ "attempt": "2",
17
+ "domain": "example.com",
18
+ "email": "deferred-recipient@example.com",
19
+ "event": "deferred",
20
+ "from": "Test Sender <sender@example.com>",
21
+ "response": "temporarily unable to deliver - server busy, please retry later",
22
+ "sg_event_id": "FAKE_DEFERRED_EVENT_001",
23
+ "sg_message_id": "FAKE_MSG_DEFERRED_001.recvd-1234567890-pqrst-1-1234567B-14.0",
24
+ "smtp-id": "<FAKE_MSG_DEFERRED_001@geopod-ismtpd-test>",
25
+ "timestamp": 1700000300,
26
+ "tls": 0
27
+ },
28
+ "timestamp": "2025-01-01T12:05:00.000Z",
29
+ "eventType": "deferred"
30
+ }
31
+ ]
@@ -0,0 +1,83 @@
1
+ [
2
+ {
3
+ "headers": {
4
+ "host": "example-webhook.ngrok.app",
5
+ "user-agent": "SendGrid Event API",
6
+ "content-length": "405",
7
+ "accept-encoding": "gzip",
8
+ "content-type": "application/json;charset=utf-8",
9
+ "x-forwarded-for": "192.0.2.1",
10
+ "x-forwarded-host": "example-webhook.ngrok.app",
11
+ "x-forwarded-proto": "https",
12
+ "x-twilio-email-event-webhook-signature": "FAKE_SIGNATURE_MEUCIB5bSBgVWU_DO_NOT_USE_IN_PRODUCTION=",
13
+ "x-twilio-email-event-webhook-timestamp": "1700000000"
14
+ },
15
+ "body": {
16
+ "email": "test-recipient@example.com",
17
+ "event": "delivered",
18
+ "ip": "192.0.2.10",
19
+ "response": "250 2.0.0 OK 1700000000 example-mail-server.example.com - gsmtp",
20
+ "sg_event_id": "FAKE_DELIVERED_EVENT_001",
21
+ "sg_message_id": "FAKE_MSG_001.recvd-1234567890-abcde-1-12345678-37.0",
22
+ "smtp-id": "<FAKE_MSG_001@geopod-ismtpd-test>",
23
+ "timestamp": 1700000000,
24
+ "tls": 1
25
+ },
26
+ "timestamp": "2025-01-01T12:00:00.000Z",
27
+ "eventType": "delivered"
28
+ },
29
+ {
30
+ "headers": {
31
+ "host": "example-webhook.ngrok.app",
32
+ "user-agent": "SendGrid Event API",
33
+ "content-length": "405",
34
+ "accept-encoding": "gzip",
35
+ "content-type": "application/json;charset=utf-8",
36
+ "x-forwarded-for": "192.0.2.2",
37
+ "x-forwarded-host": "example-webhook.ngrok.app",
38
+ "x-forwarded-proto": "https",
39
+ "x-twilio-email-event-webhook-signature": "FAKE_SIGNATURE_MEUCICpeBdSY_DO_NOT_USE_IN_PRODUCTION=",
40
+ "x-twilio-email-event-webhook-timestamp": "1700000100"
41
+ },
42
+ "body": {
43
+ "email": "test-recipient@example.com",
44
+ "event": "delivered",
45
+ "ip": "192.0.2.11",
46
+ "response": "250 2.0.0 OK 1700000100 example-mail-server.example.com - gsmtp",
47
+ "sg_event_id": "FAKE_DELIVERED_EVENT_002",
48
+ "sg_message_id": "FAKE_MSG_002.recvd-1234567890-fghij-1-12345679-10.0",
49
+ "smtp-id": "<FAKE_MSG_002@geopod-ismtpd-test>",
50
+ "timestamp": 1700000100,
51
+ "tls": 1
52
+ },
53
+ "timestamp": "2025-01-01T12:01:40.000Z",
54
+ "eventType": "delivered"
55
+ },
56
+ {
57
+ "headers": {
58
+ "host": "example-webhook.ngrok.app",
59
+ "user-agent": "SendGrid Event API",
60
+ "content-length": "405",
61
+ "accept-encoding": "gzip",
62
+ "content-type": "application/json;charset=utf-8",
63
+ "x-forwarded-for": "192.0.2.3",
64
+ "x-forwarded-host": "example-webhook.ngrok.app",
65
+ "x-forwarded-proto": "https",
66
+ "x-twilio-email-event-webhook-signature": "FAKE_SIGNATURE_MEQCIAektsAA_DO_NOT_USE_IN_PRODUCTION=",
67
+ "x-twilio-email-event-webhook-timestamp": "1700000200"
68
+ },
69
+ "body": {
70
+ "email": "another-recipient@example.com",
71
+ "event": "delivered",
72
+ "ip": "192.0.2.12",
73
+ "response": "250 2.0.0 OK 1700000200 example-mail-server.example.com - gsmtp",
74
+ "sg_event_id": "FAKE_DELIVERED_EVENT_003",
75
+ "sg_message_id": "FAKE_MSG_003.recvd-1234567890-klmno-1-1234567A-10.0",
76
+ "smtp-id": "<FAKE_MSG_003@geopod-ismtpd-test>",
77
+ "timestamp": 1700000200,
78
+ "tls": 1
79
+ },
80
+ "timestamp": "2025-01-01T12:03:20.000Z",
81
+ "eventType": "delivered"
82
+ }
83
+ ]
@@ -0,0 +1,83 @@
1
+ [
2
+ {
3
+ "headers": {
4
+ "host": "example-webhook.ngrok.app",
5
+ "user-agent": "SendGrid Event API",
6
+ "content-length": "416",
7
+ "accept-encoding": "gzip",
8
+ "content-type": "application/json;charset=utf-8",
9
+ "x-forwarded-for": "192.0.2.20",
10
+ "x-forwarded-host": "example-webhook.ngrok.app",
11
+ "x-forwarded-proto": "https",
12
+ "x-twilio-email-event-webhook-signature": "FAKE_SIGNATURE_MEQCIHN5S5nZ_DO_NOT_USE_IN_PRODUCTION=",
13
+ "x-twilio-email-event-webhook-timestamp": "1700000050"
14
+ },
15
+ "body": {
16
+ "email": "test-recipient@example.com",
17
+ "event": "open",
18
+ "ip": "192.0.2.100",
19
+ "sg_content_type": "html",
20
+ "sg_event_id": "FAKE_OPEN_EVENT_001",
21
+ "sg_machine_open": false,
22
+ "sg_message_id": "FAKE_MSG_001.recvd-1234567890-abcde-1-12345678-1B.0",
23
+ "timestamp": 1700000050,
24
+ "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
25
+ },
26
+ "timestamp": "2025-01-01T12:00:50.000Z",
27
+ "eventType": "open"
28
+ },
29
+ {
30
+ "headers": {
31
+ "host": "example-webhook.ngrok.app",
32
+ "user-agent": "SendGrid Event API",
33
+ "content-length": "416",
34
+ "accept-encoding": "gzip",
35
+ "content-type": "application/json;charset=utf-8",
36
+ "x-forwarded-for": "192.0.2.21",
37
+ "x-forwarded-host": "example-webhook.ngrok.app",
38
+ "x-forwarded-proto": "https",
39
+ "x-twilio-email-event-webhook-signature": "FAKE_SIGNATURE_MEQCIFFHHjr5_DO_NOT_USE_IN_PRODUCTION=",
40
+ "x-twilio-email-event-webhook-timestamp": "1700000150"
41
+ },
42
+ "body": {
43
+ "email": "test-recipient@example.com",
44
+ "event": "open",
45
+ "ip": "192.0.2.101",
46
+ "sg_content_type": "html",
47
+ "sg_event_id": "FAKE_OPEN_EVENT_002",
48
+ "sg_machine_open": false,
49
+ "sg_message_id": "FAKE_MSG_002.recvd-1234567890-fghij-1-12345679-10.0",
50
+ "timestamp": 1700000150,
51
+ "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
52
+ },
53
+ "timestamp": "2025-01-01T12:02:30.000Z",
54
+ "eventType": "open"
55
+ },
56
+ {
57
+ "headers": {
58
+ "host": "example-webhook.ngrok.app",
59
+ "user-agent": "SendGrid Event API",
60
+ "content-length": "366",
61
+ "accept-encoding": "gzip",
62
+ "content-type": "application/json;charset=utf-8",
63
+ "x-forwarded-for": "192.0.2.22",
64
+ "x-forwarded-host": "example-webhook.ngrok.app",
65
+ "x-forwarded-proto": "https",
66
+ "x-twilio-email-event-webhook-signature": "FAKE_SIGNATURE_MEUCIQC7bY6P_DO_NOT_USE_IN_PRODUCTION=",
67
+ "x-twilio-email-event-webhook-timestamp": "1700000250"
68
+ },
69
+ "body": {
70
+ "email": "another-recipient@example.com",
71
+ "event": "open",
72
+ "ip": "192.0.2.102",
73
+ "sg_content_type": "html",
74
+ "sg_event_id": "FAKE_OPEN_EVENT_003",
75
+ "sg_machine_open": true,
76
+ "sg_message_id": "FAKE_MSG_003.recvd-1234567890-klmno-1-1234567A-10.0",
77
+ "timestamp": 1700000250,
78
+ "useragent": "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0 (via ggpht.com GoogleImageProxy)"
79
+ },
80
+ "timestamp": "2025-01-01T12:04:10.000Z",
81
+ "eventType": "open"
82
+ }
83
+ ]
@@ -0,0 +1,88 @@
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 { connect, disconnect } from '@ngrok/ngrok';
10
+
11
+ export interface NgrokTunnelConfig {
12
+ port: number;
13
+ domain?: string; // Reserved domain (paid account feature)
14
+ authtoken?: string;
15
+ }
16
+
17
+ export class NgrokTunnelManager {
18
+ private listener: any;
19
+ private publicUrl: string | null = null;
20
+
21
+ async start(config: NgrokTunnelConfig): Promise<string> {
22
+ const connectOptions: any = {
23
+ addr: config.port,
24
+ authtoken: config.authtoken || process.env.NGROK_AUTH_TOKEN
25
+ };
26
+
27
+ // Use reserved domain if available (paid account feature)
28
+ if (config.domain) {
29
+ connectOptions.domain = config.domain;
30
+ }
31
+
32
+ try {
33
+ // Disconnect any existing tunnels first (in case of previous failed runs)
34
+ try {
35
+ await disconnect();
36
+ console.log('Disconnected existing ngrok tunnels');
37
+ } catch {
38
+ // Ignore errors if no tunnels exist
39
+ }
40
+
41
+ this.listener = await connect(connectOptions);
42
+ this.publicUrl = this.listener.url();
43
+
44
+ if (!this.publicUrl) {
45
+ throw new Error('Failed to get ngrok tunnel URL');
46
+ }
47
+
48
+ console.log(`ngrok tunnel established: ${this.publicUrl}`);
49
+ return this.publicUrl;
50
+ } catch (error) {
51
+ const errorMessage = error instanceof Error ? error.message : String(error);
52
+ throw new Error(`Failed to start ngrok tunnel: ${errorMessage}`);
53
+ }
54
+ }
55
+
56
+ async stop(): Promise<void> {
57
+ if (this.listener) {
58
+ try {
59
+ // Use disconnect with the URL, or close the listener directly
60
+ const url = this.listener.url();
61
+ if (url) {
62
+ const { disconnect } = await import('@ngrok/ngrok');
63
+ await disconnect(url);
64
+ }
65
+ this.listener = null;
66
+ this.publicUrl = null;
67
+ console.log('ngrok tunnel closed');
68
+ } catch (error) {
69
+ const errorMessage = error instanceof Error ? error.message : String(error);
70
+ console.warn(`Error closing ngrok tunnel: ${errorMessage}`);
71
+ }
72
+ }
73
+ }
74
+
75
+ getUrl(): string | null {
76
+ return this.publicUrl;
77
+ }
78
+
79
+ // Get tunnel statistics (paid account feature)
80
+ async getStats(): Promise<any> {
81
+ if (!this.listener) {
82
+ throw new Error('Tunnel not started');
83
+ }
84
+ // Access tunnel metrics via ngrok API
85
+ return this.listener;
86
+ }
87
+ }
88
+