diff --git a/README.md b/README.md index 7419282..a48363c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Skills for receiving and verifying webhooks from specific providers. Each includ | GitLab | [`gitlab-webhooks`](skills/gitlab-webhooks/) | Verify GitLab webhook tokens, handle push, merge_request, issue, and pipeline 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 | | Replicate | [`replicate-webhooks`](skills/replicate-webhooks/) | Verify Replicate webhook signatures, handle ML prediction lifecycle 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 | diff --git a/providers.yaml b/providers.yaml index ec621c0..1ddc508 100644 --- a/providers.yaml +++ b/providers.yaml @@ -201,6 +201,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: 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..22eae6f --- /dev/null +++ b/skills/postmark-webhooks/TODO.md @@ -0,0 +1,22 @@ +# 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 + +None - Postmark correctly uses URL-based authentication instead of signature verification, which matches the official documentation. + +### Major + +None - Inbound and SMTP API Error event types are documented in overview.md. + +### 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..d363c28 --- /dev/null +++ b/skills/postmark-webhooks/examples/express/src/index.js @@ -0,0 +1,143 @@ +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 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 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