@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.
- package/CHANGELOG.md +12 -0
- package/README.md +42 -0
- package/__tests__/fixtures/sendgrid-webhooks/.gitkeep +0 -0
- package/__tests__/fixtures/sendgrid-webhooks/README.md +63 -0
- package/__tests__/fixtures/sendgrid-webhooks/deferred.json +31 -0
- package/__tests__/fixtures/sendgrid-webhooks/delivered.json +83 -0
- package/__tests__/fixtures/sendgrid-webhooks/open.json +83 -0
- package/__tests__/integration/ngrok-tunnel-manager.ts +88 -0
- package/__tests__/integration/sendgrid-webhook-integration.test.ts +267 -0
- package/__tests__/integration/webhook-server.ts +188 -0
- package/__tests__/mocks/README.md +172 -0
- package/__tests__/mocks/index.ts +94 -0
- package/__tests__/mocks/sendgrid-api-responses.ts +391 -0
- package/__tests__/mocks/sendgrid-webhook-payloads.ts +463 -0
- package/package.json +16 -10
- package/vitest.config.ts +34 -0
- package/coverage/clover.xml +0 -328
- package/coverage/coverage-final.json +0 -10
- package/coverage/lcov-report/base.css +0 -224
- package/coverage/lcov-report/block-navigation.js +0 -87
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +0 -161
- package/coverage/lcov-report/prettify.css +0 -1
- package/coverage/lcov-report/prettify.js +0 -2
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +0 -210
- package/coverage/lcov-report/src/EmailWebhookEventsService.ts.html +0 -826
- package/coverage/lcov-report/src/analytics/AnalyticsEngine.ts.html +0 -775
- package/coverage/lcov-report/src/analytics/index.html +0 -116
- package/coverage/lcov-report/src/index.html +0 -131
- package/coverage/lcov-report/src/normalizers/MailgunNormalizer.ts.html +0 -301
- package/coverage/lcov-report/src/normalizers/PostmarkNormalizer.ts.html +0 -301
- package/coverage/lcov-report/src/normalizers/SESNormalizer.ts.html +0 -436
- package/coverage/lcov-report/src/normalizers/SMTP2GONormalizer.ts.html +0 -247
- package/coverage/lcov-report/src/normalizers/SendGridNormalizer.ts.html +0 -274
- package/coverage/lcov-report/src/normalizers/index.html +0 -176
- package/coverage/lcov-report/src/persistence/InMemoryPersistenceAdapter.ts.html +0 -289
- package/coverage/lcov-report/src/persistence/index.html +0 -116
- package/coverage/lcov-report/src/types.ts.html +0 -823
- package/coverage/lcov.info +0 -710
- 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
|
+
|