Files
evidencija-rezija/mailgun-webhook/src/routes/webhookRouter.ts
Knee Cola 7aeea9353d 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>
2026-01-02 20:56:22 +01:00

158 lines
5.6 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 { 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);