@bernierllc/email-webhook-events 1.0.0 → 1.0.1

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 (41) hide show
  1. package/CHANGELOG.md +12 -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 +267 -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/package.json +16 -10
  16. package/vitest.config.ts +34 -0
  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
  41. package/jest.config.cjs +0 -29
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # @bernierllc/email-webhook-events
2
+
3
+ ## 1.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`c45c75c`](https://github.com/bernierllc/tools/commit/c45c75ce2db9cc9f45fe1be6c73e8018a427727c) - Migrate test infrastructure from Jest to Vitest and add integration testing support
8
+ - Switch from Jest to Vitest for improved ESM compatibility
9
+ - Add integration test infrastructure using ngrok for real SendGrid webhook testing
10
+ - Add @bernierllc/email-manager as dependency for integration tests
11
+ - Capture real webhook payloads for accurate test fixtures
12
+ - 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
+
@@ -0,0 +1,267 @@
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
+ /**
10
+ * SendGrid Webhook Integration Tests
11
+ *
12
+ * These tests use REAL SendGrid API and webhooks to:
13
+ * - Send emails via @bernierllc/email-manager
14
+ * - Receive webhooks from SendGrid via ngrok tunnel
15
+ * - Process webhooks through @bernierllc/email-webhook-events
16
+ * - Capture real webhook payloads for mock generation
17
+ *
18
+ * Prerequisites:
19
+ * - .env.test file with valid API keys and configuration
20
+ * - SendGrid webhook URL configured to ngrok URL
21
+ * - RUN_INTEGRATION_TESTS=true environment variable set
22
+ *
23
+ * Run with: npm test -- sendgrid-webhook-integration
24
+ */
25
+
26
+ import * as dotenv from 'dotenv';
27
+ import * as path from 'path';
28
+ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
29
+ import { EmailManager } from '@bernierllc/email-manager';
30
+ import { EmailWebhookEventsService, EmailProvider, EmailEventType } from '../../src/index';
31
+ import { WebhookTestServer, CapturedWebhook } from './webhook-server';
32
+ import { NgrokTunnelManager } from './ngrok-tunnel-manager';
33
+
34
+ // Load test environment variables from .env.test (at repo root)
35
+ dotenv.config({ path: path.join(__dirname, '..', '..', '..', '..', '..', '.env.test') });
36
+
37
+ // Skip these tests if not explicitly enabled or if required env vars are missing
38
+ const SKIP_INTEGRATION_TESTS =
39
+ !process.env.RUN_INTEGRATION_TESTS ||
40
+ !process.env.SENDGRID_API_KEY ||
41
+ !process.env.SENDGRID_FROM_EMAIL ||
42
+ !process.env.NGROK_AUTH_TOKEN ||
43
+ !process.env.TEST_EMAIL;
44
+
45
+ const describeOrSkip = SKIP_INTEGRATION_TESTS ? describe.skip : describe;
46
+
47
+ describeOrSkip('SendGrid Webhook Integration Tests', () => {
48
+ let webhookServer: WebhookTestServer;
49
+ let ngrokTunnel: NgrokTunnelManager;
50
+ let webhookUrl: string;
51
+ let emailManager: EmailManager;
52
+ let webhookService: EmailWebhookEventsService;
53
+
54
+ beforeAll(async () => {
55
+ // Start webhook server
56
+ const port = parseInt(process.env.WEBHOOK_SERVER_PORT || '3000', 10);
57
+ webhookServer = new WebhookTestServer(port);
58
+ await webhookServer.start();
59
+
60
+ // Start ngrok tunnel programmatically
61
+ ngrokTunnel = new NgrokTunnelManager();
62
+ webhookUrl = await ngrokTunnel.start({
63
+ port,
64
+ domain: process.env.NGROK_RESERVED_DOMAIN,
65
+ authtoken: process.env.NGROK_AUTH_TOKEN
66
+ });
67
+
68
+ console.log(`\n📡 Webhook URL: ${webhookUrl}/webhooks/sendgrid`);
69
+ console.log(`📋 Configure SendGrid webhook URL to: ${webhookUrl}/webhooks/sendgrid\n`);
70
+
71
+ // Initialize email manager
72
+ emailManager = new EmailManager({
73
+ providers: [],
74
+ scheduling: { enabled: false } // Disable scheduler for tests
75
+ });
76
+
77
+ // Manually add provider and await to ensure it's registered before sending
78
+ const addResult = await emailManager.addProvider({
79
+ id: 'sendgrid-test',
80
+ name: 'SendGrid Test',
81
+ type: 'sendgrid',
82
+ config: {
83
+ apiKey: process.env.SENDGRID_API_KEY!,
84
+ fromEmail: process.env.SENDGRID_FROM_EMAIL!,
85
+ fromName: 'Webhook Integration Test'
86
+ },
87
+ isActive: true,
88
+ priority: 1
89
+ });
90
+
91
+ if (!addResult.success) {
92
+ throw new Error(`Failed to add SendGrid provider: ${addResult.errors?.join(', ')}`);
93
+ }
94
+
95
+ console.log('✅ SendGrid provider added successfully');
96
+
97
+ // Initialize webhook service
98
+ webhookService = new EmailWebhookEventsService({
99
+ providers: [{
100
+ provider: EmailProvider.SENDGRID,
101
+ enabled: true,
102
+ webhookUrl: '/webhooks/sendgrid',
103
+ secretKey: process.env.SENDGRID_WEBHOOK_SECRET || '',
104
+ signatureHeader: 'X-Twilio-Email-Event-Webhook-Signature',
105
+ signatureAlgorithm: 'sha256'
106
+ }],
107
+ persistence: {
108
+ enabled: true,
109
+ adapter: 'memory'
110
+ },
111
+ analytics: {
112
+ realTimeEnabled: true,
113
+ aggregationInterval: 5000,
114
+ retentionDays: 90
115
+ }
116
+ });
117
+ await webhookService.initialize();
118
+ }, 60000); // 60 second timeout for setup
119
+
120
+ afterAll(async () => {
121
+ await ngrokTunnel.stop();
122
+ await webhookServer.stop();
123
+ }, 30000);
124
+
125
+ beforeEach(() => {
126
+ webhookServer.clearCapturedWebhooks();
127
+ });
128
+
129
+ // Helper function to wait for webhook
130
+ async function waitForWebhook(
131
+ eventType: string,
132
+ timeout: number = 60000
133
+ ): Promise<CapturedWebhook | null> {
134
+ const startTime = Date.now();
135
+
136
+ while (Date.now() - startTime < timeout) {
137
+ const webhooks = webhookServer.getCapturedWebhooks();
138
+ const matching = webhooks.find(w => w.body.event === eventType);
139
+
140
+ if (matching) {
141
+ return matching;
142
+ }
143
+
144
+ await new Promise(resolve => setTimeout(resolve, 1000));
145
+ }
146
+
147
+ return null;
148
+ }
149
+
150
+ it('should receive and process delivered webhook', async () => {
151
+ const testEmail = process.env.TEST_EMAIL!;
152
+
153
+ // Send email
154
+ const sendResult = await emailManager.sendEmail({
155
+ to: testEmail,
156
+ subject: 'Test Email for Webhook Testing - Delivered',
157
+ html: '<h1>Test</h1><p>This is a test email for delivered webhook testing.</p>',
158
+ text: 'Test: This is a test email for delivered webhook testing.'
159
+ });
160
+
161
+ expect(sendResult.success).toBe(true);
162
+ expect(sendResult.messageId).toBeDefined();
163
+
164
+ // Wait for webhook (with timeout)
165
+ const deliveredWebhook = await waitForWebhook('delivered', 60000);
166
+
167
+ expect(deliveredWebhook).toBeDefined();
168
+ expect(deliveredWebhook?.body.event).toBe('delivered');
169
+ expect(deliveredWebhook?.body.email).toBe(testEmail);
170
+
171
+ // Process webhook through service
172
+ const result = await webhookService.processWebhook({
173
+ provider: EmailProvider.SENDGRID,
174
+ signature: deliveredWebhook!.headers['x-twilio-email-event-webhook-signature'] || '',
175
+ payload: deliveredWebhook!.body,
176
+ headers: deliveredWebhook!.headers
177
+ });
178
+
179
+ expect(result.success).toBe(true);
180
+ expect(result.event).toBeDefined();
181
+ expect(result.event!.type).toBe(EmailEventType.DELIVERED);
182
+ expect(result.event!.recipient).toBe(testEmail);
183
+ }, 120000); // 2 minute timeout for email delivery
184
+
185
+ it('should capture webhook payload structure', async () => {
186
+ const testEmail = process.env.TEST_EMAIL!;
187
+
188
+ // Send email
189
+ await emailManager.sendEmail({
190
+ to: testEmail,
191
+ subject: 'Test Email for Payload Capture',
192
+ html: '<h1>Payload Test</h1><p>Capturing webhook payload structure.</p>',
193
+ text: 'Payload Test: Capturing webhook payload structure.'
194
+ });
195
+
196
+ // Wait for delivered webhook
197
+ const deliveredWebhook = await waitForWebhook('delivered', 60000);
198
+
199
+ expect(deliveredWebhook).toBeDefined();
200
+
201
+ // Verify payload structure
202
+ expect(deliveredWebhook!.body).toHaveProperty('event');
203
+ expect(deliveredWebhook!.body).toHaveProperty('email');
204
+ expect(deliveredWebhook!.body).toHaveProperty('timestamp');
205
+ expect(deliveredWebhook!.body).toHaveProperty('sg_message_id');
206
+
207
+ // Verify headers
208
+ expect(deliveredWebhook!.headers).toBeDefined();
209
+
210
+ console.log('\n📦 Captured webhook payload structure:');
211
+ console.log(JSON.stringify(deliveredWebhook!.body, null, 2));
212
+ }, 120000);
213
+
214
+ it('should handle multiple events in single webhook', async () => {
215
+ const testEmail = process.env.TEST_EMAIL!;
216
+
217
+ // Send email
218
+ await emailManager.sendEmail({
219
+ to: testEmail,
220
+ subject: 'Test Email for Multiple Events',
221
+ html: '<h1>Multiple Events</h1><p>Testing multiple webhook events.</p>',
222
+ text: 'Multiple Events: Testing multiple webhook events.'
223
+ });
224
+
225
+ // Wait for delivered webhook
226
+ const deliveredWebhook = await waitForWebhook('delivered', 60000);
227
+
228
+ expect(deliveredWebhook).toBeDefined();
229
+
230
+ // SendGrid may send multiple events in one webhook
231
+ // Verify we can handle both single and multiple events
232
+ const allWebhooks = webhookServer.getCapturedWebhooks();
233
+ expect(allWebhooks.length).toBeGreaterThan(0);
234
+ }, 120000);
235
+
236
+ // Note: Open, click, bounce, spamreport, and unsubscribe events
237
+ // require manual interaction or specific test conditions.
238
+ // These can be tested manually or with email client automation.
239
+
240
+ it('should process webhook with signature validation', async () => {
241
+ const testEmail = process.env.TEST_EMAIL!;
242
+
243
+ // Send email
244
+ await emailManager.sendEmail({
245
+ to: testEmail,
246
+ subject: 'Test Email for Signature Validation',
247
+ html: '<h1>Signature Test</h1><p>Testing webhook signature validation.</p>',
248
+ text: 'Signature Test: Testing webhook signature validation.'
249
+ });
250
+
251
+ // Wait for delivered webhook
252
+ const deliveredWebhook = await waitForWebhook('delivered', 60000);
253
+
254
+ expect(deliveredWebhook).toBeDefined();
255
+
256
+ // Verify signature header is present (if SendGrid provides it)
257
+ const signatureHeader = deliveredWebhook!.headers['x-twilio-email-event-webhook-signature'];
258
+
259
+ if (signatureHeader) {
260
+ expect(signatureHeader).toBeDefined();
261
+ console.log('\n🔐 Webhook signature header present:', signatureHeader.substring(0, 20) + '...');
262
+ } else {
263
+ console.log('\n⚠️ Webhook signature header not present (may not be configured in SendGrid)');
264
+ }
265
+ }, 120000);
266
+ });
267
+