Security Improvements: - Add HMAC-SHA256 signature verification for MailGun webhooks - Remove hardcoded signing key fallback, require env variable - Add proper payload structure validation before processing API Changes: - New types: MailgunWebhookPayload, MailgunWebhookPayloadSignature - New type guard: isMailgunWebhookPayload() - Returns 401 for invalid signatures, 400 for malformed payloads Configuration: - Add MAILGUN_WEBHOOK_SIGNING_KEY to both docker-compose files - Service fails fast on startup if signing key not configured 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
202 lines
7.1 KiB
TypeScript
202 lines
7.1 KiB
TypeScript
/**
|
|
* MailGun Webhook Router
|
|
*
|
|
* Handles incoming webhook events from MailGun and logs them to the console.
|
|
* This router processes POST requests containing email event data such as
|
|
* delivered, failed, opened, clicked, bounced, complained, and unsubscribed events.
|
|
*/
|
|
|
|
import { Router, Request, Response, NextFunction } from 'express';
|
|
import createError from 'http-errors';
|
|
import { AppLocals } from '../types/AppLocals';
|
|
import { isMailgunWebhookPayload, isValidMailgunEvent, MailgunWebhookEvent, MailgunWebhookPayloadSignature } from '../types/MailgunWebhookEvent';
|
|
import { successfulRequestCounter } from '../lib/metricsCounters';
|
|
import { logInfo, logError, logWarn } from '../lib/logger';
|
|
import crypto from 'crypto';
|
|
|
|
const WEBHOOK_SIGNING_KEY = process.env.MAILGUN_WEBHOOK_SIGNING_KEY;
|
|
|
|
if (!WEBHOOK_SIGNING_KEY) {
|
|
logError('Configuration Error', 'MAILGUN_WEBHOOK_SIGNING_KEY environment variable is not set');
|
|
throw new Error('MAILGUN_WEBHOOK_SIGNING_KEY environment variable is required');
|
|
}
|
|
|
|
/**
|
|
* Verifies the MailGun webhook signature
|
|
* @param param0 - Object containing signingKey, timestamp, token, and signature
|
|
* @returns boolean indicating if the signature is valid
|
|
*/
|
|
const verifySignature = ({ timestamp, token, signature }: MailgunWebhookPayloadSignature) => {
|
|
const encodedToken = crypto
|
|
.createHmac('sha256', WEBHOOK_SIGNING_KEY)
|
|
.update(timestamp.concat(token))
|
|
.digest('hex')
|
|
|
|
return (encodedToken === signature)
|
|
}
|
|
|
|
/**
|
|
* Formats a Unix timestamp into a human-readable date string
|
|
* @param timestamp - Unix timestamp as string
|
|
* @returns Formatted date string in ISO format
|
|
*/
|
|
const formatTimestamp = (timestamp: string): string => {
|
|
try {
|
|
const timestampNum = parseInt(timestamp, 10);
|
|
if (isNaN(timestampNum)) {
|
|
return 'Invalid timestamp';
|
|
}
|
|
return new Date(timestampNum * 1000).toISOString();
|
|
} catch (error) {
|
|
return 'Invalid timestamp';
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Logs MailGun webhook event data to console with structured formatting
|
|
* @param eventData - The MailGun webhook event data
|
|
*/
|
|
const logWebhookEvent = (eventData: MailgunWebhookEvent): void => {
|
|
const separator = '========================================';
|
|
const minorSeparator = '----------------------------------------';
|
|
|
|
console.log('\n' + separator);
|
|
console.log('MailGun Webhook Event Received');
|
|
console.log(separator);
|
|
console.log(`Event Type: ${eventData.event}`);
|
|
console.log(`Timestamp: ${eventData.timestamp} (${formatTimestamp(eventData.timestamp)})`);
|
|
console.log(`Recipient: ${eventData.recipient}`);
|
|
console.log(`Domain: ${eventData.domain}`);
|
|
|
|
if (eventData['message-id']) {
|
|
console.log(`MailGun Message ID: ${eventData['message-id']}`);
|
|
}
|
|
if (eventData['Message-Id']) {
|
|
console.log(`SMTP Message ID: ${eventData['Message-Id']}`);
|
|
}
|
|
|
|
// Log event-specific data
|
|
console.log(minorSeparator);
|
|
console.log('Event-Specific Data:');
|
|
|
|
switch (eventData.event) {
|
|
case 'delivered':
|
|
if (eventData['message-headers']) {
|
|
console.log(`Message Headers: ${eventData['message-headers'].substring(0, 200)}...`);
|
|
}
|
|
break;
|
|
|
|
case 'failed':
|
|
if (eventData.severity) console.log(`Severity: ${eventData.severity}`);
|
|
if (eventData.reason) console.log(`Reason: ${eventData.reason}`);
|
|
if (eventData.notification) console.log(`Notification: ${eventData.notification}`);
|
|
break;
|
|
|
|
case 'opened':
|
|
if (eventData.city) console.log(`City: ${eventData.city}`);
|
|
if (eventData.country) console.log(`Country: ${eventData.country}`);
|
|
if (eventData['device-type']) console.log(`Device Type: ${eventData['device-type']}`);
|
|
if (eventData['client-os']) console.log(`Client OS: ${eventData['client-os']}`);
|
|
if (eventData['client-name']) console.log(`Client Name: ${eventData['client-name']}`);
|
|
if (eventData.ip) console.log(`IP Address: ${eventData.ip}`);
|
|
break;
|
|
|
|
case 'clicked':
|
|
console.log(`URL Clicked: ${eventData.url}`);
|
|
if (eventData.city) console.log(`City: ${eventData.city}`);
|
|
if (eventData.country) console.log(`Country: ${eventData.country}`);
|
|
if (eventData['device-type']) console.log(`Device Type: ${eventData['device-type']}`);
|
|
if (eventData['client-os']) console.log(`Client OS: ${eventData['client-os']}`);
|
|
if (eventData['client-name']) console.log(`Client Name: ${eventData['client-name']}`);
|
|
if (eventData.ip) console.log(`IP Address: ${eventData.ip}`);
|
|
break;
|
|
|
|
case 'bounced':
|
|
if (eventData.code) console.log(`SMTP Code: ${eventData.code}`);
|
|
if (eventData.error) console.log(`Error: ${eventData.error}`);
|
|
if (eventData.notification) console.log(`Notification: ${eventData.notification}`);
|
|
break;
|
|
|
|
case 'complained':
|
|
console.log('User marked email as spam');
|
|
break;
|
|
|
|
case 'unsubscribed':
|
|
console.log('User unsubscribed from mailing list');
|
|
break;
|
|
}
|
|
|
|
// Log full event data for debugging
|
|
console.log(minorSeparator);
|
|
console.log('Full Event Data (JSON):');
|
|
console.log(JSON.stringify(eventData, null, 2));
|
|
console.log(separator + '\n');
|
|
};
|
|
|
|
/**
|
|
* Main webhook request handler
|
|
* Processes incoming MailGun webhook events and logs them to console
|
|
*/
|
|
export const webhookRequestHandler = async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
|
|
const payload = req.body as any;
|
|
|
|
if(!isMailgunWebhookPayload(payload)) {
|
|
logWarn('Invalid webhook payload structure', JSON.stringify(payload));
|
|
next(createError(400, 'Invalid request format: payload structure is incorrect'));
|
|
return;
|
|
}
|
|
|
|
const { signature, "event-data": eventData } = payload;
|
|
|
|
// Validate that we have the minimum required fields
|
|
if (!isValidMailgunEvent(eventData)) {
|
|
logWarn('Invalid webhook event received', JSON.stringify(eventData));
|
|
next(createError(400, 'Invalid request format: missing required fields (event, recipient)'));
|
|
return;
|
|
}
|
|
|
|
// Verify webhook signature
|
|
const isValidSignature = verifySignature({
|
|
timestamp: signature.timestamp,
|
|
token: signature.token,
|
|
signature: signature.signature
|
|
});
|
|
|
|
if(!isValidSignature) {
|
|
logWarn('Invalid webhook signature', JSON.stringify(signature));
|
|
next(createError(401, 'Unauthorized: invalid webhook signature'));
|
|
return;
|
|
}
|
|
|
|
// Log the webhook event to console
|
|
logWebhookEvent(eventData as MailgunWebhookEvent);
|
|
|
|
// Log using the structured logger as well
|
|
logInfo('MailGun webhook event processed', `Event: ${eventData.event}, Recipient: ${eventData.recipient}`);
|
|
|
|
// Return success response to MailGun
|
|
res.status(200).json({
|
|
status: 'received',
|
|
message: 'Webhook event logged successfully'
|
|
});
|
|
|
|
// Increment successful requests counter for metrics
|
|
successfulRequestCounter.inc();
|
|
} catch (ex: any) {
|
|
logError('Error processing webhook', ex.message);
|
|
next(createError(500, 'Internal server error'));
|
|
}
|
|
|
|
// Stop Prometheus timer
|
|
(res.locals as AppLocals).stopPrometheusTimer({ path: req.path });
|
|
};
|
|
|
|
/**
|
|
* Express router for MailGun webhook endpoint
|
|
*/
|
|
const router = Router();
|
|
|
|
export const webhookRouter = router.post('/', webhookRequestHandler);
|