From b61a94ab2f5e1102b7fd4b20639dc2e6c3eecef9 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 5 Feb 2026 13:37:18 +0000 Subject: [PATCH 1/5] feat: add postmark-webhooks skill Add webhook handling skill for Postmark email webhooks. - SKILL.md with frontmatter and essential code - Reference docs: overview, setup, verification - Examples: Express, Next.js, FastAPI with tests - Integration: CI workflow, README, test scenarios Note: Postmark uses URL-based authentication (token in query param) rather than signature verification. Examples include token validation and payload structure validation. Co-authored-by: Cursor --- .github/workflows/test-examples.yml | 3 + README.md | 1 + scripts/test-agent-scenario.sh | 21 ++ skills/postmark-webhooks/SKILL.md | 191 +++++++++++++ skills/postmark-webhooks/TODO.md | 24 ++ .../examples/express/.env.example | 10 + .../examples/express/README.md | 80 ++++++ .../examples/express/package.json | 19 ++ .../examples/express/src/index.js | 141 ++++++++++ .../examples/express/test/webhook.test.js | 199 +++++++++++++ .../examples/fastapi/.env.example | 3 + .../examples/fastapi/README.md | 89 ++++++ .../examples/fastapi/main.py | 254 +++++++++++++++++ .../examples/fastapi/requirements.txt | 5 + .../examples/fastapi/test_webhook.py | 214 ++++++++++++++ .../examples/nextjs/.env.example | 3 + .../examples/nextjs/README.md | 78 +++++ .../nextjs/app/webhooks/postmark/route.ts | 194 +++++++++++++ .../examples/nextjs/package.json | 23 ++ .../examples/nextjs/test/webhook.test.ts | 225 +++++++++++++++ .../examples/nextjs/vitest.config.ts | 8 + .../postmark-webhooks/references/overview.md | 116 ++++++++ skills/postmark-webhooks/references/setup.md | 116 ++++++++ .../references/verification.md | 266 ++++++++++++++++++ 24 files changed, 2283 insertions(+) create mode 100644 skills/postmark-webhooks/SKILL.md create mode 100644 skills/postmark-webhooks/TODO.md create mode 100644 skills/postmark-webhooks/examples/express/.env.example create mode 100644 skills/postmark-webhooks/examples/express/README.md create mode 100644 skills/postmark-webhooks/examples/express/package.json create mode 100644 skills/postmark-webhooks/examples/express/src/index.js create mode 100644 skills/postmark-webhooks/examples/express/test/webhook.test.js create mode 100644 skills/postmark-webhooks/examples/fastapi/.env.example create mode 100644 skills/postmark-webhooks/examples/fastapi/README.md create mode 100644 skills/postmark-webhooks/examples/fastapi/main.py create mode 100644 skills/postmark-webhooks/examples/fastapi/requirements.txt create mode 100644 skills/postmark-webhooks/examples/fastapi/test_webhook.py create mode 100644 skills/postmark-webhooks/examples/nextjs/.env.example create mode 100644 skills/postmark-webhooks/examples/nextjs/README.md create mode 100644 skills/postmark-webhooks/examples/nextjs/app/webhooks/postmark/route.ts create mode 100644 skills/postmark-webhooks/examples/nextjs/package.json create mode 100644 skills/postmark-webhooks/examples/nextjs/test/webhook.test.ts create mode 100644 skills/postmark-webhooks/examples/nextjs/vitest.config.ts create mode 100644 skills/postmark-webhooks/references/overview.md create mode 100644 skills/postmark-webhooks/references/setup.md create mode 100644 skills/postmark-webhooks/references/verification.md diff --git a/.github/workflows/test-examples.yml b/.github/workflows/test-examples.yml index cf467b1..ca6cfe7 100644 --- a/.github/workflows/test-examples.yml +++ b/.github/workflows/test-examples.yml @@ -20,6 +20,7 @@ jobs: - resend-webhooks - deepgram-webhooks - fusionauth-webhooks + - postmark-webhooks - hookdeck-event-gateway steps: @@ -53,6 +54,7 @@ jobs: - resend-webhooks - deepgram-webhooks - fusionauth-webhooks + - postmark-webhooks - hookdeck-event-gateway steps: @@ -86,6 +88,7 @@ jobs: - resend-webhooks - deepgram-webhooks - fusionauth-webhooks + - postmark-webhooks - hookdeck-event-gateway steps: diff --git a/README.md b/README.md index 7942d3d..f8ef65e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Skills for receiving and verifying webhooks from specific providers. Each includ | GitHub | [`github-webhooks`](skills/github-webhooks/) | Verify GitHub webhook signatures, handle push, pull_request, and issue events | | OpenAI | [`openai-webhooks`](skills/openai-webhooks/) | Verify OpenAI webhooks for fine-tuning, batch, and realtime async events | | Paddle | [`paddle-webhooks`](skills/paddle-webhooks/) | Verify Paddle webhook signatures, handle subscription and billing events | +| Postmark | [`postmark-webhooks`](skills/postmark-webhooks/) | Authenticate Postmark webhooks (Basic Auth/Token), handle email delivery, bounce, open, click, and spam events | | Resend | [`resend-webhooks`](skills/resend-webhooks/) | Verify Resend webhook signatures, handle email delivery and bounce events | | SendGrid | [`sendgrid-webhooks`](skills/sendgrid-webhooks/) | Verify SendGrid webhook signatures (ECDSA), handle email delivery events | | Shopify | [`shopify-webhooks`](skills/shopify-webhooks/) | Verify Shopify HMAC signatures, handle order and product webhook events | diff --git a/scripts/test-agent-scenario.sh b/scripts/test-agent-scenario.sh index 2216776..9bcfa79 100755 --- a/scripts/test-agent-scenario.sh +++ b/scripts/test-agent-scenario.sh @@ -34,6 +34,9 @@ usage() { echo " fusionauth-express - FusionAuth webhook handling in Express" echo " fusionauth-nextjs - FusionAuth webhook handling in Next.js" echo " fusionauth-fastapi - FusionAuth webhook handling in FastAPI" + echo " postmark-express - Postmark webhook handling in Express" + echo " postmark-nextjs - Postmark webhook handling in Next.js" + echo " postmark-fastapi - Postmark webhook handling in FastAPI" echo " hookdeck-express - Hookdeck Event Gateway in Express" echo "" echo "Options:" @@ -125,6 +128,24 @@ get_scenario_config() { SKILL_NAME="fusionauth-webhooks" PROMPT="Add a FusionAuth webhook endpoint to my FastAPI app. I need to handle user.create and user.delete events. If you use any skills to help with this, add a comment in the code noting which skill(s) you referenced." ;; + postmark-express) + PROVIDER="postmark" + FRAMEWORK="express" + SKILL_NAME="postmark-webhooks" + PROMPT="Add Postmark webhook handling to my Express app. I want to handle Bounce and Delivery events. If you use any skills to help with this, add a comment in the code noting which skill(s) you referenced." + ;; + postmark-nextjs) + PROVIDER="postmark" + FRAMEWORK="nextjs" + SKILL_NAME="postmark-webhooks" + PROMPT="Add a Postmark webhook endpoint to handle email events in my Next.js app. I need to handle Open and Click events. If you use any skills to help with this, add a comment in the code noting which skill(s) you referenced." + ;; + postmark-fastapi) + PROVIDER="postmark" + FRAMEWORK="fastapi" + SKILL_NAME="postmark-webhooks" + PROMPT="Add a Postmark webhook endpoint to my FastAPI app. I need to handle SpamComplaint and SubscriptionChange events. If you use any skills to help with this, add a comment in the code noting which skill(s) you referenced." + ;; hookdeck-express) PROVIDER="hookdeck" FRAMEWORK="express" diff --git a/skills/postmark-webhooks/SKILL.md b/skills/postmark-webhooks/SKILL.md new file mode 100644 index 0000000..5eda2f0 --- /dev/null +++ b/skills/postmark-webhooks/SKILL.md @@ -0,0 +1,191 @@ +--- +name: postmark-webhooks +description: > + Receive and process Postmark webhooks. Use when setting up Postmark webhook + handlers, handling email delivery events, processing bounces, opens, clicks, + spam complaints, or subscription changes. +license: MIT +metadata: + author: hookdeck + version: "0.1.0" + repository: https://github.com/hookdeck/webhook-skills +--- + +# Postmark Webhooks + +## When to Use This Skill + +- Setting up Postmark webhook handlers for email event tracking +- Processing email delivery events (bounce, delivered, open, click) +- Handling spam complaints and subscription changes +- Implementing email engagement analytics +- Troubleshooting webhook authentication issues + +## Essential Code + +### Authentication + +Postmark does NOT use signature verification. Instead, webhooks are authenticated by including credentials in the webhook URL itself. + +```javascript +// Express - Basic Auth in URL +// Configure webhook URL in Postmark as: +// https://username:password@yourdomain.com/webhooks/postmark + +app.post('/webhooks/postmark', express.json(), (req, res) => { + // Basic auth is handled by your web server or proxy + // Additional validation can check expected payload structure + + const event = req.body; + + // Validate expected fields exist + if (!event.RecordType || !event.MessageID) { + return res.status(400).send('Invalid payload structure'); + } + + // Process event + console.log(`Received ${event.RecordType} event for ${event.Email}`); + + res.sendStatus(200); +}); + +// Alternative: Token in URL +// Configure webhook URL as: +// https://yourdomain.com/webhooks/postmark?token=your-secret-token + +app.post('/webhooks/postmark', express.json(), (req, res) => { + const token = req.query.token; + + if (token !== process.env.POSTMARK_WEBHOOK_TOKEN) { + return res.status(401).send('Unauthorized'); + } + + const event = req.body; + console.log(`Received ${event.RecordType} event`); + + res.sendStatus(200); +}); +``` + +### Handling Multiple Events + +```javascript +// Postmark sends one event per request (not batched) +app.post('/webhooks/postmark', express.json(), (req, res) => { + const event = req.body; + + switch (event.RecordType) { + case 'Bounce': + console.log(`Bounce: ${event.Email} - ${event.Type} - ${event.Description}`); + // Update contact as undeliverable + break; + + case 'SpamComplaint': + console.log(`Spam complaint: ${event.Email}`); + // Remove from mailing list + break; + + case 'Open': + console.log(`Email opened: ${event.Email} at ${event.ReceivedAt}`); + // Track engagement + break; + + case 'Click': + console.log(`Link clicked: ${event.Email} - ${event.OriginalLink}`); + // Track click-through rate + break; + + case 'Delivery': + console.log(`Delivered: ${event.Email} at ${event.DeliveredAt}`); + // Confirm delivery + break; + + case 'SubscriptionChange': + console.log(`Subscription change: ${event.Email} - ${event.ChangedAt}`); + // Update subscription preferences + break; + + case 'Inbound': + console.log(`Inbound email from: ${event.Email} - Subject: ${event.Subject}`); + // Process incoming email + break; + + case 'SMTP API Error': + console.log(`SMTP API error: ${event.Email} - ${event.Error}`); + // Handle API error, maybe retry + break; + + default: + console.log(`Unknown event type: ${event.RecordType}`); + } + + res.sendStatus(200); +}); +``` + +## Common Event Types + +| Event | RecordType | Description | Key Fields | +|-------|------------|-------------|------------| +| Bounce | `Bounce` | Hard/soft bounce or blocked email | Email, Type, TypeCode, Description | +| Spam Complaint | `SpamComplaint` | Recipient marked as spam | Email, BouncedAt | +| Open | `Open` | Email opened (requires open tracking) | Email, ReceivedAt, Platform, UserAgent | +| Click | `Click` | Link clicked (requires click tracking) | Email, ClickedAt, OriginalLink | +| Delivery | `Delivery` | Successfully delivered | Email, DeliveredAt, Details | +| Subscription Change | `SubscriptionChange` | Unsubscribe/resubscribe | Email, ChangedAt, SuppressionReason | +| Inbound | `Inbound` | Incoming email received | Email, FromFull, Subject, TextBody, HtmlBody | +| SMTP API Error | `SMTP API Error` | SMTP API call failed | Email, Error, ErrorCode, MessageID | + +## Environment Variables + +```bash +# For token-based authentication +POSTMARK_WEBHOOK_TOKEN="your-secret-token-here" + +# For basic auth (if not using URL-embedded credentials) +WEBHOOK_USERNAME="your-username" +WEBHOOK_PASSWORD="your-password" +``` + +## Security Best Practices + +1. **Always use HTTPS** - Never configure webhooks with HTTP URLs +2. **Use strong credentials** - Generate long, random tokens or passwords +3. **Validate payload structure** - Check for expected fields before processing +4. **Implement IP allowlisting** - Postmark publishes their IP ranges +5. **Consider using a webhook gateway** - Like Hookdeck for additional security layers + +## Local Development + +For local webhook testing, use Hookdeck CLI: + +```bash +brew install hookdeck/hookdeck/hookdeck +hookdeck listen 3000 --path /webhooks/postmark +``` + +No account required. Provides local tunnel + web UI for inspecting requests. + +## Resources + +- [overview.md](references/overview.md) - What Postmark webhooks are, common event types +- [setup.md](references/setup.md) - Configure webhooks in Postmark dashboard +- [verification.md](references/verification.md) - Authentication methods and security best practices +- [examples/](examples/) - Complete implementations for Express, Next.js, and FastAPI + +## Recommended: webhook-handler-patterns + +For production-ready webhook handling, also install the webhook-handler-patterns skill: + +- [Handler sequence](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/handler-sequence.md) - Webhook processing flow +- [Idempotency](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/idempotency.md) - Prevent duplicate processing +- [Error handling](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/error-handling.md) - Graceful error recovery +- [Retry logic](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/retry-logic.md) - Handle transient failures + +## Related Skills + +- [sendgrid-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/sendgrid-webhooks) - SendGrid webhook handling with ECDSA verification +- [resend-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/resend-webhooks) - Resend webhook handling with Svix signatures +- [stripe-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/stripe-webhooks) - Stripe webhook handling with HMAC-SHA256 +- [webhook-handler-patterns](https://github.com/hookdeck/webhook-skills/tree/main/skills/webhook-handler-patterns) - Idempotency, error handling, retry logic +- [hookdeck-event-gateway](https://github.com/hookdeck/webhook-skills/tree/main/skills/hookdeck-event-gateway) - Production webhook infrastructure \ No newline at end of file diff --git a/skills/postmark-webhooks/TODO.md b/skills/postmark-webhooks/TODO.md new file mode 100644 index 0000000..7d77411 --- /dev/null +++ b/skills/postmark-webhooks/TODO.md @@ -0,0 +1,24 @@ +# TODO - Known Issues and Improvements + +*Last updated: 2026-02-05* + +These items were identified during automated review but are acceptable for merge. +Contributions to address these items are welcome. + +## Issues + +### Critical + +- [ ] **skills/postmark-webhooks/SKILL.md**: The documentation correctly states that Postmark does NOT use signature verification, which aligns with the official documentation. However, the review checklist's focus on signature verification is misleading for this provider. + - Suggested fix: No fix needed - the implementation is correct. The review checklist should note that some providers don't use signature verification. + +### Major + +- [ ] **skills/postmark-webhooks/references/overview.md**: Missing 'Inbound' and 'SMTP API Error' webhook event types that are listed in Postmark's official documentation + - Suggested fix: Add documentation for 'Inbound' and 'SMTP API Error' webhook types to the overview and examples + +### Minor + +- [ ] **skills/postmark-webhooks/examples/nextjs/package.json**: Next.js version 16.1.6 doesn't exist. Latest stable is 15.x + - Suggested fix: Change next version to ^15.1.6 or latest 15.x version + diff --git a/skills/postmark-webhooks/examples/express/.env.example b/skills/postmark-webhooks/examples/express/.env.example new file mode 100644 index 0000000..efd751d --- /dev/null +++ b/skills/postmark-webhooks/examples/express/.env.example @@ -0,0 +1,10 @@ +# Postmark webhook authentication token +# Generate with: openssl rand -base64 32 +POSTMARK_WEBHOOK_TOKEN=your-secret-webhook-token + +# Alternative: Basic auth credentials +# WEBHOOK_USERNAME=webhook-user +# WEBHOOK_PASSWORD=webhook-password + +# Server port +PORT=3000 \ No newline at end of file diff --git a/skills/postmark-webhooks/examples/express/README.md b/skills/postmark-webhooks/examples/express/README.md new file mode 100644 index 0000000..9e9d1a1 --- /dev/null +++ b/skills/postmark-webhooks/examples/express/README.md @@ -0,0 +1,80 @@ +# Postmark Webhooks - Express Example + +Minimal example of receiving Postmark webhooks with authentication in Express.js. + +## Prerequisites + +- Node.js 18+ +- Postmark account with webhook configuration access + +## Setup + +1. Install dependencies: + ```bash + npm install + ``` + +2. Copy environment variables: + ```bash + cp .env.example .env + ``` + +3. Generate a secure webhook token: + ```bash + openssl rand -base64 32 + ``` + +4. Add the token to your `.env` file + +## Run + +Start the server: + +```bash +npm start +``` + +Server runs on http://localhost:3000 + +## Configure Postmark + +1. Log in to your [Postmark account](https://account.postmarkapp.com) +2. Select your Server → Webhooks → Add webhook +3. Set the webhook URL with your token: + ``` + https://yourdomain.com/webhooks/postmark?token=your-secret-token + ``` +4. Select the events you want to receive +5. Save and test the webhook + +## Test Locally + +Use the Hookdeck CLI for local testing: + +```bash +# Install Hookdeck CLI +brew install hookdeck/hookdeck/hookdeck + +# Create tunnel to your local server +hookdeck listen 3000 --path /webhooks/postmark +``` + +Then use the provided URL in Postmark's webhook configuration. + +## Test + +Run the test suite: + +```bash +npm test +``` + +## Authentication Methods + +This example uses token-based authentication. For basic auth, configure your webhook URL as: + +``` +https://username:password@yourdomain.com/webhooks/postmark +``` + +And update the handler to validate basic auth headers instead of the token parameter. \ No newline at end of file diff --git a/skills/postmark-webhooks/examples/express/package.json b/skills/postmark-webhooks/examples/express/package.json new file mode 100644 index 0000000..20c8ee0 --- /dev/null +++ b/skills/postmark-webhooks/examples/express/package.json @@ -0,0 +1,19 @@ +{ + "name": "postmark-webhooks-express", + "version": "1.0.0", + "description": "Express.js example for handling Postmark webhooks", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js", + "test": "jest" + }, + "dependencies": { + "express": "^5.2.1", + "dotenv": "^16.4.7" + }, + "devDependencies": { + "jest": "^30.2.0", + "supertest": "^7.0.0" + } +} \ No newline at end of file diff --git a/skills/postmark-webhooks/examples/express/src/index.js b/skills/postmark-webhooks/examples/express/src/index.js new file mode 100644 index 0000000..4e93426 --- /dev/null +++ b/skills/postmark-webhooks/examples/express/src/index.js @@ -0,0 +1,141 @@ +const express = require('express'); +require('dotenv').config(); + +const app = express(); +const port = process.env.PORT || 3000; + +// Postmark webhook handler with token authentication +app.post('/webhooks/postmark', express.json(), (req, res) => { + // Verify authentication token + const token = req.query.token; + + if (token !== process.env.POSTMARK_WEBHOOK_TOKEN) { + console.error('Invalid webhook token'); + return res.status(401).send('Unauthorized'); + } + + // Validate payload structure + const event = req.body; + + if (!event.RecordType || !event.MessageID) { + console.error('Invalid payload structure:', event); + return res.status(400).send('Invalid payload structure'); + } + + // Process the event based on type + console.log(`Received ${event.RecordType} event for message ${event.MessageID}`); + + switch (event.RecordType) { + case 'Bounce': + handleBounce(event); + break; + + case 'SpamComplaint': + handleSpamComplaint(event); + break; + + case 'Open': + handleOpen(event); + break; + + case 'Click': + handleClick(event); + break; + + case 'Delivery': + handleDelivery(event); + break; + + case 'SubscriptionChange': + handleSubscriptionChange(event); + break; + + default: + console.log(`Unknown event type: ${event.RecordType}`); + } + + // Always return 200 to acknowledge receipt + res.sendStatus(200); +}); + +// Event handlers +function handleBounce(event) { + console.log(`Bounce: ${event.Email}`); + console.log(` Type: ${event.Type}`); + console.log(` Description: ${event.Description}`); + console.log(` Bounced at: ${event.BouncedAt}`); + + // In a real application: + // - Mark email as undeliverable in your database + // - Update contact status + // - Trigger re-engagement workflow +} + +function handleSpamComplaint(event) { + console.log(`Spam complaint: ${event.Email}`); + console.log(` Complained at: ${event.BouncedAt}`); + + // In a real application: + // - Remove from all mailing lists immediately + // - Log for compliance tracking + // - Update sender reputation metrics +} + +function handleOpen(event) { + console.log(`Email opened: ${event.Email}`); + console.log(` Opened at: ${event.ReceivedAt}`); + console.log(` Platform: ${event.Platform}`); + console.log(` User Agent: ${event.UserAgent}`); + + // In a real application: + // - Track engagement metrics + // - Update last activity timestamp + // - Trigger engagement-based automation +} + +function handleClick(event) { + console.log(`Link clicked: ${event.Email}`); + console.log(` Clicked at: ${event.ClickedAt}`); + console.log(` Link: ${event.OriginalLink}`); + console.log(` Click location: ${event.ClickLocation}`); + + // In a real application: + // - Track click-through rates + // - Log user behavior + // - Trigger click-based automation +} + +function handleDelivery(event) { + console.log(`Email delivered: ${event.Email}`); + console.log(` Delivered at: ${event.DeliveredAt}`); + console.log(` Server: ${event.ServerID}`); + + // In a real application: + // - Update delivery status + // - Log successful delivery + // - Clear any retry flags +} + +function handleSubscriptionChange(event) { + console.log(`Subscription change: ${event.Email}`); + console.log(` Changed at: ${event.ChangedAt}`); + console.log(` Suppression reason: ${event.SuppressionReason}`); + + // In a real application: + // - Update subscription preferences + // - Log for compliance + // - Trigger preference center update +} + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'ok', service: 'postmark-webhook-handler' }); +}); + +// Start server +app.listen(port, () => { + console.log(`Postmark webhook handler listening on port ${port}`); + console.log(`Webhook endpoint: POST /webhooks/postmark?token=`); +}); + +module.exports = app; \ No newline at end of file diff --git a/skills/postmark-webhooks/examples/express/test/webhook.test.js b/skills/postmark-webhooks/examples/express/test/webhook.test.js new file mode 100644 index 0000000..806f570 --- /dev/null +++ b/skills/postmark-webhooks/examples/express/test/webhook.test.js @@ -0,0 +1,199 @@ +const request = require('supertest'); +const app = require('../src/index'); + +describe('Postmark Webhook Handler', () => { + const validToken = 'test-webhook-token'; + process.env.POSTMARK_WEBHOOK_TOKEN = validToken; + + const webhookUrl = `/webhooks/postmark?token=${validToken}`; + + describe('Authentication', () => { + it('should accept requests with valid token', async () => { + const payload = { + RecordType: 'Bounce', + MessageID: '883953f4-6105-42a2-a16a-77a8eac79483' + }; + + const response = await request(app) + .post(webhookUrl) + .send(payload); + + expect(response.status).toBe(200); + }); + + it('should reject requests with invalid token', async () => { + const response = await request(app) + .post('/webhooks/postmark?token=invalid-token') + .send({ RecordType: 'Bounce', MessageID: 'test' }); + + expect(response.status).toBe(401); + expect(response.text).toBe('Unauthorized'); + }); + + it('should reject requests without token', async () => { + const response = await request(app) + .post('/webhooks/postmark') + .send({ RecordType: 'Bounce', MessageID: 'test' }); + + expect(response.status).toBe(401); + }); + }); + + describe('Payload Validation', () => { + it('should reject payload without RecordType', async () => { + const response = await request(app) + .post(webhookUrl) + .send({ MessageID: 'test-id' }); + + expect(response.status).toBe(400); + expect(response.text).toBe('Invalid payload structure'); + }); + + it('should reject payload without MessageID', async () => { + const response = await request(app) + .post(webhookUrl) + .send({ RecordType: 'Bounce' }); + + expect(response.status).toBe(400); + expect(response.text).toBe('Invalid payload structure'); + }); + }); + + describe('Event Processing', () => { + it('should handle Bounce events', async () => { + const bounceEvent = { + RecordType: 'Bounce', + MessageID: '883953f4-6105-42a2-a16a-77a8eac79483', + Type: 'HardBounce', + Email: 'bounced@example.com', + Description: 'The email address does not exist', + BouncedAt: '2024-01-15T10:30:00Z', + ServerID: 23, + MessageStream: 'outbound', + Tag: 'welcome-email', + Metadata: { + user_id: '12345' + } + }; + + const response = await request(app) + .post(webhookUrl) + .send(bounceEvent); + + expect(response.status).toBe(200); + }); + + it('should handle SpamComplaint events', async () => { + const spamEvent = { + RecordType: 'SpamComplaint', + MessageID: 'test-message-id', + Email: 'user@example.com', + BouncedAt: '2024-01-15T10:30:00Z', + ServerID: 23, + MessageStream: 'outbound' + }; + + const response = await request(app) + .post(webhookUrl) + .send(spamEvent); + + expect(response.status).toBe(200); + }); + + it('should handle Open events', async () => { + const openEvent = { + RecordType: 'Open', + MessageID: 'test-message-id', + Email: 'user@example.com', + ReceivedAt: '2024-01-15T10:30:00Z', + Platform: 'Gmail', + UserAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + ServerID: 23 + }; + + const response = await request(app) + .post(webhookUrl) + .send(openEvent); + + expect(response.status).toBe(200); + }); + + it('should handle Click events', async () => { + const clickEvent = { + RecordType: 'Click', + MessageID: 'test-message-id', + Email: 'user@example.com', + ClickedAt: '2024-01-15T10:30:00Z', + OriginalLink: 'https://example.com/verify', + ClickLocation: 'HTML', + Platform: 'Gmail', + UserAgent: 'Mozilla/5.0' + }; + + const response = await request(app) + .post(webhookUrl) + .send(clickEvent); + + expect(response.status).toBe(200); + }); + + it('should handle Delivery events', async () => { + const deliveryEvent = { + RecordType: 'Delivery', + MessageID: 'test-message-id', + Email: 'user@example.com', + DeliveredAt: '2024-01-15T10:30:00Z', + ServerID: 23, + Details: 'Test details' + }; + + const response = await request(app) + .post(webhookUrl) + .send(deliveryEvent); + + expect(response.status).toBe(200); + }); + + it('should handle SubscriptionChange events', async () => { + const subscriptionEvent = { + RecordType: 'SubscriptionChange', + MessageID: 'test-message-id', + Email: 'user@example.com', + ChangedAt: '2024-01-15T10:30:00Z', + SuppressionReason: 'ManualSuppression' + }; + + const response = await request(app) + .post(webhookUrl) + .send(subscriptionEvent); + + expect(response.status).toBe(200); + }); + + it('should handle unknown event types gracefully', async () => { + const unknownEvent = { + RecordType: 'UnknownType', + MessageID: 'test-message-id' + }; + + const response = await request(app) + .post(webhookUrl) + .send(unknownEvent); + + expect(response.status).toBe(200); + }); + }); + + describe('Health Check', () => { + it('should return health status', async () => { + const response = await request(app) + .get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + status: 'ok', + service: 'postmark-webhook-handler' + }); + }); + }); +}); \ No newline at end of file diff --git a/skills/postmark-webhooks/examples/fastapi/.env.example b/skills/postmark-webhooks/examples/fastapi/.env.example new file mode 100644 index 0000000..fcf161c --- /dev/null +++ b/skills/postmark-webhooks/examples/fastapi/.env.example @@ -0,0 +1,3 @@ +# Postmark webhook authentication token +# Generate with: openssl rand -base64 32 +POSTMARK_WEBHOOK_TOKEN=your-secret-webhook-token \ No newline at end of file diff --git a/skills/postmark-webhooks/examples/fastapi/README.md b/skills/postmark-webhooks/examples/fastapi/README.md new file mode 100644 index 0000000..76434a8 --- /dev/null +++ b/skills/postmark-webhooks/examples/fastapi/README.md @@ -0,0 +1,89 @@ +# Postmark Webhooks - FastAPI Example + +Minimal example of receiving Postmark webhooks with FastAPI. + +## Prerequisites + +- Python 3.9+ +- Postmark account with webhook configuration access + +## Setup + +1. Create virtual environment: + ```bash + python3 -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +2. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +3. Copy environment variables: + ```bash + cp .env.example .env + ``` + +4. Generate a secure webhook token: + ```bash + openssl rand -base64 32 + ``` + +5. Add the token to your `.env` file + +## Run + +Start the server: + +```bash +uvicorn main:app --reload +``` + +Server runs on http://localhost:8000 + +API documentation available at http://localhost:8000/docs + +## Configure Postmark + +1. Log in to your [Postmark account](https://account.postmarkapp.com) +2. Select your Server → Webhooks → Add webhook +3. Set the webhook URL with your token: + ``` + https://yourdomain.com/webhooks/postmark?token=your-secret-token + ``` +4. Select the events you want to receive +5. Save and test the webhook + +## Test Locally + +Use the Hookdeck CLI for local testing: + +```bash +# Install Hookdeck CLI +brew install hookdeck/hookdeck/hookdeck + +# Create tunnel to your local server +hookdeck listen 8000 --path /webhooks/postmark +``` + +Then use the provided URL in Postmark's webhook configuration. + +## Test + +Run the test suite: + +```bash +pytest test_webhook.py -v +``` + +## Authentication Options + +This example uses token-based authentication. For basic auth: + +1. Configure webhook URL as: + ``` + https://username:password@yourdomain.com/webhooks/postmark + ``` + +2. Use FastAPI's HTTPBasic security instead of query parameter validation. \ No newline at end of file diff --git a/skills/postmark-webhooks/examples/fastapi/main.py b/skills/postmark-webhooks/examples/fastapi/main.py new file mode 100644 index 0000000..1eae901 --- /dev/null +++ b/skills/postmark-webhooks/examples/fastapi/main.py @@ -0,0 +1,254 @@ +from fastapi import FastAPI, HTTPException, Query, Request, status +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any, Union, Literal +from datetime import datetime +import os +from dotenv import load_dotenv +import logging + +# Load environment variables +load_dotenv() + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI(title="Postmark Webhook Handler") + +# Get webhook token from environment +POSTMARK_WEBHOOK_TOKEN = os.getenv("POSTMARK_WEBHOOK_TOKEN") +if not POSTMARK_WEBHOOK_TOKEN: + raise ValueError("POSTMARK_WEBHOOK_TOKEN environment variable is required") + + +# Pydantic models for webhook events +class PostmarkEvent(BaseModel): + RecordType: str + MessageID: str + ServerID: int + MessageStream: Optional[str] = None + Tag: Optional[str] = None + Metadata: Optional[Dict[str, Any]] = None + + +class BounceEvent(PostmarkEvent): + RecordType: Literal["Bounce"] + Email: str + Type: str + TypeCode: int + Description: str + Details: str + BouncedAt: str + DumpAvailable: bool + Inactive: bool + CanActivate: bool + Subject: Optional[str] = None + + +class SpamComplaintEvent(PostmarkEvent): + RecordType: Literal["SpamComplaint"] + Email: str + BouncedAt: str + + +class OpenEvent(PostmarkEvent): + RecordType: Literal["Open"] + Email: str + ReceivedAt: str + Platform: Optional[str] = None + UserAgent: Optional[str] = None + + +class ClickEvent(PostmarkEvent): + RecordType: Literal["Click"] + Email: str + ClickedAt: str + OriginalLink: str + ClickLocation: Optional[str] = None + Platform: Optional[str] = None + UserAgent: Optional[str] = None + + +class DeliveryEvent(PostmarkEvent): + RecordType: Literal["Delivery"] + Email: str + DeliveredAt: str + Details: Optional[str] = None + + +class SubscriptionChangeEvent(PostmarkEvent): + RecordType: Literal["SubscriptionChange"] + Email: str + ChangedAt: str + SuppressionReason: Optional[str] = None + + +# Union type for all webhook events +WebhookEvent = Union[ + BounceEvent, + SpamComplaintEvent, + OpenEvent, + ClickEvent, + DeliveryEvent, + SubscriptionChangeEvent, + PostmarkEvent # For unknown event types +] + + +@app.post("/webhooks/postmark", status_code=status.HTTP_200_OK) +async def handle_webhook( + request: Request, + token: Optional[str] = Query(None, description="Authentication token") +): + """Handle Postmark webhook events.""" + + # Verify authentication token + if token != POSTMARK_WEBHOOK_TOKEN: + logger.error("Invalid webhook token") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Unauthorized" + ) + + # Parse the raw body + try: + body = await request.json() + except Exception as e: + logger.error(f"Failed to parse request body: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid JSON payload" + ) + + # Validate required fields + if not body.get("RecordType") or not body.get("MessageID"): + logger.error(f"Invalid payload structure: {body}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid payload structure" + ) + + # Process the event + record_type = body["RecordType"] + message_id = body["MessageID"] + + logger.info(f"Received {record_type} event for message {message_id}") + + # Route to appropriate handler + try: + if record_type == "Bounce": + event = BounceEvent(**body) + await handle_bounce(event) + elif record_type == "SpamComplaint": + event = SpamComplaintEvent(**body) + await handle_spam_complaint(event) + elif record_type == "Open": + event = OpenEvent(**body) + await handle_open(event) + elif record_type == "Click": + event = ClickEvent(**body) + await handle_click(event) + elif record_type == "Delivery": + event = DeliveryEvent(**body) + await handle_delivery(event) + elif record_type == "SubscriptionChange": + event = SubscriptionChangeEvent(**body) + await handle_subscription_change(event) + else: + logger.warning(f"Unknown event type: {record_type}") + # Still return 200 for unknown events + except Exception as e: + logger.error(f"Error processing {record_type} event: {e}") + # Still return 200 to prevent retries + + return {"received": True} + + +# Event handlers +async def handle_bounce(event: BounceEvent): + """Process bounce events.""" + logger.info(f"Bounce: {event.Email}") + logger.info(f" Type: {event.Type}") + logger.info(f" Description: {event.Description}") + logger.info(f" Bounced at: {event.BouncedAt}") + + # In a real application: + # - Mark email as undeliverable in your database + # - Update contact status + # - Trigger re-engagement workflow + + +async def handle_spam_complaint(event: SpamComplaintEvent): + """Process spam complaint events.""" + logger.info(f"Spam complaint: {event.Email}") + logger.info(f" Complained at: {event.BouncedAt}") + + # In a real application: + # - Remove from all mailing lists immediately + # - Log for compliance tracking + # - Update sender reputation metrics + + +async def handle_open(event: OpenEvent): + """Process email open events.""" + logger.info(f"Email opened: {event.Email}") + logger.info(f" Opened at: {event.ReceivedAt}") + if event.Platform: + logger.info(f" Platform: {event.Platform}") + if event.UserAgent: + logger.info(f" User Agent: {event.UserAgent}") + + # In a real application: + # - Track engagement metrics + # - Update last activity timestamp + # - Trigger engagement-based automation + + +async def handle_click(event: ClickEvent): + """Process link click events.""" + logger.info(f"Link clicked: {event.Email}") + logger.info(f" Clicked at: {event.ClickedAt}") + logger.info(f" Link: {event.OriginalLink}") + if event.ClickLocation: + logger.info(f" Click location: {event.ClickLocation}") + + # In a real application: + # - Track click-through rates + # - Log user behavior + # - Trigger click-based automation + + +async def handle_delivery(event: DeliveryEvent): + """Process delivery events.""" + logger.info(f"Email delivered: {event.Email}") + logger.info(f" Delivered at: {event.DeliveredAt}") + logger.info(f" Server: {event.ServerID}") + + # In a real application: + # - Update delivery status + # - Log successful delivery + # - Clear any retry flags + + +async def handle_subscription_change(event: SubscriptionChangeEvent): + """Process subscription change events.""" + logger.info(f"Subscription change: {event.Email}") + logger.info(f" Changed at: {event.ChangedAt}") + if event.SuppressionReason: + logger.info(f" Suppression reason: {event.SuppressionReason}") + + # In a real application: + # - Update subscription preferences + # - Log for compliance + # - Trigger preference center update + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "ok", "service": "postmark-webhook-handler"} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/skills/postmark-webhooks/examples/fastapi/requirements.txt b/skills/postmark-webhooks/examples/fastapi/requirements.txt new file mode 100644 index 0000000..b83dd06 --- /dev/null +++ b/skills/postmark-webhooks/examples/fastapi/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.128.1 +uvicorn>=0.35.0 +python-dotenv>=1.0.1 +pytest>=9.0.2 +httpx>=0.28.1 \ No newline at end of file diff --git a/skills/postmark-webhooks/examples/fastapi/test_webhook.py b/skills/postmark-webhooks/examples/fastapi/test_webhook.py new file mode 100644 index 0000000..c32e4ae --- /dev/null +++ b/skills/postmark-webhooks/examples/fastapi/test_webhook.py @@ -0,0 +1,214 @@ +import pytest +from fastapi.testclient import TestClient +import os + +# Set test environment variables BEFORE importing the app +os.environ["POSTMARK_WEBHOOK_TOKEN"] = "test-webhook-token" + +from main import app + +client = TestClient(app) + +WEBHOOK_URL = "/webhooks/postmark" +VALID_TOKEN = "test-webhook-token" + + +class TestPostmarkWebhook: + """Test Postmark webhook handler.""" + + def test_health_check(self): + """Test health check endpoint.""" + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == { + "status": "ok", + "service": "postmark-webhook-handler" + } + + def test_valid_token_accepted(self): + """Test that requests with valid token are accepted.""" + payload = { + "RecordType": "Bounce", + "MessageID": "883953f4-6105-42a2-a16a-77a8eac79483", + "ServerID": 23 + } + response = client.post( + f"{WEBHOOK_URL}?token={VALID_TOKEN}", + json=payload + ) + assert response.status_code == 200 + assert response.json() == {"received": True} + + def test_invalid_token_rejected(self): + """Test that requests with invalid token are rejected.""" + payload = { + "RecordType": "Bounce", + "MessageID": "test" + } + response = client.post( + f"{WEBHOOK_URL}?token=invalid-token", + json=payload + ) + assert response.status_code == 401 + assert response.json()["detail"] == "Unauthorized" + + def test_missing_token_rejected(self): + """Test that requests without token are rejected.""" + payload = { + "RecordType": "Bounce", + "MessageID": "test" + } + response = client.post(WEBHOOK_URL, json=payload) + assert response.status_code == 401 + + def test_invalid_payload_structure(self): + """Test that invalid payload structure is rejected.""" + # Missing RecordType + response = client.post( + f"{WEBHOOK_URL}?token={VALID_TOKEN}", + json={"MessageID": "test"} + ) + assert response.status_code == 400 + assert "Invalid payload structure" in response.json()["detail"] + + # Missing MessageID + response = client.post( + f"{WEBHOOK_URL}?token={VALID_TOKEN}", + json={"RecordType": "Bounce"} + ) + assert response.status_code == 400 + + def test_bounce_event(self): + """Test handling of bounce events.""" + bounce_event = { + "RecordType": "Bounce", + "MessageID": "883953f4-6105-42a2-a16a-77a8eac79483", + "Type": "HardBounce", + "TypeCode": 1, + "Email": "bounced@example.com", + "Description": "The email address does not exist", + "Details": "smtp;550 5.1.1 The email account does not exist", + "BouncedAt": "2024-01-15T10:30:00Z", + "DumpAvailable": True, + "Inactive": True, + "CanActivate": False, + "ServerID": 23, + "MessageStream": "outbound", + "Tag": "welcome-email", + "Metadata": { + "user_id": "12345" + } + } + response = client.post( + f"{WEBHOOK_URL}?token={VALID_TOKEN}", + json=bounce_event + ) + assert response.status_code == 200 + + def test_spam_complaint_event(self): + """Test handling of spam complaint events.""" + spam_event = { + "RecordType": "SpamComplaint", + "MessageID": "test-message-id", + "Email": "user@example.com", + "BouncedAt": "2024-01-15T10:30:00Z", + "ServerID": 23, + "MessageStream": "outbound" + } + response = client.post( + f"{WEBHOOK_URL}?token={VALID_TOKEN}", + json=spam_event + ) + assert response.status_code == 200 + + def test_open_event(self): + """Test handling of open events.""" + open_event = { + "RecordType": "Open", + "MessageID": "test-message-id", + "Email": "user@example.com", + "ReceivedAt": "2024-01-15T10:30:00Z", + "Platform": "Gmail", + "UserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + "ServerID": 23 + } + response = client.post( + f"{WEBHOOK_URL}?token={VALID_TOKEN}", + json=open_event + ) + assert response.status_code == 200 + + def test_click_event(self): + """Test handling of click events.""" + click_event = { + "RecordType": "Click", + "MessageID": "test-message-id", + "Email": "user@example.com", + "ClickedAt": "2024-01-15T10:30:00Z", + "OriginalLink": "https://example.com/verify", + "ClickLocation": "HTML", + "Platform": "Gmail", + "UserAgent": "Mozilla/5.0", + "ServerID": 23 + } + response = client.post( + f"{WEBHOOK_URL}?token={VALID_TOKEN}", + json=click_event + ) + assert response.status_code == 200 + + def test_delivery_event(self): + """Test handling of delivery events.""" + delivery_event = { + "RecordType": "Delivery", + "MessageID": "test-message-id", + "Email": "user@example.com", + "DeliveredAt": "2024-01-15T10:30:00Z", + "ServerID": 23, + "Details": "Test details" + } + response = client.post( + f"{WEBHOOK_URL}?token={VALID_TOKEN}", + json=delivery_event + ) + assert response.status_code == 200 + + def test_subscription_change_event(self): + """Test handling of subscription change events.""" + subscription_event = { + "RecordType": "SubscriptionChange", + "MessageID": "test-message-id", + "Email": "user@example.com", + "ChangedAt": "2024-01-15T10:30:00Z", + "SuppressionReason": "ManualSuppression", + "ServerID": 23 + } + response = client.post( + f"{WEBHOOK_URL}?token={VALID_TOKEN}", + json=subscription_event + ) + assert response.status_code == 200 + + def test_unknown_event_type(self): + """Test that unknown event types are handled gracefully.""" + unknown_event = { + "RecordType": "UnknownType", + "MessageID": "test-message-id", + "ServerID": 23 + } + response = client.post( + f"{WEBHOOK_URL}?token={VALID_TOKEN}", + json=unknown_event + ) + # Should still return 200 + assert response.status_code == 200 + + def test_invalid_json_payload(self): + """Test handling of invalid JSON payload.""" + response = client.post( + f"{WEBHOOK_URL}?token={VALID_TOKEN}", + content="invalid json", + headers={"Content-Type": "application/json"} + ) + assert response.status_code == 400 + assert "Invalid JSON payload" in response.json()["detail"] \ No newline at end of file diff --git a/skills/postmark-webhooks/examples/nextjs/.env.example b/skills/postmark-webhooks/examples/nextjs/.env.example new file mode 100644 index 0000000..fcf161c --- /dev/null +++ b/skills/postmark-webhooks/examples/nextjs/.env.example @@ -0,0 +1,3 @@ +# Postmark webhook authentication token +# Generate with: openssl rand -base64 32 +POSTMARK_WEBHOOK_TOKEN=your-secret-webhook-token \ No newline at end of file diff --git a/skills/postmark-webhooks/examples/nextjs/README.md b/skills/postmark-webhooks/examples/nextjs/README.md new file mode 100644 index 0000000..ecc15cd --- /dev/null +++ b/skills/postmark-webhooks/examples/nextjs/README.md @@ -0,0 +1,78 @@ +# Postmark Webhooks - Next.js Example + +Minimal example of receiving Postmark webhooks in a Next.js App Router application. + +## Prerequisites + +- Node.js 18+ +- Postmark account with webhook configuration access + +## Setup + +1. Install dependencies: + ```bash + npm install + ``` + +2. Copy environment variables: + ```bash + cp .env.example .env.local + ``` + +3. Generate a secure webhook token: + ```bash + openssl rand -base64 32 + ``` + +4. Add the token to your `.env.local` file + +## Run + +Development mode: + +```bash +npm run dev +``` + +Visit http://localhost:3000 + +## Configure Postmark + +1. Log in to your [Postmark account](https://account.postmarkapp.com) +2. Select your Server → Webhooks → Add webhook +3. Set the webhook URL with your token: + ``` + https://yourdomain.com/webhooks/postmark?token=your-secret-token + ``` +4. Select the events you want to receive +5. Save and test the webhook + +## Test Locally + +Use the Hookdeck CLI for local testing: + +```bash +# Install Hookdeck CLI +brew install hookdeck/hookdeck/hookdeck + +# Create tunnel to your local server +hookdeck listen 3000 --path /webhooks/postmark +``` + +Then use the provided URL in Postmark's webhook configuration. + +## Test + +Run the test suite: + +```bash +npm test +``` + +## Production Deployment + +For production, ensure: + +1. Environment variable `POSTMARK_WEBHOOK_TOKEN` is set +2. Your domain uses HTTPS +3. Consider implementing rate limiting and IP allowlisting \ No newline at end of file diff --git a/skills/postmark-webhooks/examples/nextjs/app/webhooks/postmark/route.ts b/skills/postmark-webhooks/examples/nextjs/app/webhooks/postmark/route.ts new file mode 100644 index 0000000..b4fa08f --- /dev/null +++ b/skills/postmark-webhooks/examples/nextjs/app/webhooks/postmark/route.ts @@ -0,0 +1,194 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// Postmark event type definitions +interface PostmarkEvent { + RecordType: 'Bounce' | 'SpamComplaint' | 'Open' | 'Click' | 'Delivery' | 'SubscriptionChange'; + MessageID: string; + ServerID: number; + MessageStream?: string; + Tag?: string; + Metadata?: Record; +} + +interface BounceEvent extends PostmarkEvent { + RecordType: 'Bounce'; + Email: string; + Type: string; + TypeCode: number; + Description: string; + Details: string; + BouncedAt: string; + DumpAvailable: boolean; + Inactive: boolean; + CanActivate: boolean; + Subject?: string; +} + +interface SpamComplaintEvent extends PostmarkEvent { + RecordType: 'SpamComplaint'; + Email: string; + BouncedAt: string; +} + +interface OpenEvent extends PostmarkEvent { + RecordType: 'Open'; + Email: string; + ReceivedAt: string; + Platform?: string; + UserAgent?: string; +} + +interface ClickEvent extends PostmarkEvent { + RecordType: 'Click'; + Email: string; + ClickedAt: string; + OriginalLink: string; + ClickLocation?: string; + Platform?: string; + UserAgent?: string; +} + +interface DeliveryEvent extends PostmarkEvent { + RecordType: 'Delivery'; + Email: string; + DeliveredAt: string; + Details?: string; +} + +interface SubscriptionChangeEvent extends PostmarkEvent { + RecordType: 'SubscriptionChange'; + Email: string; + ChangedAt: string; + SuppressionReason?: string; +} + +type WebhookEvent = BounceEvent | SpamComplaintEvent | OpenEvent | ClickEvent | DeliveryEvent | SubscriptionChangeEvent; + +export async function POST(request: NextRequest) { + try { + // Verify authentication token + const { searchParams } = new URL(request.url); + const token = searchParams.get('token'); + + if (token !== process.env.POSTMARK_WEBHOOK_TOKEN) { + console.error('Invalid webhook token'); + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Parse the webhook payload + const event: WebhookEvent = await request.json(); + + // Validate payload structure + if (!event.RecordType || !event.MessageID) { + console.error('Invalid payload structure:', event); + return NextResponse.json({ error: 'Invalid payload structure' }, { status: 400 }); + } + + // Process the event + console.log(`Received ${event.RecordType} event for message ${event.MessageID}`); + + switch (event.RecordType) { + case 'Bounce': + await handleBounce(event as BounceEvent); + break; + + case 'SpamComplaint': + await handleSpamComplaint(event as SpamComplaintEvent); + break; + + case 'Open': + await handleOpen(event as OpenEvent); + break; + + case 'Click': + await handleClick(event as ClickEvent); + break; + + case 'Delivery': + await handleDelivery(event as DeliveryEvent); + break; + + case 'SubscriptionChange': + await handleSubscriptionChange(event as SubscriptionChangeEvent); + break; + + default: + console.log(`Unknown event type: ${event.RecordType}`); + } + + // Always return 200 to acknowledge receipt + return NextResponse.json({ received: true }, { status: 200 }); + } catch (error) { + console.error('Error processing webhook:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// Event handlers +async function handleBounce(event: BounceEvent) { + console.log(`Bounce: ${event.Email}`); + console.log(` Type: ${event.Type}`); + console.log(` Description: ${event.Description}`); + console.log(` Bounced at: ${event.BouncedAt}`); + + // In a real application: + // - Mark email as undeliverable in your database + // - Update contact status + // - Trigger re-engagement workflow +} + +async function handleSpamComplaint(event: SpamComplaintEvent) { + console.log(`Spam complaint: ${event.Email}`); + console.log(` Complained at: ${event.BouncedAt}`); + + // In a real application: + // - Remove from all mailing lists immediately + // - Log for compliance tracking + // - Update sender reputation metrics +} + +async function handleOpen(event: OpenEvent) { + console.log(`Email opened: ${event.Email}`); + console.log(` Opened at: ${event.ReceivedAt}`); + if (event.Platform) console.log(` Platform: ${event.Platform}`); + if (event.UserAgent) console.log(` User Agent: ${event.UserAgent}`); + + // In a real application: + // - Track engagement metrics + // - Update last activity timestamp + // - Trigger engagement-based automation +} + +async function handleClick(event: ClickEvent) { + console.log(`Link clicked: ${event.Email}`); + console.log(` Clicked at: ${event.ClickedAt}`); + console.log(` Link: ${event.OriginalLink}`); + if (event.ClickLocation) console.log(` Click location: ${event.ClickLocation}`); + + // In a real application: + // - Track click-through rates + // - Log user behavior + // - Trigger click-based automation +} + +async function handleDelivery(event: DeliveryEvent) { + console.log(`Email delivered: ${event.Email}`); + console.log(` Delivered at: ${event.DeliveredAt}`); + console.log(` Server: ${event.ServerID}`); + + // In a real application: + // - Update delivery status + // - Log successful delivery + // - Clear any retry flags +} + +async function handleSubscriptionChange(event: SubscriptionChangeEvent) { + console.log(`Subscription change: ${event.Email}`); + console.log(` Changed at: ${event.ChangedAt}`); + if (event.SuppressionReason) console.log(` Suppression reason: ${event.SuppressionReason}`); + + // In a real application: + // - Update subscription preferences + // - Log for compliance + // - Trigger preference center update +} \ No newline at end of file diff --git a/skills/postmark-webhooks/examples/nextjs/package.json b/skills/postmark-webhooks/examples/nextjs/package.json new file mode 100644 index 0000000..80460dd --- /dev/null +++ b/skills/postmark-webhooks/examples/nextjs/package.json @@ -0,0 +1,23 @@ +{ + "name": "postmark-webhooks-nextjs", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "test": "vitest run" + }, + "dependencies": { + "next": "^15.1.6", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} \ No newline at end of file diff --git a/skills/postmark-webhooks/examples/nextjs/test/webhook.test.ts b/skills/postmark-webhooks/examples/nextjs/test/webhook.test.ts new file mode 100644 index 0000000..ba91ef2 --- /dev/null +++ b/skills/postmark-webhooks/examples/nextjs/test/webhook.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { POST } from '../app/webhooks/postmark/route'; +import { NextRequest } from 'next/server'; + +describe('Postmark Webhook Route', () => { + const validToken = 'test-webhook-token'; + + beforeEach(() => { + process.env.POSTMARK_WEBHOOK_TOKEN = validToken; + vi.clearAllMocks(); + }); + + function createRequest(payload: any, token?: string) { + const url = token + ? `http://localhost:3000/webhooks/postmark?token=${token}` + : 'http://localhost:3000/webhooks/postmark'; + + return new NextRequest(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + } + + describe('Authentication', () => { + it('should accept requests with valid token', async () => { + const payload = { + RecordType: 'Bounce', + MessageID: '883953f4-6105-42a2-a16a-77a8eac79483', + }; + + const request = createRequest(payload, validToken); + const response = await POST(request); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.received).toBe(true); + }); + + it('should reject requests with invalid token', async () => { + const payload = { + RecordType: 'Bounce', + MessageID: 'test', + }; + + const request = createRequest(payload, 'invalid-token'); + const response = await POST(request); + + expect(response.status).toBe(401); + const data = await response.json(); + expect(data.error).toBe('Unauthorized'); + }); + + it('should reject requests without token', async () => { + const payload = { + RecordType: 'Bounce', + MessageID: 'test', + }; + + const request = createRequest(payload); + const response = await POST(request); + + expect(response.status).toBe(401); + }); + }); + + describe('Payload Validation', () => { + it('should reject payload without RecordType', async () => { + const request = createRequest({ MessageID: 'test-id' }, validToken); + const response = await POST(request); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toBe('Invalid payload structure'); + }); + + it('should reject payload without MessageID', async () => { + const request = createRequest({ RecordType: 'Bounce' }, validToken); + const response = await POST(request); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toBe('Invalid payload structure'); + }); + }); + + describe('Event Processing', () => { + it('should handle Bounce events', async () => { + const bounceEvent = { + RecordType: 'Bounce', + MessageID: '883953f4-6105-42a2-a16a-77a8eac79483', + Type: 'HardBounce', + Email: 'bounced@example.com', + Description: 'The email address does not exist', + Details: 'smtp;550 5.1.1 The email account does not exist', + BouncedAt: '2024-01-15T10:30:00Z', + DumpAvailable: true, + Inactive: true, + CanActivate: false, + ServerID: 23, + MessageStream: 'outbound', + Tag: 'welcome-email', + Metadata: { + user_id: '12345', + }, + }; + + const request = createRequest(bounceEvent, validToken); + const response = await POST(request); + + expect(response.status).toBe(200); + }); + + it('should handle SpamComplaint events', async () => { + const spamEvent = { + RecordType: 'SpamComplaint', + MessageID: 'test-message-id', + Email: 'user@example.com', + BouncedAt: '2024-01-15T10:30:00Z', + ServerID: 23, + MessageStream: 'outbound', + }; + + const request = createRequest(spamEvent, validToken); + const response = await POST(request); + + expect(response.status).toBe(200); + }); + + it('should handle Open events', async () => { + const openEvent = { + RecordType: 'Open', + MessageID: 'test-message-id', + Email: 'user@example.com', + ReceivedAt: '2024-01-15T10:30:00Z', + Platform: 'Gmail', + UserAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + ServerID: 23, + }; + + const request = createRequest(openEvent, validToken); + const response = await POST(request); + + expect(response.status).toBe(200); + }); + + it('should handle Click events', async () => { + const clickEvent = { + RecordType: 'Click', + MessageID: 'test-message-id', + Email: 'user@example.com', + ClickedAt: '2024-01-15T10:30:00Z', + OriginalLink: 'https://example.com/verify', + ClickLocation: 'HTML', + Platform: 'Gmail', + UserAgent: 'Mozilla/5.0', + }; + + const request = createRequest(clickEvent, validToken); + const response = await POST(request); + + expect(response.status).toBe(200); + }); + + it('should handle Delivery events', async () => { + const deliveryEvent = { + RecordType: 'Delivery', + MessageID: 'test-message-id', + Email: 'user@example.com', + DeliveredAt: '2024-01-15T10:30:00Z', + ServerID: 23, + Details: 'Test details', + }; + + const request = createRequest(deliveryEvent, validToken); + const response = await POST(request); + + expect(response.status).toBe(200); + }); + + it('should handle SubscriptionChange events', async () => { + const subscriptionEvent = { + RecordType: 'SubscriptionChange', + MessageID: 'test-message-id', + Email: 'user@example.com', + ChangedAt: '2024-01-15T10:30:00Z', + SuppressionReason: 'ManualSuppression', + }; + + const request = createRequest(subscriptionEvent, validToken); + const response = await POST(request); + + expect(response.status).toBe(200); + }); + + it('should handle unknown event types gracefully', async () => { + const unknownEvent = { + RecordType: 'UnknownType' as any, + MessageID: 'test-message-id', + }; + + const request = createRequest(unknownEvent, validToken); + const response = await POST(request); + + expect(response.status).toBe(200); + }); + }); + + describe('Error Handling', () => { + it('should handle JSON parsing errors', async () => { + const request = new NextRequest(`http://localhost:3000/webhooks/postmark?token=${validToken}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: 'invalid json', + }); + + const response = await POST(request); + expect(response.status).toBe(500); + }); + }); +}); \ No newline at end of file diff --git a/skills/postmark-webhooks/examples/nextjs/vitest.config.ts b/skills/postmark-webhooks/examples/nextjs/vitest.config.ts new file mode 100644 index 0000000..45c0cf3 --- /dev/null +++ b/skills/postmark-webhooks/examples/nextjs/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + }, +}); \ No newline at end of file diff --git a/skills/postmark-webhooks/references/overview.md b/skills/postmark-webhooks/references/overview.md new file mode 100644 index 0000000..c401fa4 --- /dev/null +++ b/skills/postmark-webhooks/references/overview.md @@ -0,0 +1,116 @@ +# Postmark Webhooks Overview + +## What Are Postmark Webhooks? + +Postmark webhooks are HTTP callbacks that notify your application in real-time when email events occur. Unlike polling the API, webhooks push event data to your endpoint immediately when something happens with your emails. + +## Common Event Types + +| Event | Triggered When | Common Use Cases | +|-------|----------------|------------------| +| `Bounce` | Email permanently fails delivery | Update contact lists, handle invalid addresses | +| `SpamComplaint` | Recipient marks email as spam | Remove from mailing lists, protect sender reputation | +| `Open` | Email is opened by recipient | Track engagement rates, measure campaign effectiveness | +| `Click` | Link in email is clicked | Monitor CTR, track user behavior | +| `Delivery` | Email successfully delivered | Confirm delivery, update status | +| `SubscriptionChange` | User unsubscribes/resubscribes | Manage subscription preferences | +| `Inbound` | Incoming email received | Process replies, parse email content, automate responses | +| `SMTP API Error` | SMTP API call fails | Handle API errors, retry failed operations | + +## Event Payload Structure + +Postmark sends one event per webhook request (not batched). All events include: + +```json +{ + "RecordType": "Bounce", + "MessageID": "883953f4-6105-42a2-a16a-77a8eac79483", + "MessageStream": "outbound", + "ServerID": 23, + "From": "sender@example.com", + "Tag": "welcome-email", + "Metadata": { + "user_id": "12345", + "campaign": "onboarding" + } +} +``` + +### Bounce Event Fields + +- `Email` - The bounced email address +- `Type` - Classification: HardBounce, Transient, etc. +- `TypeCode` - Numeric bounce type code +- `Description` - Human-readable bounce reason +- `BouncedAt` - ISO 8601 timestamp +- `DumpAvailable` - Whether full bounce content is available +- `Inactive` - Whether email is now deactivated +- `CanActivate` - Whether email can be reactivated + +### Open Event Fields + +- `Email` - Recipient email address +- `ReceivedAt` - When the email was opened +- `Platform` - Email client platform +- `UserAgent` - Browser/client user agent +- `OS` - Operating system details + +### Click Event Fields + +- `Email` - Recipient email address +- `ClickedAt` - When the link was clicked +- `OriginalLink` - The actual link URL +- `Platform` - Email client platform +- `UserAgent` - Browser user agent +- `ClickLocation` - HTML or Text + +### Inbound Event Fields + +- `Email` - Sender email address +- `FromFull` - Full sender info (name and email) +- `ToFull` - Full recipient info array +- `Subject` - Email subject line +- `TextBody` - Plain text body content +- `HtmlBody` - HTML body content +- `StrippedTextReply` - Reply text with quotes removed +- `Attachments` - Array of attachment metadata +- `MessageStream` - Always "inbound" + +### SMTP API Error Event Fields + +- `Error` - Error message from the API +- `ErrorCode` - Numeric error code +- `MessageID` - ID of the failed message +- `Email` - Recipient email address +- `From` - Sender email address +- `Subject` - Email subject that failed +- `ServerID` - Postmark server ID + +## Authentication Methods + +Postmark does NOT use signature verification. Instead, authentication options include: + +1. **Basic Authentication in URL** + ``` + https://username:password@yourdomain.com/webhooks/postmark + ``` + +2. **Token Parameter** + ``` + https://yourdomain.com/webhooks/postmark?token=your-secret-token + ``` + +3. **IP Allowlisting** + - Firewall configuration to only accept requests from Postmark IPs + +## Testing Webhooks + +Postmark provides several testing methods: + +1. **Test Button** - Send sample webhook from dashboard +2. **RequestBin** - Capture and inspect webhook payloads +3. **Hookdeck CLI** - Local tunnel for development + +## Full Event Reference + +For the complete list of events and fields, see [Postmark's webhook documentation](https://postmarkapp.com/developer/webhooks/webhooks-overview). \ No newline at end of file diff --git a/skills/postmark-webhooks/references/setup.md b/skills/postmark-webhooks/references/setup.md new file mode 100644 index 0000000..5a1915d --- /dev/null +++ b/skills/postmark-webhooks/references/setup.md @@ -0,0 +1,116 @@ +# Setting Up Postmark Webhooks + +## Prerequisites + +- Postmark account with at least one Server configured +- Your application's webhook endpoint URL (must be HTTPS in production) +- Decision on authentication method (Basic Auth or Token) + +## Step 1: Access Webhook Settings + +1. Log in to [Postmark](https://account.postmarkapp.com) +2. Select your Server from the Servers page +3. Navigate to **Webhooks** in the left sidebar + +## Step 2: Add a Webhook + +1. Click **Add webhook** +2. Enter your webhook URL with authentication: + + **Option A: Basic Authentication** + ``` + https://username:password@yourdomain.com/webhooks/postmark + ``` + + **Option B: Token Authentication** + ``` + https://yourdomain.com/webhooks/postmark?token=your-secret-token + ``` + +3. Select the events you want to receive: + - **Bounce** - Hard bounces, soft bounces, and blocked emails + - **Spam Complaint** - When recipients mark email as spam + - **Opens** - Email open tracking (requires open tracking enabled) + - **Link Clicks** - Click tracking (requires click tracking enabled) + - **Delivery** - Successful delivery confirmations + - **Subscription Changes** - Unsubscribe/resubscribe events + +## Step 3: Configure Event Options + +### Bounce Settings +- Include message content in bounce webhooks (optional) +- Choose bounce types to track + +### Open Tracking +- Must be enabled on the Server's **Message Streams** settings +- Configure open tracking for Transactional and/or Broadcast streams + +### Click Tracking +- Must be enabled on the Server's **Message Streams** settings +- Choose between tracking all links or specific links only + +## Step 4: Test Your Webhook + +1. After saving, click the **Test** button next to your webhook +2. Postmark will send a sample payload to your endpoint +3. Verify your endpoint returns a 2xx status code +4. Check your application logs to confirm receipt + +## Message Streams + +Postmark separates email into different streams: +- **Transactional** - Order confirmations, password resets, etc. +- **Broadcast** - Marketing emails, newsletters, etc. + +Configure webhooks per stream or for all streams. + +## Security Recommendations + +1. **Always use HTTPS** in production +2. **Generate strong credentials**: + ```bash + # Generate a secure token + openssl rand -base64 32 + ``` + +3. **Store credentials securely** - Use environment variables, not hard-coded values + +4. **Consider IP allowlisting** - Configure your firewall to only accept requests from Postmark's IP ranges + +## Retry Behavior + +Postmark will retry failed webhook deliveries: +- Retries occur at increasing intervals +- Different retry schedules for different event types +- Webhooks are retried for up to 10 hours + +Your endpoint should: +- Return 2xx status codes for successful processing +- Return 4xx for invalid requests (won't retry) +- Return 5xx for temporary failures (will retry) + +## Multiple Endpoints + +You can configure multiple webhook endpoints for redundancy: +- Each endpoint can receive the same or different events +- Useful for sending events to multiple systems +- Each endpoint has independent retry logic + +## Troubleshooting + +Common issues: + +1. **Webhook not firing** + - Verify events are enabled in webhook configuration + - Check that open/click tracking is enabled if using those events + - Ensure your server is sending emails through the correct Message Stream + +2. **Authentication failures** + - Verify credentials are correctly URL-encoded + - Check environment variables match webhook URL + - Ensure no extra spaces in credentials + +3. **Payload parsing errors** + - Postmark sends `Content-Type: application/json` + - Each request contains a single event (not an array) + - All timestamps are in ISO 8601 format \ No newline at end of file diff --git a/skills/postmark-webhooks/references/verification.md b/skills/postmark-webhooks/references/verification.md new file mode 100644 index 0000000..51d524c --- /dev/null +++ b/skills/postmark-webhooks/references/verification.md @@ -0,0 +1,266 @@ +# Postmark Webhook Authentication + +## No Signature Verification + +Unlike many webhook providers, Postmark does NOT use cryptographic signature verification (no HMAC, no public key cryptography). Instead, Postmark relies on: + +1. **HTTPS** - Encrypted transport +2. **Authentication credentials in the URL** - Basic auth or token +3. **IP allowlisting** - Optional firewall rules + +## Authentication Methods + +### Method 1: Basic Authentication (Recommended) + +Include username and password directly in the webhook URL: + +``` +https://username:password@yourdomain.com/webhooks/postmark +``` + +**Implementation:** + +```javascript +// Express - Basic auth is handled automatically by most web servers +// For manual verification: +app.use('/webhooks/postmark', (req, res, next) => { + const auth = req.headers.authorization; + + if (!auth || !auth.startsWith('Basic ')) { + return res.status(401).send('Unauthorized'); + } + + const credentials = Buffer.from(auth.slice(6), 'base64').toString(); + const [username, password] = credentials.split(':'); + + if (username !== process.env.WEBHOOK_USERNAME || + password !== process.env.WEBHOOK_PASSWORD) { + return res.status(401).send('Unauthorized'); + } + + next(); +}); +``` + +### Method 2: Token in Query Parameter + +Include a secret token as a URL parameter: + +``` +https://yourdomain.com/webhooks/postmark?token=your-secret-token +``` + +**Implementation:** + +```javascript +// Express +app.post('/webhooks/postmark', express.json(), (req, res) => { + const token = req.query.token; + + if (token !== process.env.POSTMARK_WEBHOOK_TOKEN) { + return res.status(401).send('Unauthorized'); + } + + // Process webhook + const event = req.body; + console.log(`Received ${event.RecordType} event`); + res.sendStatus(200); +}); +``` + +### Method 3: Custom Header Token + +While not officially documented, you can implement your own header-based authentication: + +```javascript +// Configure webhook URL with token: +// https://yourdomain.com/webhooks/postmark?token=your-secret-token +// Then validate it as a custom header + +app.post('/webhooks/postmark', express.json(), (req, res) => { + // Could also check a custom header if you proxy the request + const token = req.headers['x-webhook-token'] || req.query.token; + + if (token !== process.env.POSTMARK_WEBHOOK_TOKEN) { + return res.status(401).send('Unauthorized'); + } + + // Process webhook + res.sendStatus(200); +}); +``` + +## Security Best Practices + +### 1. Use Strong Credentials + +```bash +# Generate a secure token +openssl rand -base64 32 +# Example output: EyR5P8XuTia44nBDZ7Te7BQXH4oX7BqNDhS6FWsz8CA= + +# Generate secure password +openssl rand -base64 24 +# Example output: 5KY85cWZRjaL8kUj5Qr3WtPg +``` + +### 2. Validate Payload Structure + +Since there's no signature to verify, validate the expected payload structure: + +```javascript +function isValidPostmarkPayload(payload) { + // Check required fields + if (!payload.RecordType || !payload.MessageID) { + return false; + } + + // Validate RecordType is expected + const validTypes = ['Bounce', 'SpamComplaint', 'Open', + 'Click', 'Delivery', 'SubscriptionChange']; + if (!validTypes.includes(payload.RecordType)) { + return false; + } + + // Validate timestamp format + if (payload.BouncedAt && !isValidISO8601(payload.BouncedAt)) { + return false; + } + + return true; +} + +app.post('/webhooks/postmark', express.json(), (req, res) => { + // ... authentication check ... + + if (!isValidPostmarkPayload(req.body)) { + return res.status(400).send('Invalid payload structure'); + } + + // Process webhook + res.sendStatus(200); +}); +``` + +### 3. IP Allowlisting + +Configure your firewall to only accept webhook requests from Postmark's IP addresses: + +```nginx +# Nginx example +location /webhooks/postmark { + # Postmark publishes their IP ranges + # Check their documentation for current IPs + allow 50.31.156.0/24; + allow 50.31.156.104/32; + # ... add all Postmark IPs + deny all; + + proxy_pass http://your-app; +} +``` + +### 4. Rate Limiting + +Implement rate limiting to prevent abuse: + +```javascript +const rateLimit = require('express-rate-limit'); + +const postmarkLimiter = rateLimit({ + windowMs: 1 * 60 * 1000, // 1 minute + max: 100, // limit each IP to 100 requests per minute + message: 'Too many webhook requests' +}); + +app.post('/webhooks/postmark', postmarkLimiter, express.json(), (req, res) => { + // Process webhook +}); +``` + +## Common Security Issues + +### URL Encoding + +Ensure special characters in credentials are properly URL-encoded: + +```javascript +// Wrong - will break with special characters +https://user:pass@word@domain.com/webhook + +// Correct - URL encode the password +https://user:pass%40word@domain.com/webhook + +// JavaScript URL encoding +const username = encodeURIComponent('webhook-user'); +const password = encodeURIComponent('p@ss#word!'); +const url = `https://${username}:${password}@domain.com/webhooks/postmark`; +``` + +### Credential Exposure + +Never log or expose webhook URLs with embedded credentials: + +```javascript +// BAD - logs credentials +console.log(`Webhook configured at: ${webhookUrl}`); + +// GOOD - sanitize URL for logging +const sanitizedUrl = webhookUrl.replace(/\/\/[^@]+@/, '//***:***@'); +console.log(`Webhook configured at: ${sanitizedUrl}`); +``` + +### Test vs Production + +Use different credentials for different environments: + +```bash +# .env.development +POSTMARK_WEBHOOK_TOKEN=test-token-not-secret + +# .env.production +POSTMARK_WEBHOOK_TOKEN=EyR5P8XuTia44nBDZ7Te7BQXH4oX7BqNDhS6FWsz8CA= +``` + +## Debugging Authentication Issues + +1. **Check webhook logs in Postmark dashboard** + - Shows HTTP status codes returned + - Displays any error messages + +2. **Use request logging** + ```javascript + app.use((req, res, next) => { + console.log(`${req.method} ${req.path}`, { + headers: req.headers, + query: req.query, + // Don't log body in production - may contain sensitive data + }); + next(); + }); + ``` + +3. **Test with curl** + ```bash + # Test basic auth + curl -X POST https://username:password@localhost:3000/webhooks/postmark \ + -H "Content-Type: application/json" \ + -d '{"RecordType":"Bounce","MessageID":"test"}' + + # Test token auth + curl -X POST "https://localhost:3000/webhooks/postmark?token=test-token" \ + -H "Content-Type: application/json" \ + -d '{"RecordType":"Bounce","MessageID":"test"}' + ``` + +## Using Hookdeck for Enhanced Security + +For additional security layers, consider using Hookdeck as a webhook gateway: + +- Automatic HTTPS endpoint provisioning +- Built-in authentication and verification +- Request filtering and transformation +- Retry logic and error handling +- Webhook replay capabilities + +See the [hookdeck-event-gateway](https://github.com/hookdeck/webhook-skills/tree/main/skills/hookdeck-event-gateway) skill for implementation details. \ No newline at end of file From 1290f322c476d5827f4d2f76aed70bb4acac663d Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 5 Feb 2026 13:43:17 +0000 Subject: [PATCH 2/5] docs: update TODO.md - mark resolved issues as addressed Co-authored-by: Cursor --- skills/postmark-webhooks/TODO.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/skills/postmark-webhooks/TODO.md b/skills/postmark-webhooks/TODO.md index 7d77411..22eae6f 100644 --- a/skills/postmark-webhooks/TODO.md +++ b/skills/postmark-webhooks/TODO.md @@ -9,13 +9,11 @@ Contributions to address these items are welcome. ### Critical -- [ ] **skills/postmark-webhooks/SKILL.md**: The documentation correctly states that Postmark does NOT use signature verification, which aligns with the official documentation. However, the review checklist's focus on signature verification is misleading for this provider. - - Suggested fix: No fix needed - the implementation is correct. The review checklist should note that some providers don't use signature verification. +None - Postmark correctly uses URL-based authentication instead of signature verification, which matches the official documentation. ### Major -- [ ] **skills/postmark-webhooks/references/overview.md**: Missing 'Inbound' and 'SMTP API Error' webhook event types that are listed in Postmark's official documentation - - Suggested fix: Add documentation for 'Inbound' and 'SMTP API Error' webhook types to the overview and examples +None - Inbound and SMTP API Error event types are documented in overview.md. ### Minor From 5bc44f0fc0bc31f7b60e864687231149f2bc561c Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 5 Feb 2026 18:33:19 +0000 Subject: [PATCH 3/5] fix: update validate-provider.sh for dynamic test scenarios test-agent-scenario.sh now reads scenarios from providers.yaml instead of hardcoding them. Update validation to check for testScenario in providers.yaml instead of grepping test-agent-scenario.sh. Co-authored-by: Cursor --- scripts/validate-provider.sh | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/scripts/validate-provider.sh b/scripts/validate-provider.sh index fd74d43..1892b9b 100755 --- a/scripts/validate-provider.sh +++ b/scripts/validate-provider.sh @@ -212,20 +212,28 @@ validate_integration() { errors+=("$provider not found in README.md Provider Skills table") fi - # Check providers.yaml has entry + # Check providers.yaml has entry with testScenario + # test-agent-scenario.sh now reads scenarios dynamically from providers.yaml if [ -f "$ROOT_DIR/providers.yaml" ]; then if ! grep -q "name: $provider_name" "$ROOT_DIR/providers.yaml"; then errors+=("$provider_name not found in providers.yaml") + else + # Check that the provider has a testScenario defined + # Use awk to find the provider block and check for testScenario + local has_test_scenario + has_test_scenario=$(awk -v provider="$provider_name" ' + /^ - name:/ { in_provider = ($3 == provider) } + in_provider && /testScenario:/ { print "yes"; exit } + /^ - name:/ && !($3 == provider) { in_provider = 0 } + ' "$ROOT_DIR/providers.yaml") + if [ "$has_test_scenario" != "yes" ]; then + errors+=("No testScenario for $provider_name in providers.yaml") + fi fi else errors+=("providers.yaml not found at repository root") fi - # Check test-agent-scenario.sh has at least one scenario - if ! grep -q "$provider_name" "$ROOT_DIR/scripts/test-agent-scenario.sh"; then - errors+=("No scenario for $provider_name in scripts/test-agent-scenario.sh") - fi - # Return errors if [ ${#errors[@]} -gt 0 ]; then printf '%s\n' "${errors[@]}" @@ -324,8 +332,7 @@ if [ ${#FAILED_PROVIDERS[@]} -gt 0 ]; then log "Please ensure you have updated:" log " 1. All required skill files (SKILL.md, references/, examples/)" log " 2. README.md - Add provider to Provider Skills table" - log " 3. providers.yaml - Add provider entry with documentation URLs" - log " 4. scripts/test-agent-scenario.sh - Add at least one test scenario" + log " 3. providers.yaml - Add provider entry with documentation URLs and testScenario" exit 1 else log "${GREEN}All ${#PASSED_PROVIDERS[@]} provider(s) passed validation!${NC}" From b32ca2bade748238dfc71b5a9f53b2dafd35dd5e Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 5 Feb 2026 18:34:11 +0000 Subject: [PATCH 4/5] feat: add postmark provider to providers.yaml Add postmark entry with documentation URLs and testScenario for agent testing integration. Co-authored-by: Cursor --- providers.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/providers.yaml b/providers.yaml index dd6f532..31ace5a 100644 --- a/providers.yaml +++ b/providers.yaml @@ -161,6 +161,21 @@ providers: - email.delivered - email.bounced + - name: postmark + displayName: Postmark + docs: + webhooks: https://postmarkapp.com/developer/webhooks/webhooks-overview + events: https://postmarkapp.com/developer/webhooks/webhooks-overview + notes: > + Email delivery platform. Uses Basic Auth or token-based authentication in the webhook URL + (not signature verification). Configure webhook URL with credentials like + https://username:password@yourdomain.com/webhooks/postmark or use a token query parameter. + Events: Bounce, Delivery, Open, Click, SpamComplaint, SubscriptionChange. + testScenario: + events: + - Bounce + - Delivery + - name: sendgrid displayName: SendGrid docs: From 8ae3bf9dea68d80e748dfdd4a89b5ccd7e04c8b7 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 5 Feb 2026 18:55:33 +0000 Subject: [PATCH 5/5] fix: prevent server from starting during tests Only start the Express server when the file is run directly, not when imported for testing. This prevents open handles from causing Jest to hang after tests complete. Co-authored-by: Cursor --- .../postmark-webhooks/examples/express/src/index.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/skills/postmark-webhooks/examples/express/src/index.js b/skills/postmark-webhooks/examples/express/src/index.js index 4e93426..d363c28 100644 --- a/skills/postmark-webhooks/examples/express/src/index.js +++ b/skills/postmark-webhooks/examples/express/src/index.js @@ -132,10 +132,12 @@ app.get('/health', (req, res) => { res.json({ status: 'ok', service: 'postmark-webhook-handler' }); }); -// Start server -app.listen(port, () => { - console.log(`Postmark webhook handler listening on port ${port}`); - console.log(`Webhook endpoint: POST /webhooks/postmark?token=`); -}); +// Start server only when run directly (not when imported for testing) +if (require.main === module) { + app.listen(port, () => { + console.log(`Postmark webhook handler listening on port ${port}`); + console.log(`Webhook endpoint: POST /webhooks/postmark?token=`); + }); +} module.exports = app; \ No newline at end of file