feat: implement MailGun webhook service for logging email events
Implemented a production-ready TypeScript/Express.js service to receive and log MailGun webhook events (delivered, failed, opened, clicked, etc.). Key features: - Webhook endpoint (POST /webhook) with comprehensive event logging - Full TypeScript type definitions for all MailGun event types - Prometheus metrics integration for monitoring - Health check endpoint (GET /ping) - Comprehensive Jest test suite with 87.76% coverage - Docker containerization with build scripts Removed template/example code: - All SQL/MSSQL dependencies and related code - Example auth router and middleware - PRTG metrics support (simplified to Prometheus only) - Unused middleware (CORS, IP whitelist, request parsing/validation) - Template documentation (kept only MailGun webhook API spec) The service is clean, minimal, and focused solely on receiving and logging MailGun webhook events to the console. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
157
mailgun-webhook/src/routes/webhookRouter.ts
Normal file
157
mailgun-webhook/src/routes/webhookRouter.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 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 { isValidMailgunEvent, MailgunWebhookEvent } from '../types/MailgunWebhookEvent';
|
||||
import { successfulRequestCounter } from '../lib/metricsCounters';
|
||||
import { logInfo, logError, logWarn } from '../lib/logger';
|
||||
|
||||
/**
|
||||
* 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 eventData = req.body as any;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
Reference in New Issue
Block a user