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:
7
mailgun-webhook/.dockerignore
Normal file
7
mailgun-webhook/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
tests
|
||||
.git
|
||||
coverage
|
||||
node_modules
|
||||
jest.config.ts
|
||||
service-tester.sh
|
||||
build-image.sh
|
||||
5
mailgun-webhook/.env
Normal file
5
mailgun-webhook/.env
Normal file
@@ -0,0 +1,5 @@
|
||||
# This file defines enviroment variables used in development environment
|
||||
# It will be ommited from Docker image by the build process
|
||||
|
||||
# in dev environment Web6 app uses port 3000, so we need to make sure we use different port
|
||||
PORT="4000"
|
||||
26
mailgun-webhook/.env.example
Normal file
26
mailgun-webhook/.env.example
Normal file
@@ -0,0 +1,26 @@
|
||||
# Environment Variables for MailGun Webhook Service
|
||||
# Copy this file to .env for local development
|
||||
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
|
||||
# Prometheus Monitoring
|
||||
# Label that will mark the metric in Prometheus (default: package.json name)
|
||||
PROMETHEUS_APP_LABEL=mailgun-webhook-service
|
||||
|
||||
# CSV definition of buckets for Prometheus/Grafana histogram
|
||||
PROMETHEUS_HISTOGRAM_BUCKETS=0.1,0.5,1,5,10
|
||||
|
||||
# Debug Logging
|
||||
# Enable debug output for specific namespaces
|
||||
# Examples: server:*, app:*, or * for all
|
||||
DEBUG=server:*,app:*
|
||||
|
||||
# MailGun Configuration (for future enhancements)
|
||||
# Uncomment and configure when adding webhook signature verification
|
||||
# MAILGUN_SIGNING_KEY=your-mailgun-signing-key
|
||||
# MAILGUN_WEBHOOK_TIMEOUT=30000
|
||||
|
||||
# Security Configuration (optional)
|
||||
# Uncomment to restrict webhook access to MailGun IPs only
|
||||
# ALLOWED_IPS=209.61.151.0/24,209.61.154.0/24,173.193.210.0/24
|
||||
3
mailgun-webhook/.gitignore
vendored
Normal file
3
mailgun-webhook/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
build
|
||||
coverage/
|
||||
19
mailgun-webhook/.mcp.json
Normal file
19
mailgun-webhook/.mcp.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"serena": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"--from",
|
||||
"git+https://github.com/oraios/serena",
|
||||
"serena",
|
||||
"start-mcp-server",
|
||||
"--enable-web-dashboard",
|
||||
"false"
|
||||
]
|
||||
},
|
||||
"context7": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.context7.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
61
mailgun-webhook/.vscode/launch.json
vendored
Normal file
61
mailgun-webhook/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug Server",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run-script",
|
||||
"start"
|
||||
],
|
||||
"sourceMaps": true,
|
||||
"env": {
|
||||
"DEBUG": "*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"name": "vscode-jest-tests",
|
||||
"request": "launch",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"test",
|
||||
"--",
|
||||
"--runInBand",
|
||||
"--watchAll=false"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"name": "vscode-jest-tests-1634200842588",
|
||||
"request": "launch",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"test",
|
||||
"--",
|
||||
"--runInBand",
|
||||
"--watchAll=false"
|
||||
]
|
||||
},
|
||||
|
||||
]
|
||||
}
|
||||
9
mailgun-webhook/.vscode/settings.json
vendored
Normal file
9
mailgun-webhook/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"jest.jestCommandLine": "npm run test --",
|
||||
"jest.autoRun": {
|
||||
"watch": false,
|
||||
"onSave": "test-file"
|
||||
},
|
||||
"jest.nodeEnv": {
|
||||
}
|
||||
}
|
||||
74
mailgun-webhook/Dockerfile
Normal file
74
mailgun-webhook/Dockerfile
Normal file
@@ -0,0 +1,74 @@
|
||||
#--------------------------------------------
|
||||
# Stage: building TypeScript
|
||||
#--------------------------------------------
|
||||
FROM node:20 AS build-stage
|
||||
|
||||
ENV WORKDIR=/app
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./package*.json ./
|
||||
|
||||
# instaliram pakete
|
||||
RUN npm i && npm cache clean --force
|
||||
|
||||
COPY ./tsconfig.json ./
|
||||
COPY ./src ./src
|
||||
RUN npm run build
|
||||
|
||||
#--------------------------------------------
|
||||
# Stage: instaliram produkcijski node_modules
|
||||
#--------------------------------------------
|
||||
FROM node:20 AS package-stage
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./package*.json ./
|
||||
|
||||
# instaliram SAMO produkcijske
|
||||
RUN npm i --omit=dev && npm cache clean --force
|
||||
|
||||
#--------------------------------------------
|
||||
# Stage: priprema finalnog image-a
|
||||
#--------------------------------------------
|
||||
FROM gcr.io/distroless/nodejs:20 AS assembly-stage
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG PORT
|
||||
ENV PORT=${PORT}
|
||||
|
||||
# (optional) App label to be used in Prometheus (Grafana)
|
||||
ARG PROMETHEUS_APP_LABEL
|
||||
ENV PROMETHEUS_APP_LABEL=${PROMETHEUS_APP_LABEL}=${PROMETHEUS_APP_LABEL}
|
||||
|
||||
# (optional) Prometheus histogram bucket sizes (grafana)
|
||||
ARG PROMETHEUS_HISTOGRAM_BUCKETS
|
||||
ENV PROMETHEUS_HISTOGRAM_BUCKETS=${PROMETHEUS_HISTOGRAM_BUCKETS}=${PROMETHEUS_HISTOGRAM_BUCKETS}
|
||||
|
||||
# CORS settings: kojim domenama dopuštam pristup slikama
|
||||
ARG CORS_ALLOWED_ORIGINS
|
||||
ENV CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS}
|
||||
|
||||
# (optional) IP Address whitelist za metrics i prtg router
|
||||
ARG METRICS_ALLOWED_IP_ADDRESSES
|
||||
ENV METRICS_ALLOWED_IP_ADDRESSES=${METRICS_ALLOWED_IP_ADDRESSES}
|
||||
|
||||
# (optional) uključuje logging u stdout
|
||||
ARG DEBUG
|
||||
ENV DEBUG=${DEBUG}
|
||||
|
||||
# kopiram node-modules
|
||||
COPY --from=package-stage /app/package*.json ./
|
||||
COPY --from=package-stage /app/node_modules ./node_modules
|
||||
|
||||
# kopiram buildane datoteke
|
||||
COPY --from=build-stage /app/build ./server
|
||||
|
||||
# server vrtim pod ograničenim "nobody" korisnikom
|
||||
USER nobody:nobody
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||
CMD ["/nodejs/bin/node", "./server/healthcheck.js"]
|
||||
|
||||
# pokrećem server
|
||||
CMD ["./server/entry.js"]
|
||||
@@ -1,23 +1,185 @@
|
||||
# Mailgun Webhook Handler
|
||||
# MailGun Webhook Service
|
||||
|
||||
This workspace contains the Mailgun webhook handler service for processing email events related to the Evidencija Režija tenant notification system.
|
||||
A production-ready TypeScript/Express.js service for receiving and logging MailGun webhook events with:
|
||||
- **Webhook Processing**: Handles all MailGun email event types (delivered, failed, opened, clicked, etc.)
|
||||
- **Structured Logging**: Console logging with detailed event information
|
||||
- **Monitoring**: Built-in Prometheus metrics and health checks
|
||||
- **Testing**: Complete Jest test suite with comprehensive webhook event coverage
|
||||
- **Docker Ready**: Containerized deployment with health monitoring
|
||||
|
||||
## Purpose
|
||||
## Features
|
||||
|
||||
This service handles email verification and status updates by:
|
||||
- Detecting new tenant email addresses (EmailStatus.Unverified)
|
||||
- Sending verification emails via Mailgun
|
||||
- Updating email status to VerificationPending
|
||||
- Processing webhook events from Mailgun (bounces, complaints, etc.)
|
||||
### 📧 MailGun Webhook Integration
|
||||
- **Event Types**: Supports all MailGun webhook events:
|
||||
- `delivered` - Email successfully delivered
|
||||
- `failed` - Delivery failed (temporary or permanent)
|
||||
- `opened` - Email opened by recipient
|
||||
- `clicked` - Link clicked in email
|
||||
- `bounced` - Email bounced back
|
||||
- `complained` - Recipient marked as spam
|
||||
- `unsubscribed` - Recipient unsubscribed
|
||||
- **Data Logging**: Comprehensive console logging with structured formatting
|
||||
- **Type Safety**: Full TypeScript definitions for all event types
|
||||
|
||||
## Architecture
|
||||
### 🏗️ Infrastructure
|
||||
- **TypeScript**: Full type safety and modern JavaScript features
|
||||
- **Express.js**: Fast, minimalist web framework
|
||||
- **Logging**: Structured logging with debug support and detailed event formatting
|
||||
- **Health Checks**: Built-in `/ping` endpoint for monitoring
|
||||
|
||||
This is a separate system from the Next.js web-app that communicates via the shared MongoDB database.
|
||||
### 📊 Monitoring & DevOps
|
||||
- **Prometheus Metrics**: Built-in metrics collection at `/metrics`
|
||||
- **Docker**: Complete containerization setup
|
||||
- **Source Maps**: Debugging support for production
|
||||
- **Hot Reload**: Development server with auto-restart
|
||||
|
||||
## Setup
|
||||
### 🧪 Testing & Quality
|
||||
- **Jest**: Comprehensive unit tests for all webhook event types
|
||||
- **TypeScript**: Full type coverage
|
||||
- **Test Structure**: Mirror source structure for easy navigation
|
||||
- **CI Ready**: Tests configured for continuous integration
|
||||
|
||||
TBD
|
||||
## Architecture Overview
|
||||
|
||||
## Environment Variables
|
||||
```
|
||||
src/
|
||||
├── entry.ts # Application bootstrap
|
||||
├── app.ts # Express app configuration
|
||||
├── routes/
|
||||
│ ├── webhookRouter.ts # MailGun webhook handler
|
||||
│ ├── pingRouter.ts # Health check endpoint
|
||||
│ ├── metricsRouter.ts # Prometheus metrics
|
||||
│ └── errorRouter.ts # Error handling
|
||||
├── middleware/
|
||||
│ └── InitLocalsMiddleware.ts # Request context initialization
|
||||
├── types/
|
||||
│ ├── MailgunWebhookEvent.ts # MailGun event type definitions
|
||||
│ └── enums/SupportedRoutes.ts # Route path constants
|
||||
└── lib/
|
||||
├── logger.ts # Structured logging utilities
|
||||
└── metricsCounters.ts # Prometheus metrics definitions
|
||||
```
|
||||
|
||||
TBD
|
||||
**API Endpoints:**
|
||||
- `POST /webhook` - Receives MailGun webhook events
|
||||
- `GET /ping` - Health check endpoint
|
||||
- `GET /metrics` - Prometheus metrics
|
||||
|
||||
For detailed API specification, see [docs/MAILGUN_WEBHOOK_API_SPEC.md](docs/MAILGUN_WEBHOOK_API_SPEC.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Installation
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Create environment file
|
||||
cp .env.example .env
|
||||
# Edit .env if needed (defaults work for development)
|
||||
```
|
||||
|
||||
### 2. Development
|
||||
```bash
|
||||
# Start development server with hot reload
|
||||
npm start
|
||||
|
||||
# Server will be running at http://localhost:3000
|
||||
```
|
||||
|
||||
### 3. Testing the Webhook
|
||||
```bash
|
||||
# Send a test webhook event
|
||||
curl -X POST http://localhost:3000/webhook \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"event": "delivered",
|
||||
"timestamp": "1234567890",
|
||||
"token": "test-token",
|
||||
"signature": "test-signature",
|
||||
"recipient": "user@example.com",
|
||||
"domain": "mail.example.com"
|
||||
}'
|
||||
|
||||
# Check the console output for logged event data
|
||||
```
|
||||
|
||||
### 4. Running Tests
|
||||
```bash
|
||||
# Run tests in watch mode
|
||||
npm test
|
||||
|
||||
# Run tests once with coverage
|
||||
npm run test:ci
|
||||
|
||||
# Type checking
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
### 5. Production Build
|
||||
```bash
|
||||
# Build TypeScript to JavaScript
|
||||
npm run build
|
||||
|
||||
# Run production server
|
||||
npm run run-server
|
||||
```
|
||||
|
||||
## Configuring MailGun
|
||||
|
||||
To start receiving webhook events from MailGun:
|
||||
|
||||
1. **Log in to your MailGun account**
|
||||
2. **Navigate to Sending → Webhooks**
|
||||
3. **Add a new webhook URL**: `https://your-domain.com/webhook`
|
||||
4. **Select event types** you want to receive (or select all)
|
||||
5. **Save the webhook configuration**
|
||||
|
||||
MailGun will start sending events to your service endpoint immediately.
|
||||
|
||||
### Webhook Security (Future Enhancement)
|
||||
|
||||
For production deployment, you should implement webhook signature verification:
|
||||
- Use MailGun's `timestamp`, `token`, and `signature` fields
|
||||
- Verify the signature using your MailGun signing key
|
||||
- See [MailGun Webhook Security Documentation](https://documentation.mailgun.com/en/latest/user_manual.html#webhooks)
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Building the Docker Image
|
||||
```bash
|
||||
# Build the image
|
||||
./build-image.sh 1.0.0
|
||||
|
||||
# The image will be tagged as mailgun-webhook-service:1.0.0
|
||||
```
|
||||
|
||||
### Running with Docker
|
||||
```bash
|
||||
# Run the container
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e PORT=3000 \
|
||||
-e PROMETHEUS_APP_LABEL=mailgun-webhook-service \
|
||||
--name mailgun-webhook \
|
||||
mailgun-webhook-service:1.0.0
|
||||
|
||||
# Check logs
|
||||
docker logs -f mailgun-webhook
|
||||
```
|
||||
|
||||
|
||||
## Monitoring
|
||||
|
||||
The service exposes several monitoring endpoints:
|
||||
|
||||
- **Health Check**: `GET /ping` - Returns "pong" if service is healthy
|
||||
- **Prometheus Metrics**: `GET /metrics` - Prometheus-compatible metrics
|
||||
|
||||
## Documentation
|
||||
|
||||
- 📧 **[MailGun Webhook API Spec](docs/MAILGUN_WEBHOOK_API_SPEC.md)** - Complete API specification
|
||||
|
||||
## License
|
||||
|
||||
ISC
|
||||
29
mailgun-webhook/build-image.sh
Executable file
29
mailgun-webhook/build-image.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ "$1" == "" ] ; then
|
||||
printf "\nYou did not specify the Docker image version to build"
|
||||
printf "\n\nSyntax:\n\n build-image.sh 1.0.0\n\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -p "BUILD: Push new image to registry [y/n]? " -n 1 -r
|
||||
echo # (optional) move to a new line
|
||||
|
||||
PUSH_IMAGE_TO_REPO="$REPLY"
|
||||
|
||||
printf "\nBUILD START ...\n\n"
|
||||
|
||||
IMAGE_NAME=$(node -p "require('./package.json').name")
|
||||
IMAGE_VERSION=$1
|
||||
|
||||
IMAGE_TAG=$IMAGE_NAME:$IMAGE_VERSION
|
||||
|
||||
docker build . -t $IMAGE_TAG
|
||||
|
||||
if [[ "$PUSH_IMAGE_TO_REPO" =~ ^[Yy]$ ]]
|
||||
then
|
||||
printf "\nPushing image ...\n\n"
|
||||
docker push $IMAGE_TAG
|
||||
fi
|
||||
|
||||
printf "\nBUILD DONE!\n\n"
|
||||
176
mailgun-webhook/docs/MAILGUN_WEBHOOK_API_SPEC.md
Normal file
176
mailgun-webhook/docs/MAILGUN_WEBHOOK_API_SPEC.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# MailGun Webhook API Specification
|
||||
|
||||
## Overview
|
||||
|
||||
This document specifies the API for receiving webhook events from MailGun. The service logs all received event data to the console for monitoring and debugging purposes.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST /webhook
|
||||
|
||||
Receives webhook events from MailGun when email events occur.
|
||||
|
||||
#### Request Format
|
||||
|
||||
- **Method**: POST
|
||||
- **Content-Type**: `application/x-www-form-urlencoded` or `multipart/form-data`
|
||||
- **Headers**:
|
||||
- No custom headers required for initial implementation
|
||||
|
||||
#### Request Parameters
|
||||
|
||||
MailGun sends various parameters depending on the event type. Common parameters include:
|
||||
|
||||
**Event Identification:**
|
||||
- `event` (string) - Type of event (delivered, failed, opened, clicked, bounced, complained, unsubscribed)
|
||||
- `timestamp` (number) - Unix timestamp when the event occurred
|
||||
- `token` (string) - Randomly generated string for message signature verification
|
||||
- `signature` (string) - String with hexadecimal digits for signature verification
|
||||
|
||||
**Message Information:**
|
||||
- `message-id` (string) - MailGun message ID
|
||||
- `recipient` (string) - Email address of the recipient
|
||||
- `domain` (string) - Domain from which the email was sent
|
||||
- `Message-Id` (string) - SMTP Message-ID header
|
||||
|
||||
**Event-Specific Parameters:**
|
||||
|
||||
For **delivered** events:
|
||||
- `message-headers` (string) - JSON string of message headers
|
||||
|
||||
For **failed** events:
|
||||
- `severity` (string) - Severity level (temporary/permanent)
|
||||
- `reason` (string) - Reason for failure
|
||||
- `notification` (string) - Detailed notification message
|
||||
|
||||
For **opened** events:
|
||||
- `city` (string) - City where email was opened
|
||||
- `country` (string) - Country code
|
||||
- `device-type` (string) - Device type (desktop/mobile/tablet)
|
||||
- `client-os` (string) - Operating system
|
||||
- `client-name` (string) - Email client name
|
||||
- `ip` (string) - IP address
|
||||
|
||||
For **clicked** events:
|
||||
- `url` (string) - URL that was clicked
|
||||
- `city`, `country`, `device-type`, `client-os`, `client-name`, `ip` - Same as opened events
|
||||
|
||||
For **bounced** events:
|
||||
- `code` (string) - SMTP error code
|
||||
- `error` (string) - Detailed error message
|
||||
- `notification` (string) - Bounce notification
|
||||
|
||||
For **complained** events:
|
||||
- No additional parameters
|
||||
|
||||
For **unsubscribed** events:
|
||||
- No additional parameters
|
||||
|
||||
#### Success Response
|
||||
|
||||
- **HTTP Status**: 200 OK
|
||||
- **Content-Type**: `application/json`
|
||||
- **Response Body**:
|
||||
```json
|
||||
{
|
||||
"status": "received",
|
||||
"message": "Webhook event logged successfully"
|
||||
}
|
||||
```
|
||||
|
||||
#### Error Responses
|
||||
|
||||
**Invalid Request (400 Bad Request)**:
|
||||
- **Content-Type**: `application/json`
|
||||
- **Response Body**:
|
||||
```json
|
||||
{
|
||||
"error": "Invalid request format"
|
||||
}
|
||||
```
|
||||
|
||||
**Server Error (500 Internal Server Error)**:
|
||||
- **Content-Type**: `application/json`
|
||||
- **Response Body**:
|
||||
```json
|
||||
{
|
||||
"error": "Internal server error"
|
||||
}
|
||||
```
|
||||
|
||||
## Execution Flow
|
||||
|
||||
1. **Receive webhook POST request** from MailGun
|
||||
2. **Parse request body** (form-urlencoded or multipart data)
|
||||
3. **Extract event data** from request parameters
|
||||
4. **Log event data to console** with structured formatting:
|
||||
- Event type
|
||||
- Timestamp (both Unix and human-readable)
|
||||
- Recipient
|
||||
- All additional event-specific parameters
|
||||
5. **Return success response** to MailGun
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Missing Event Type
|
||||
- **Detection**: Check if `event` parameter is present
|
||||
- **Handling**: Log warning and return 400 Bad Request
|
||||
|
||||
### Malformed Timestamp
|
||||
- **Detection**: Check if `timestamp` can be parsed as number
|
||||
- **Handling**: Log with current timestamp instead, continue processing
|
||||
|
||||
### Large Payload
|
||||
- **Detection**: Monitor request body size
|
||||
- **Handling**: Log truncated data if exceeds reasonable size
|
||||
|
||||
### Duplicate Events
|
||||
- **Detection**: MailGun may send duplicate webhooks
|
||||
- **Handling**: Log all events (no deduplication in initial implementation)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Future Enhancements
|
||||
For production deployment, consider:
|
||||
- **Signature Verification**: Verify webhook authenticity using `timestamp`, `token`, and `signature`
|
||||
- **IP Whitelisting**: Restrict to MailGun's IP ranges
|
||||
- **Rate Limiting**: Prevent abuse
|
||||
|
||||
## Database Integration
|
||||
|
||||
- **Current Implementation**: No database operations required
|
||||
- **Future Enhancement**: Store events in database for analysis
|
||||
|
||||
## Third-Party API Calls
|
||||
|
||||
- **Current Implementation**: No third-party API calls
|
||||
- **Future Enhancement**: Could integrate with notification services
|
||||
|
||||
## Logging Format
|
||||
|
||||
Console output format:
|
||||
```
|
||||
========================================
|
||||
MailGun Webhook Event Received
|
||||
========================================
|
||||
Event Type: delivered
|
||||
Timestamp: 1234567890 (2024-01-01 12:00:00 UTC)
|
||||
Recipient: user@example.com
|
||||
Domain: mail.example.com
|
||||
Message ID: <20240101120000.1.ABC123@mail.example.com>
|
||||
----------------------------------------
|
||||
Additional Parameters:
|
||||
{
|
||||
"message-headers": "[...]",
|
||||
"token": "...",
|
||||
"signature": "..."
|
||||
}
|
||||
========================================
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Use Express body-parser middleware for form data parsing
|
||||
- All logging should use structured logger (debug package)
|
||||
- Maintain type safety with TypeScript interfaces for event data
|
||||
- Follow template's error handling patterns
|
||||
38
mailgun-webhook/jest.config.ts
Normal file
38
mailgun-webhook/jest.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/** @type {import('jest/dist/types').InitialOptionsTsJest} */
|
||||
|
||||
import type { Config } from 'jest/build/index';
|
||||
|
||||
const config:Config = {
|
||||
// preset: 'ts-jest',
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'esbuild-jest', {
|
||||
sourcemap:true, // bez ovog VS code umjesto originala prikazuje transpilirane datoteke
|
||||
target:'es2020' // ovo je nužno kako bi BigInt funkcionirao
|
||||
}]
|
||||
},
|
||||
maxWorkers: 4,
|
||||
testEnvironment: 'node',
|
||||
moduleNameMapper: {
|
||||
},
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
rootDir: "./",
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
roots: [
|
||||
"<rootDir>/tests",
|
||||
],
|
||||
// The glob patterns Jest uses to detect test files
|
||||
testMatch: [
|
||||
"**/?(*.)+(spec).[tj]s?(x)",
|
||||
],
|
||||
// Automatically clear mock calls and instances between every test
|
||||
clearMocks: true,
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
transformIgnorePatterns: ["/node_modules/"],
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
testPathIgnorePatterns: ["/node_modules/", "/build/"],
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
verbose: true,
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
9828
mailgun-webhook/package-lock.json
generated
Normal file
9828
mailgun-webhook/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
mailgun-webhook/package.json
Normal file
51
mailgun-webhook/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "mailgun-webhook-service",
|
||||
"version": "1.0.0",
|
||||
"description": "MailGun webhook receiver service for logging email event notifications",
|
||||
"main": "entry.ts",
|
||||
"scripts": {
|
||||
"start": "nodemon ./src/entry.ts",
|
||||
"build": "ttsc --project ./",
|
||||
"test": "jest --watch",
|
||||
"test:ci": "jest --ci --coverage --watchAll=false",
|
||||
"test:coverage": "jest --coverage",
|
||||
"lint": "tsc --noEmit",
|
||||
"type-check": "tsc --noEmit",
|
||||
"run-server": "DEBUG=* node --enable-source-maps ./build/entry.js"
|
||||
},
|
||||
"author": "Nikola",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"debug": "^2.6.9",
|
||||
"express": "^4.18.2",
|
||||
"http-errors": "^1.7.2",
|
||||
"prom-client": "^14.0.1",
|
||||
"stoppable": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-typescript": "^7.18.6",
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/http-errors": "^1.8.1",
|
||||
"@types/jest": "^29.2.5",
|
||||
"@types/node": "^16.10.2",
|
||||
"@types/stoppable": "^1.1.1",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@zerollup/ts-transform-paths": "^1.7.18",
|
||||
"esbuild": "^0.16.14",
|
||||
"esbuild-jest": "^0.5.0",
|
||||
"jest": "^29.3.1",
|
||||
"nodemon": "^2.0.13",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.1.2",
|
||||
"ttypescript": "^1.5.15",
|
||||
"typescript": "^4.9.4",
|
||||
"typescript-transform-paths": "^3.4.4"
|
||||
},
|
||||
"babel": {
|
||||
"presets": [
|
||||
"@babel/preset-typescript"
|
||||
]
|
||||
}
|
||||
}
|
||||
49
mailgun-webhook/src/app.ts
Normal file
49
mailgun-webhook/src/app.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import express from 'express';
|
||||
import createError from 'http-errors';
|
||||
|
||||
import { errorRouter } from './routes/errorRouter';
|
||||
import { finalErrorRouter } from './routes/finalErrorRouter';
|
||||
import { metricsRouter } from './routes/metricsRouter';
|
||||
|
||||
import { SupportedRoutes } from './types/enums/SupportedRoutes';
|
||||
import { pingRouter } from './routes/pingRouter';
|
||||
import { InitLocalsMiddleware } from './middleware/InitLocalsMiddleware';
|
||||
import { webhookRouter } from './routes/webhookRouter';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.disable('x-powered-by');
|
||||
|
||||
// in case the server runs behind a proxy
|
||||
// this flag will force Express to get information such as
|
||||
// client IP address, protocol from X-Forward-*
|
||||
// HTTP header fields, which are set by the proxy
|
||||
app.set('trust proxy', true);
|
||||
|
||||
// Parse URL-encoded bodies (for MailGun webhook form data)
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Parse JSON bodies (in case MailGun sends JSON)
|
||||
app.use(express.json());
|
||||
|
||||
// Middleware that initializes Locals
|
||||
app.use(InitLocalsMiddleware);
|
||||
|
||||
// MailGun webhook endpoint
|
||||
app.use(SupportedRoutes.webhook, webhookRouter);
|
||||
|
||||
// prometheus fetches the latest valid statistics from this route
|
||||
app.use(SupportedRoutes.metricsPath, metricsRouter);
|
||||
|
||||
app.use(SupportedRoutes.ping, pingRouter);
|
||||
|
||||
// default handler
|
||||
app.use((req, res, next) => next(createError(404)));
|
||||
|
||||
// error handler for all expected errors
|
||||
app.use(errorRouter);
|
||||
|
||||
// error router for unexpected errors
|
||||
app.use(finalErrorRouter);
|
||||
|
||||
export default app;
|
||||
111
mailgun-webhook/src/entry.ts
Executable file
111
mailgun-webhook/src/entry.ts
Executable file
@@ -0,0 +1,111 @@
|
||||
import app from './app';
|
||||
import http from 'http';
|
||||
import stoppable from 'stoppable';
|
||||
import { logServer } from './lib/logger';
|
||||
|
||||
/**
|
||||
* Normalize a port into a number, string, or false.
|
||||
*/
|
||||
const normalizePort = (val:string):string|number|boolean => {
|
||||
const port = parseInt(val, 10);
|
||||
|
||||
if (isNaN(port)) {
|
||||
// named pipe
|
||||
return val;
|
||||
}
|
||||
|
||||
if (port >= 0) {
|
||||
// port number
|
||||
return port;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Event listener for HTTP server "error" event.
|
||||
*/
|
||||
const onError = (error:any):void => {
|
||||
if (error.syscall !== 'listen') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const bind = typeof port === 'string'
|
||||
? 'Pipe ' + port
|
||||
: 'Port ' + port;
|
||||
|
||||
// handle specific listen errors with friendly messages
|
||||
switch (error.code) {
|
||||
case 'EACCES':
|
||||
console.error(bind + ' requires elevated privileges');
|
||||
process.exit(1);
|
||||
break;
|
||||
case 'EADDRINUSE':
|
||||
console.error(bind + ' is already in use');
|
||||
process.exit(1);
|
||||
break;
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Event listener for HTTP server "listening" event.
|
||||
*/
|
||||
const onListening = ():void => {
|
||||
const addr = server.address();
|
||||
const bind = typeof addr === 'string'
|
||||
? 'pipe ' + addr
|
||||
: 'port ' + addr?.port;
|
||||
logServer(`server is running at ${bind}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get port from environment and store in Express.
|
||||
*/
|
||||
const port:number|string|boolean = normalizePort(process.env.PORT || '3000');
|
||||
|
||||
/**
|
||||
* How long should stoppable wait before it starts force-closing connections
|
||||
* @description wait max 10 seconds - needs to be shorter than `healthcheck.timeout` (=15sec)
|
||||
*/
|
||||
const FORCE_STOP_TIMEOUT = 10000;
|
||||
|
||||
/**
|
||||
* Create HTTP server.
|
||||
*/
|
||||
const server = stoppable( http.createServer(app), FORCE_STOP_TIMEOUT );
|
||||
|
||||
// Listen on provided port, on all network interfaces.
|
||||
server.listen(port);
|
||||
server.on('error', onError);
|
||||
server.on('listening', onListening);
|
||||
|
||||
// quit on ctrl-c when running docker in terminal
|
||||
// (signal neće biti registriran ako je server pokrenuti via `npm` ili `nodemon` - mora biti pokrenuti izravno via Node)
|
||||
process.on('SIGINT', () => {
|
||||
logServer('got SIGINT (aka ctrl-c in docker). Graceful shutdown ', new Date().toISOString());
|
||||
shutdown();
|
||||
});
|
||||
|
||||
// quit properly on docker stop
|
||||
// (signal neće biti registriran ako je server pokrenuti via `npm` ili `nodemon` - mora biti pokrenuti izravno via Node)
|
||||
process.on('SIGTERM', () => {
|
||||
logServer('got SIGTERM (docker container stop). Graceful shutdown ', new Date().toISOString());
|
||||
shutdown();
|
||||
});
|
||||
|
||||
// shut down server
|
||||
const shutdown = () => {
|
||||
// NOTE: server.close is for express based apps
|
||||
// If using hapi, use `server.stop`
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
logServer('Exiting server process...');
|
||||
}
|
||||
process.exit();
|
||||
});
|
||||
};
|
||||
28
mailgun-webhook/src/healthcheck.ts
Normal file
28
mailgun-webhook/src/healthcheck.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { logHealthCheck } from "./lib/logger";
|
||||
|
||||
import http, { IncomingMessage } from "http";
|
||||
|
||||
const options = {
|
||||
host: "localhost",
|
||||
port: "3000",
|
||||
timeout: 2000,
|
||||
path: '/ping/'
|
||||
};
|
||||
|
||||
const request = http.request(options, (res:IncomingMessage) => {
|
||||
|
||||
logHealthCheck(`STATUS ${res.statusCode}`);
|
||||
|
||||
if (res.statusCode == 200) {
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
request.on("error", function (err:any) {
|
||||
logHealthCheck("ERROR");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
request.end();
|
||||
38
mailgun-webhook/src/lib/getClientIP.ts
Normal file
38
mailgun-webhook/src/lib/getClientIP.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* Determines the client's IP address
|
||||
* @param req HTTP request object
|
||||
* @returns client's IP address
|
||||
*/
|
||||
export const getClientIP = (req: Request):string => {
|
||||
|
||||
// CloudFlare performs NAT and forwards origin IP address in HTTP header
|
||||
const CF_Connecting_IP:string|undefined|string[] = req.headers["cf-connecting-ip"];
|
||||
|
||||
// 1. priority = CloudFlare - handles connections from external clients
|
||||
if(CF_Connecting_IP) {
|
||||
return(CF_Connecting_IP as string);
|
||||
}
|
||||
|
||||
// Fortigate gateway/SSL offloader do NAT, so original client IP address is
|
||||
// not directly available. Instead it is forwarded via a HTTP header
|
||||
const X_Forwarded_For:string|undefined|string[] = req.headers["x-forwarded-for"];
|
||||
|
||||
// 2. priority = X_Forwarded_For (added by Fortigate gateway)
|
||||
// handles case when the traffic is not going through CF (e.g. local traffic going through FGT load balancer)
|
||||
if(X_Forwarded_For) {
|
||||
return(X_Forwarded_For as string);
|
||||
}
|
||||
|
||||
// IP address of the client connected to the express server (e.g. reverse proxy or dev machine)
|
||||
const direct_client_IP:string = req.ip as string;
|
||||
|
||||
// the local machine's IP address may be in IPv6 format
|
||||
if(direct_client_IP.substr(0,7) === '::ffff:') {
|
||||
return(direct_client_IP.substr(7)); // vraćam IPv4 adresu
|
||||
}
|
||||
|
||||
// 3. priority = direct_client_IP - useful for local testing on DEV machine
|
||||
return(direct_client_IP);
|
||||
};
|
||||
8
mailgun-webhook/src/lib/initTools.ts
Normal file
8
mailgun-webhook/src/lib/initTools.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Returns the provided value if it's defined and not empty, otherwise returns the default value.
|
||||
*
|
||||
* @param value - The value to check (can be string or undefined)
|
||||
* @param defaultValue - The default value to return if value is undefined or empty string
|
||||
* @returns The original value if it's defined and not empty, otherwise the default value
|
||||
*/
|
||||
export const coalesce = (value:string|undefined, defaultValue:string):string => value===undefined ? defaultValue : (value==="" ? defaultValue : value);
|
||||
68
mailgun-webhook/src/lib/logger.ts
Normal file
68
mailgun-webhook/src/lib/logger.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import debug from 'debug';
|
||||
|
||||
/**
|
||||
* Creates a debug logger instance with nodemon compatibility.
|
||||
*
|
||||
* @param namespace - Logger namespace for filtering output
|
||||
* @returns Debug logger instance configured for the environment
|
||||
*/
|
||||
const createLogger = (namespace:string):debug.Debugger => {
|
||||
const dbg = debug(namespace);
|
||||
|
||||
const nodemonRegex = /nodemon/gi;
|
||||
|
||||
if(nodemonRegex.test(process.env?.npm_lifecycle_script ?? "")) {
|
||||
// When running via nodemon, force console output instead of stdout
|
||||
// This ensures logs are visible since nodemon doesn't handle stdout properly
|
||||
dbg.log = console.log.bind(console);
|
||||
}
|
||||
|
||||
return(dbg);
|
||||
};
|
||||
|
||||
const serverLogger = createLogger('server:server');
|
||||
const healthCheckLogger = createLogger('server:healthcheck');
|
||||
const errorLogger = createLogger('app:error');
|
||||
const warnLogger = createLogger('app:warn');
|
||||
const infoLogger = createLogger('app:info');
|
||||
|
||||
/**
|
||||
* Logs a server message with server icon prefix.
|
||||
* @param logTitle - The main server message
|
||||
* @param logData - Optional additional data to log
|
||||
* @returns
|
||||
*/
|
||||
export const logServer = (logTitle:string, logData:string|null=null) => serverLogger(`⚡️ SERVER: ${logTitle}`, logData);
|
||||
|
||||
|
||||
/**
|
||||
* Logs a health check message with health check icon prefix.
|
||||
* @param logTitle - The main health check message
|
||||
* @param logData - Optional additional data to log
|
||||
* @returns
|
||||
*/
|
||||
export const logHealthCheck = (logTitle:string, logData:string|null=null) => healthCheckLogger(`🩺 HEALTHCHECK: ${logTitle}`, logData);
|
||||
|
||||
/**
|
||||
* Logs an error message with error icon prefix.
|
||||
*
|
||||
* @param logTitle - The main error message
|
||||
* @param logData - Optional additional data to log
|
||||
*/
|
||||
export const logError = (logTitle:string, logData:string|null=null) => errorLogger(`❌ ERROR: ${logTitle}`, logData);
|
||||
|
||||
/**
|
||||
* Logs a warning message with warning icon prefix.
|
||||
*
|
||||
* @param logTitle - The main warning message
|
||||
* @param logData - Optional additional data to log
|
||||
*/
|
||||
export const logWarn = (logTitle:string, logData:string|null=null) => warnLogger(`⚠️ WARN: ${logTitle}`, logData);
|
||||
|
||||
/**
|
||||
* Logs an informational message with info icon prefix.
|
||||
*
|
||||
* @param logTitle - The main info message
|
||||
* @param logData - Optional additional data to log
|
||||
*/
|
||||
export const logInfo = (logTitle:string, logData:string|null=null) => infoLogger(`🛈 INFO: ${logTitle}`, logData);
|
||||
60
mailgun-webhook/src/lib/metricsCounters.ts
Normal file
60
mailgun-webhook/src/lib/metricsCounters.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Counter, Histogram, register } from 'prom-client';
|
||||
import { coalesce } from './initTools';
|
||||
const { name:packageName } = require('../../package.json');
|
||||
|
||||
/** Histogram Buckets */
|
||||
const PROMETHEUS_HISTOGRAM_BUCKETS = coalesce(process.env.PROMETHEUS_HISTOGRAM_BUCKETS, '0.1,0.5,1,5,10');
|
||||
|
||||
/** Label to identify metrics collected from this web service */
|
||||
const PROMETHEUS_APP_LABEL = coalesce(process.env.PROMETHEUS_APP_LABEL, packageName);
|
||||
|
||||
// We use "app" labels to separate results in Grafana
|
||||
register.setDefaultLabels({ app: PROMETHEUS_APP_LABEL });
|
||||
|
||||
/**
|
||||
* Counts total number of requests received for processing
|
||||
*/
|
||||
export const totalRequestCounter = new Counter({
|
||||
name: "request_operations_total",
|
||||
help: "total number of received requests",
|
||||
/** Separate counters by request type */
|
||||
labelNames: ['path'],
|
||||
});
|
||||
|
||||
/**
|
||||
* Counts requests that were successfully processed
|
||||
*/
|
||||
export const successfulRequestCounter = new Counter({
|
||||
name: "request_operations_ok",
|
||||
help: "number of successfully processed requests",
|
||||
/** Separate counters by request type */
|
||||
labelNames: ['path'],
|
||||
});
|
||||
|
||||
/**
|
||||
* Counts requests that encountered an error during processing
|
||||
*/
|
||||
export const failedRequestCounter = new Counter({
|
||||
name: "request_operations_failed",
|
||||
help: "number of requests that encountered an error during processing",
|
||||
/** Separate counters by request type and execution result */
|
||||
labelNames: ["path", "status"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Counts requests that were rejected (e.g. by validation)
|
||||
*/
|
||||
export const rejectedRequestCounter = new Counter({
|
||||
name: "request_operations_rejected",
|
||||
help: "number of requests that were rejected",
|
||||
labelNames: ["path", "status"],
|
||||
});
|
||||
|
||||
/** Histogram measures how long incoming request processing takes */
|
||||
export const requestDurationHistogram = new Histogram({
|
||||
name: "request_duration_seconds",
|
||||
help: "Request duration in seconds",
|
||||
/** Separate counters by request type and execution result */
|
||||
labelNames: ["path", "status"],
|
||||
buckets: PROMETHEUS_HISTOGRAM_BUCKETS?.split(',').map((el) => parseFloat(el))
|
||||
});
|
||||
43
mailgun-webhook/src/middleware/InitLocalsMiddleware.ts
Normal file
43
mailgun-webhook/src/middleware/InitLocalsMiddleware.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Response, Request, NextFunction } from 'express';
|
||||
import { AppLocals } from '../types/AppLocals';
|
||||
import { requestDurationHistogram, totalRequestCounter } from '../lib/metricsCounters';
|
||||
import { SupportedRoutes } from '../types/enums/SupportedRoutes';
|
||||
|
||||
/**
|
||||
* Middleware initializes infrastructure objects which will be used throughout the request lifecycle
|
||||
* @param req - Express request object
|
||||
* @param res - Express response object
|
||||
* @param next - Express next middleware function
|
||||
*/
|
||||
export const InitLocalsMiddleware = (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
switch(req.path) {
|
||||
// for metrics routes, no prometheus timer is needed
|
||||
case SupportedRoutes.metricsPath:
|
||||
case '/favicon.ico':
|
||||
// placeholder method to avoid checking if timer is initialized
|
||||
(res.locals as AppLocals).stopPrometheusTimer = (labels) => 0;
|
||||
break;
|
||||
// all other routes get prometheus metrics
|
||||
default:
|
||||
// The request must be processed even if Prometheus does not work
|
||||
// That's why here we wrap the Prometheus calls in try/catch
|
||||
try {
|
||||
// counting all received requests
|
||||
totalRequestCounter.inc({ path: req.path });
|
||||
|
||||
// starting a timer to measure request processing duration
|
||||
// this timer will be stopped in the route handler
|
||||
(res.locals as AppLocals).stopPrometheusTimer = requestDurationHistogram.startTimer();
|
||||
} catch(ex:any) {
|
||||
console.error(ex);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch(ex:any) {
|
||||
console.error('Error in InitLocalsMiddleware:', ex);
|
||||
next(ex);
|
||||
}
|
||||
}
|
||||
97
mailgun-webhook/src/routes/errorRouter.ts
Normal file
97
mailgun-webhook/src/routes/errorRouter.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { ErrorRequestHandler, Request, Response } from "express";
|
||||
import createHttpError, { HttpError } from "http-errors";
|
||||
import { logError, logWarn } from '../lib/logger';
|
||||
import { AppLocals } from "../types/AppLocals";
|
||||
import { failedRequestCounter, rejectedRequestCounter } from "../lib/metricsCounters";
|
||||
import { SupportedRoutes } from "../types/enums/SupportedRoutes";
|
||||
|
||||
/**
|
||||
* Error handler that processes and formats error responses.
|
||||
* Handles different error types, logs appropriately, and updates metrics.
|
||||
*
|
||||
* @param err - HTTP error object
|
||||
* @param req - Express request object
|
||||
* @param res - Express response object
|
||||
* @param next - Express next function
|
||||
*/
|
||||
export const errorRouter:ErrorRequestHandler = async (err:HttpError, req, res, next) => {
|
||||
|
||||
const requestPath = req.path as SupportedRoutes;
|
||||
|
||||
// Since this error handler is complex, it might throw an error somewhere
|
||||
// Wrap it in try-catch to ensure it won't crash the entire process
|
||||
try {
|
||||
let errorLogText:string = err.message,
|
||||
errorLogName:string = err.name
|
||||
|
||||
const responseStatus:number = err.status;
|
||||
|
||||
let responseBody:string = "",
|
||||
responseContentType = "text/html";
|
||||
|
||||
switch(err.status) {
|
||||
case 400:
|
||||
responseBody = 'bad request';
|
||||
break;
|
||||
case 401:
|
||||
responseBody = 'unauthorized';
|
||||
break;
|
||||
case 403:
|
||||
responseBody = 'forbidden';
|
||||
break;
|
||||
case 404:
|
||||
logWarn(`page not found ${req.method} ${requestPath}`)
|
||||
responseBody = 'page not found';
|
||||
errorLogText = `page ${requestPath} not found`;
|
||||
break;
|
||||
case 500:
|
||||
responseBody = "internal server error";
|
||||
errorLogText = err.message;
|
||||
break;
|
||||
default:
|
||||
responseBody = err.name;
|
||||
errorLogText = `err.status=${err.status};err.name=${err.name};err.message=${err.message}`;
|
||||
}
|
||||
|
||||
logWarn(`${errorLogName}:${errorLogText}`);
|
||||
|
||||
// `headersSent` will be TRUE if the router where the error occurred has already sent headers
|
||||
// If we try to set them again, it will throw an error - we can avoid that here
|
||||
if(!res.headersSent) {
|
||||
res.status(responseStatus);
|
||||
res.setHeader('Content-Type', responseContentType);
|
||||
res.end(responseBody);
|
||||
} else {
|
||||
// If `end` hasn't been called - call it to finish processing the request
|
||||
// Otherwise the connection will remain open until timeout
|
||||
if(!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
|
||||
} catch(ex:any) {
|
||||
// This error will be handled by `finalErrorRouter`
|
||||
next(createHttpError(500, ex));
|
||||
}
|
||||
|
||||
// Prevent prometheus client from crashing the server
|
||||
try {
|
||||
switch(err.status) {
|
||||
case 400:
|
||||
case 401:
|
||||
case 403:
|
||||
case 404:
|
||||
// Count rejected requests separately from errors
|
||||
rejectedRequestCounter.inc({ path: requestPath, status: err.status });
|
||||
break;
|
||||
case 500:
|
||||
default:
|
||||
failedRequestCounter.inc({ path: requestPath, status: err.status });
|
||||
break;
|
||||
}
|
||||
|
||||
(res.locals as AppLocals).stopPrometheusTimer({ path: req.path, status: err.status });
|
||||
} catch(ex:any) {
|
||||
logError(`Error while processing prometheus metrics: ${ex.message}`);
|
||||
}
|
||||
};
|
||||
33
mailgun-webhook/src/routes/finalErrorRouter.ts
Normal file
33
mailgun-webhook/src/routes/finalErrorRouter.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ErrorRequestHandler, Request, Response } from "express";
|
||||
import { HttpError } from "http-errors";
|
||||
import { logError } from '../lib/logger';
|
||||
|
||||
/**
|
||||
* Final error handler that executes when an unhandled error occurs.
|
||||
* This prevents the server from crashing and ensures proper response handling.
|
||||
*
|
||||
* @param err - HTTP error object
|
||||
* @param req - Express request object
|
||||
* @param res - Express response object
|
||||
* @param next - Express next function
|
||||
*/
|
||||
export const finalErrorRouter:ErrorRequestHandler = async (err:HttpError, req, res, next) => {
|
||||
|
||||
const errorLogText:string = JSON.stringify({ message:err.message, name:err.name, stack:err.stack });
|
||||
|
||||
logError("server error", `${err.status}; n${errorLogText}`);
|
||||
|
||||
// `headersSent` will be TRUE if the router where the error occurred has already sent headers
|
||||
// If we try to set them again, it will throw an error and CRASH THE SERVER - we must prevent this
|
||||
if(!res.headersSent) {
|
||||
res.status(err.status);
|
||||
res.setHeader('Content-Type', "text/html");
|
||||
res.end(`unhandled server error`);
|
||||
} else {
|
||||
// If `end` hasn't been called - call it to finish processing the request
|
||||
// Otherwise the connection will remain open until timeout
|
||||
if(!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
};
|
||||
19
mailgun-webhook/src/routes/metricsRouter.ts
Normal file
19
mailgun-webhook/src/routes/metricsRouter.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Router, NextFunction, Request, Response } from "express";
|
||||
import createError from 'http-errors';
|
||||
import { register } from 'prom-client';
|
||||
import { logServer } from '../lib/logger';
|
||||
|
||||
/** Express router for Prometheus metrics endpoint */
|
||||
export const metricsRouter = Router();
|
||||
|
||||
metricsRouter.get('/', async (req:Request, res:Response, next:NextFunction) => {
|
||||
// Prevent Prometheus client from crashing the server
|
||||
// This is not mission critical, so we can afford it not working but cannot allow it to crash the server
|
||||
try {
|
||||
logServer(`GET /metrics`);
|
||||
res.set('Content-Type', register.contentType);
|
||||
res.end(await register.metrics());
|
||||
} catch(ex:any) {
|
||||
next(createError(500, (ex as Error).message));
|
||||
}
|
||||
});
|
||||
18
mailgun-webhook/src/routes/pingRouter.ts
Normal file
18
mailgun-webhook/src/routes/pingRouter.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { RequestHandler, Router } from "express";
|
||||
|
||||
/**
|
||||
* Request handler that responds to health check requests.
|
||||
*
|
||||
* @param req - Express request object
|
||||
* @param res - Express response object
|
||||
* @param next - Express next function
|
||||
*/
|
||||
export const pingRequestHandler:RequestHandler = async (req, res, next) => {
|
||||
res.status(200);
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.end('PONG');
|
||||
};
|
||||
|
||||
/** Express router for ping/health check endpoint */
|
||||
export const pingRouter = Router();
|
||||
pingRouter.get('/', pingRequestHandler);
|
||||
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);
|
||||
9
mailgun-webhook/src/types/AppLocals.ts
Normal file
9
mailgun-webhook/src/types/AppLocals.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { LabelValues } from "prom-client";
|
||||
|
||||
export type PrometheusTimer = (labels?: LabelValues<"path"|"status">) => number;
|
||||
|
||||
/** Data assigned to `express.response.locals` */
|
||||
export type AppLocals = {
|
||||
/** Prometheus timer function for stopping request duration measurement */
|
||||
stopPrometheusTimer: PrometheusTimer,
|
||||
};
|
||||
165
mailgun-webhook/src/types/MailgunWebhookEvent.ts
Normal file
165
mailgun-webhook/src/types/MailgunWebhookEvent.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Type definitions for MailGun webhook events
|
||||
*/
|
||||
|
||||
/**
|
||||
* All possible MailGun webhook event types
|
||||
*/
|
||||
export type MailgunEventType =
|
||||
| 'delivered'
|
||||
| 'failed'
|
||||
| 'opened'
|
||||
| 'clicked'
|
||||
| 'bounced'
|
||||
| 'complained'
|
||||
| 'unsubscribed';
|
||||
|
||||
/**
|
||||
* Base interface for all MailGun webhook events
|
||||
* Contains fields common to all event types
|
||||
*/
|
||||
export interface MailgunWebhookEventBase {
|
||||
/** Type of event */
|
||||
event: MailgunEventType;
|
||||
|
||||
/** Unix timestamp when the event occurred */
|
||||
timestamp: string;
|
||||
|
||||
/** Randomly generated string for message signature verification */
|
||||
token: string;
|
||||
|
||||
/** String with hexadecimal digits for signature verification */
|
||||
signature: string;
|
||||
|
||||
/** Email address of the recipient */
|
||||
recipient: string;
|
||||
|
||||
/** Domain from which the email was sent */
|
||||
domain: string;
|
||||
|
||||
/** MailGun message ID */
|
||||
'message-id'?: string;
|
||||
|
||||
/** SMTP Message-ID header */
|
||||
'Message-Id'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional fields for 'delivered' events
|
||||
*/
|
||||
export interface MailgunDeliveredEvent extends MailgunWebhookEventBase {
|
||||
event: 'delivered';
|
||||
|
||||
/** JSON string of message headers */
|
||||
'message-headers'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional fields for 'failed' events
|
||||
*/
|
||||
export interface MailgunFailedEvent extends MailgunWebhookEventBase {
|
||||
event: 'failed';
|
||||
|
||||
/** Severity level (temporary/permanent) */
|
||||
severity?: string;
|
||||
|
||||
/** Reason for failure */
|
||||
reason?: string;
|
||||
|
||||
/** Detailed notification message */
|
||||
notification?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional fields for events with tracking data (opened, clicked)
|
||||
*/
|
||||
export interface MailgunTrackingData {
|
||||
/** City where email was opened */
|
||||
city?: string;
|
||||
|
||||
/** Country code */
|
||||
country?: string;
|
||||
|
||||
/** Device type (desktop/mobile/tablet) */
|
||||
'device-type'?: string;
|
||||
|
||||
/** Operating system */
|
||||
'client-os'?: string;
|
||||
|
||||
/** Email client name */
|
||||
'client-name'?: string;
|
||||
|
||||
/** IP address */
|
||||
ip?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional fields for 'opened' events
|
||||
*/
|
||||
export interface MailgunOpenedEvent extends MailgunWebhookEventBase, MailgunTrackingData {
|
||||
event: 'opened';
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional fields for 'clicked' events
|
||||
*/
|
||||
export interface MailgunClickedEvent extends MailgunWebhookEventBase, MailgunTrackingData {
|
||||
event: 'clicked';
|
||||
|
||||
/** URL that was clicked */
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional fields for 'bounced' events
|
||||
*/
|
||||
export interface MailgunBouncedEvent extends MailgunWebhookEventBase {
|
||||
event: 'bounced';
|
||||
|
||||
/** SMTP error code */
|
||||
code?: string;
|
||||
|
||||
/** Detailed error message */
|
||||
error?: string;
|
||||
|
||||
/** Bounce notification */
|
||||
notification?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional fields for 'complained' events
|
||||
*/
|
||||
export interface MailgunComplainedEvent extends MailgunWebhookEventBase {
|
||||
event: 'complained';
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional fields for 'unsubscribed' events
|
||||
*/
|
||||
export interface MailgunUnsubscribedEvent extends MailgunWebhookEventBase {
|
||||
event: 'unsubscribed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type of all possible MailGun webhook events
|
||||
*/
|
||||
export type MailgunWebhookEvent =
|
||||
| MailgunDeliveredEvent
|
||||
| MailgunFailedEvent
|
||||
| MailgunOpenedEvent
|
||||
| MailgunClickedEvent
|
||||
| MailgunBouncedEvent
|
||||
| MailgunComplainedEvent
|
||||
| MailgunUnsubscribedEvent;
|
||||
|
||||
/**
|
||||
* Type guard to check if event data has required fields
|
||||
*/
|
||||
export function isValidMailgunEvent(data: any): data is MailgunWebhookEventBase {
|
||||
return (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
typeof data.event === 'string' &&
|
||||
typeof data.recipient === 'string'
|
||||
);
|
||||
}
|
||||
8
mailgun-webhook/src/types/enums/SupportedRoutes.ts
Normal file
8
mailgun-webhook/src/types/enums/SupportedRoutes.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export enum SupportedRoutes {
|
||||
ping='/ping/',
|
||||
metricsPath='/metrics',
|
||||
/**
|
||||
* MailGun webhook endpoint for receiving email event notifications
|
||||
*/
|
||||
webhook='/webhook',
|
||||
}
|
||||
30
mailgun-webhook/src/types/environment.d.ts
vendored
Normal file
30
mailgun-webhook/src/types/environment.d.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
PORT?: string,
|
||||
/**
|
||||
* (optional) App label to be used in Prometheus (Grafana)
|
||||
* @default "evo-open-table-sync-svc"
|
||||
* */
|
||||
PROMETHEUS_APP_LABEL?: string
|
||||
/**
|
||||
* (optional) Prometheus histogram bucket sizes (grafana)
|
||||
* @default "0.1, 0.5, 1, 5, 10"
|
||||
* */
|
||||
PROMETHEUS_HISTOGRAM_BUCKETS?: string,
|
||||
/**
|
||||
* (optional) CORS settings: which domains are allowed to access resources
|
||||
* @summary If parameter is not set, origin checking will be disabled
|
||||
* */
|
||||
CORS_ALLOWED_ORIGINS?:string,
|
||||
/**
|
||||
* (optional) IP Address whitelist for prometheus metrics (if not set whitelisting will be disabled)
|
||||
* @summary Although this param is optional, it is recommended for security reasons
|
||||
* */
|
||||
METRICS_ALLOWED_IP_ADDRESSES?:string,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
1
mailgun-webhook/tests/__mocks__/crypto.ts
Normal file
1
mailgun-webhook/tests/__mocks__/crypto.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const randomUUID = () => 'mock-uuid-created-by-crypto';
|
||||
18
mailgun-webhook/tests/__mocks__/logger.ts
Normal file
18
mailgun-webhook/tests/__mocks__/logger.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Mock implementation of the logger module for testing
|
||||
*/
|
||||
|
||||
export const logServer = jest.fn();
|
||||
export const logHealthCheck = jest.fn();
|
||||
export const logError = jest.fn();
|
||||
export const logWarn = jest.fn();
|
||||
export const logInfo = jest.fn();
|
||||
|
||||
// Helper function to reset all mocks
|
||||
export const resetAllLoggerMocks = () => {
|
||||
logServer.mockClear();
|
||||
logHealthCheck.mockClear();
|
||||
logError.mockClear();
|
||||
logWarn.mockClear();
|
||||
logInfo.mockClear();
|
||||
};
|
||||
29
mailgun-webhook/tests/__mocks__/prom-client.ts
Normal file
29
mailgun-webhook/tests/__mocks__/prom-client.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { LabelValues } from "prom-client";
|
||||
|
||||
export class Counter {
|
||||
public inc() {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export class Histogram<T extends string> {
|
||||
startTimer(labels?: LabelValues<T>): (labels?: LabelValues<T>) => void {
|
||||
return((labels?: LabelValues<T>) => { });
|
||||
}
|
||||
}
|
||||
|
||||
class Register {
|
||||
public setDefaultLabels(labels: Object) {
|
||||
|
||||
}
|
||||
|
||||
public metrics(): Promise<string> {
|
||||
return(Promise.resolve(""));
|
||||
}
|
||||
|
||||
public get contentType() {
|
||||
return("");
|
||||
}
|
||||
}
|
||||
|
||||
export const register = new Register();
|
||||
59
mailgun-webhook/tests/helpers/mockHttpContext.ts
Normal file
59
mailgun-webhook/tests/helpers/mockHttpContext.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* This file is a part of an "auth" example router, which was taken from an existing integration.
|
||||
* It is to be used only as a reference for how an API router for a web service should be structured.
|
||||
* In a real-live implementation all files related to `/auth` route example should be removed
|
||||
* by new set of files implementing the new API.
|
||||
*/
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { AppLocals } from "../../src/types/AppLocals";
|
||||
|
||||
interface IMockHttpParams {
|
||||
sessionID?: number
|
||||
sessionGUID?: string
|
||||
table_id?: string
|
||||
clientIp?: string
|
||||
free_play: 0 | 1
|
||||
}
|
||||
|
||||
interface IMockHttpContext {
|
||||
clientIpAddress?:string
|
||||
reqPath?:string
|
||||
headersSent?:boolean
|
||||
writableEnded?:boolean
|
||||
method?:string
|
||||
params?: IMockHttpParams
|
||||
}
|
||||
|
||||
export const defaultMockParams:IMockHttpParams = {
|
||||
sessionID: 123,
|
||||
sessionGUID: '016e6812-b915-4e5e-94fe-193582239b96',
|
||||
table_id: 'mock-table-id',
|
||||
clientIp: '192.168.1.10',
|
||||
free_play: 0
|
||||
}
|
||||
|
||||
export const mockHttpContext = ({reqPath="/", headersSent=false, writableEnded=false, method="GET", params=defaultMockParams}:IMockHttpContext|undefined = {}) => {
|
||||
const req = {
|
||||
path:reqPath,
|
||||
method,
|
||||
url:`https://localhost${reqPath}`,
|
||||
params,
|
||||
} as unknown as Request;
|
||||
|
||||
const res = {
|
||||
end: jest.fn(),
|
||||
status: jest.fn(),
|
||||
setHeader: jest.fn(),
|
||||
params,
|
||||
locals: {
|
||||
stopPrometheusTimer: jest.fn(),
|
||||
} as unknown as AppLocals,
|
||||
headersSent,
|
||||
writableEnded,
|
||||
} as unknown as Response;
|
||||
|
||||
const next:NextFunction = jest.fn();
|
||||
|
||||
return({req,res,next})
|
||||
}
|
||||
115
mailgun-webhook/tests/helpers/mockWebhookEvent.ts
Normal file
115
mailgun-webhook/tests/helpers/mockWebhookEvent.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Helper for creating mock MailGun webhook events for testing
|
||||
*/
|
||||
|
||||
import {
|
||||
MailgunWebhookEvent,
|
||||
MailgunDeliveredEvent,
|
||||
MailgunFailedEvent,
|
||||
MailgunOpenedEvent,
|
||||
MailgunClickedEvent,
|
||||
MailgunBouncedEvent,
|
||||
MailgunComplainedEvent,
|
||||
MailgunUnsubscribedEvent
|
||||
} from '../../src/types/MailgunWebhookEvent';
|
||||
|
||||
/**
|
||||
* Base event data common to all webhook events
|
||||
*/
|
||||
const baseEventData = {
|
||||
timestamp: '1234567890',
|
||||
token: 'mock-token-123',
|
||||
signature: 'mock-signature-abc123',
|
||||
recipient: 'user@example.com',
|
||||
domain: 'mail.example.com',
|
||||
'message-id': '<20240101120000.1.ABC123@mail.example.com>',
|
||||
'Message-Id': '<20240101120000.1.ABC123@mail.example.com>'
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a mock 'delivered' event
|
||||
*/
|
||||
export const createMockDeliveredEvent = (): MailgunDeliveredEvent => ({
|
||||
...baseEventData,
|
||||
event: 'delivered',
|
||||
'message-headers': JSON.stringify([
|
||||
['Subject', 'Test Email'],
|
||||
['From', 'sender@example.com']
|
||||
])
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a mock 'failed' event
|
||||
*/
|
||||
export const createMockFailedEvent = (): MailgunFailedEvent => ({
|
||||
...baseEventData,
|
||||
event: 'failed',
|
||||
severity: 'permanent',
|
||||
reason: 'bounce',
|
||||
notification: 'User inbox is full'
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a mock 'opened' event
|
||||
*/
|
||||
export const createMockOpenedEvent = (): MailgunOpenedEvent => ({
|
||||
...baseEventData,
|
||||
event: 'opened',
|
||||
city: 'San Francisco',
|
||||
country: 'US',
|
||||
'device-type': 'desktop',
|
||||
'client-os': 'macOS',
|
||||
'client-name': 'Apple Mail',
|
||||
ip: '192.168.1.100'
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a mock 'clicked' event
|
||||
*/
|
||||
export const createMockClickedEvent = (): MailgunClickedEvent => ({
|
||||
...baseEventData,
|
||||
event: 'clicked',
|
||||
url: 'https://example.com/link',
|
||||
city: 'New York',
|
||||
country: 'US',
|
||||
'device-type': 'mobile',
|
||||
'client-os': 'iOS',
|
||||
'client-name': 'Gmail',
|
||||
ip: '192.168.1.101'
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a mock 'bounced' event
|
||||
*/
|
||||
export const createMockBouncedEvent = (): MailgunBouncedEvent => ({
|
||||
...baseEventData,
|
||||
event: 'bounced',
|
||||
code: '550',
|
||||
error: 'User not found',
|
||||
notification: 'The email account that you tried to reach does not exist'
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a mock 'complained' event
|
||||
*/
|
||||
export const createMockComplainedEvent = (): MailgunComplainedEvent => ({
|
||||
...baseEventData,
|
||||
event: 'complained'
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a mock 'unsubscribed' event
|
||||
*/
|
||||
export const createMockUnsubscribedEvent = (): MailgunUnsubscribedEvent => ({
|
||||
...baseEventData,
|
||||
event: 'unsubscribed'
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates an invalid event missing required fields
|
||||
*/
|
||||
export const createInvalidEvent = () => ({
|
||||
timestamp: '1234567890',
|
||||
token: 'mock-token-123'
|
||||
// Missing 'event' and 'recipient' fields
|
||||
});
|
||||
120
mailgun-webhook/tests/routers/errorRouter.spec.ts
Normal file
120
mailgun-webhook/tests/routers/errorRouter.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { errorRouter } from '../../src/routes/errorRouter';
|
||||
import createError from "http-errors";
|
||||
import { mockHttpContext } from "../helpers/mockHttpContext";
|
||||
import { logWarn, resetAllLoggerMocks } from '../__mocks__/logger';
|
||||
|
||||
// Mock the logger module
|
||||
jest.mock('../../src/lib/logger', () => require('../__mocks__/logger'));
|
||||
|
||||
describe("errorRouter", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllLoggerMocks();
|
||||
});
|
||||
|
||||
test("should return string message 'page not found' in case of 404 error", async () => {
|
||||
const err = createError(404)
|
||||
const {req,res,next} = mockHttpContext();
|
||||
|
||||
await errorRouter(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
|
||||
expect(res.end).toHaveBeenCalledWith("page not found");
|
||||
});
|
||||
|
||||
test("should log page not found warning in case of 404 error", async () => {
|
||||
const err = createError(404)
|
||||
const reqPath = "/some-path/";
|
||||
const {req,res,next} = mockHttpContext({ reqPath });
|
||||
|
||||
await errorRouter(err, req, res, next);
|
||||
|
||||
expect(logWarn).toHaveBeenCalledWith(`page not found GET ${reqPath}`);
|
||||
expect(logWarn).toHaveBeenCalledWith(`${err.name}:page ${reqPath} not found`);
|
||||
});
|
||||
|
||||
test("should not send headers again if they are already sent", async () => {
|
||||
const err = createError(404)
|
||||
const {req,res,next} = mockHttpContext({ headersSent:true, writableEnded:true });
|
||||
|
||||
await errorRouter(err, req, res, next);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(res.setHeader).not.toHaveBeenCalled();
|
||||
expect(res.end).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should call [end] method if it has NOT been called yet", async () => {
|
||||
const err = createError(404)
|
||||
const {req,res,next} = mockHttpContext({ headersSent:true, writableEnded:false });
|
||||
|
||||
await errorRouter(err, req, res, next);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(res.setHeader).not.toHaveBeenCalled();
|
||||
expect(res.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should stop Prometheus Timer", async () => {
|
||||
const err = createError(404)
|
||||
const {req,res,next} = mockHttpContext({ headersSent:true, writableEnded:false });
|
||||
|
||||
await errorRouter(err, req, res, next);
|
||||
|
||||
expect(res.locals.stopPrometheusTimer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return string message 'internal server error' in case of 500 error", async () => {
|
||||
const err = createError(500)
|
||||
const {req,res,next} = mockHttpContext();
|
||||
|
||||
await errorRouter(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
|
||||
expect(res.end).toHaveBeenCalledWith("internal server error");
|
||||
});
|
||||
|
||||
test("should return string message 'bad request' and log error in case of 400 error", async () => {
|
||||
const errorMessage = "mock error text 1";
|
||||
const err = createError(400, errorMessage);
|
||||
const {req,res,next} = mockHttpContext();
|
||||
|
||||
await errorRouter(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
|
||||
expect(res.end).toHaveBeenCalledWith("bad request");
|
||||
|
||||
expect(logWarn).toHaveBeenCalledWith(`${err.name}:${errorMessage}`);
|
||||
});
|
||||
|
||||
test("should return string message 'unauthorized' and log error in case of 401 error", async () => {
|
||||
const errorMessage = "mock error text 2";
|
||||
const err = createError(401, errorMessage)
|
||||
const {req,res,next} = mockHttpContext();
|
||||
|
||||
await errorRouter(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
|
||||
expect(res.end).toHaveBeenCalledWith("unauthorized");
|
||||
|
||||
expect(logWarn).toHaveBeenCalledWith(`${err.name}:${errorMessage}`);
|
||||
});
|
||||
|
||||
test("should return string message 'forbidden' and log error in case of 403 error", async () => {
|
||||
const errorMessage = "mock error text 3";
|
||||
const err = createError(403, errorMessage);
|
||||
const {req,res,next} = mockHttpContext();
|
||||
|
||||
await errorRouter(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
|
||||
expect(res.end).toHaveBeenCalledWith("forbidden");
|
||||
|
||||
expect(logWarn).toHaveBeenCalledWith(`${err.name}:${errorMessage}`);
|
||||
});
|
||||
});
|
||||
196
mailgun-webhook/tests/routers/webhookRouter.spec.ts
Normal file
196
mailgun-webhook/tests/routers/webhookRouter.spec.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Tests for MailGun webhook router
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import app from '../../src/app';
|
||||
import {
|
||||
createMockDeliveredEvent,
|
||||
createMockFailedEvent,
|
||||
createMockOpenedEvent,
|
||||
createMockClickedEvent,
|
||||
createMockBouncedEvent,
|
||||
createMockComplainedEvent,
|
||||
createMockUnsubscribedEvent,
|
||||
createInvalidEvent
|
||||
} from '../helpers/mockWebhookEvent';
|
||||
|
||||
describe('MailGun Webhook Router', () => {
|
||||
// Mock console.log to avoid cluttering test output
|
||||
let consoleLogSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('POST /webhook', () => {
|
||||
it('should handle delivered event successfully', async () => {
|
||||
const event = createMockDeliveredEvent();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/webhook')
|
||||
.send(event)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
status: 'received',
|
||||
message: 'Webhook event logged successfully'
|
||||
});
|
||||
|
||||
// Verify console.log was called with event data
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
const logOutput = consoleLogSpy.mock.calls.join('\n');
|
||||
expect(logOutput).toContain('MailGun Webhook Event Received');
|
||||
expect(logOutput).toContain('Event Type: delivered');
|
||||
expect(logOutput).toContain('Recipient: user@example.com');
|
||||
});
|
||||
|
||||
it('should handle failed event successfully', async () => {
|
||||
const event = createMockFailedEvent();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/webhook')
|
||||
.send(event)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
status: 'received',
|
||||
message: 'Webhook event logged successfully'
|
||||
});
|
||||
|
||||
const logOutput = consoleLogSpy.mock.calls.join('\n');
|
||||
expect(logOutput).toContain('Event Type: failed');
|
||||
expect(logOutput).toContain('Severity: permanent');
|
||||
expect(logOutput).toContain('Reason: bounce');
|
||||
});
|
||||
|
||||
it('should handle opened event successfully', async () => {
|
||||
const event = createMockOpenedEvent();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/webhook')
|
||||
.send(event)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe('received');
|
||||
|
||||
const logOutput = consoleLogSpy.mock.calls.join('\n');
|
||||
expect(logOutput).toContain('Event Type: opened');
|
||||
expect(logOutput).toContain('City: San Francisco');
|
||||
expect(logOutput).toContain('Device Type: desktop');
|
||||
});
|
||||
|
||||
it('should handle clicked event successfully', async () => {
|
||||
const event = createMockClickedEvent();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/webhook')
|
||||
.send(event)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe('received');
|
||||
|
||||
const logOutput = consoleLogSpy.mock.calls.join('\n');
|
||||
expect(logOutput).toContain('Event Type: clicked');
|
||||
expect(logOutput).toContain('URL Clicked: https://example.com/link');
|
||||
});
|
||||
|
||||
it('should handle bounced event successfully', async () => {
|
||||
const event = createMockBouncedEvent();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/webhook')
|
||||
.send(event)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe('received');
|
||||
|
||||
const logOutput = consoleLogSpy.mock.calls.join('\n');
|
||||
expect(logOutput).toContain('Event Type: bounced');
|
||||
expect(logOutput).toContain('SMTP Code: 550');
|
||||
});
|
||||
|
||||
it('should handle complained event successfully', async () => {
|
||||
const event = createMockComplainedEvent();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/webhook')
|
||||
.send(event)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe('received');
|
||||
|
||||
const logOutput = consoleLogSpy.mock.calls.join('\n');
|
||||
expect(logOutput).toContain('Event Type: complained');
|
||||
});
|
||||
|
||||
it('should handle unsubscribed event successfully', async () => {
|
||||
const event = createMockUnsubscribedEvent();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/webhook')
|
||||
.send(event)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe('received');
|
||||
|
||||
const logOutput = consoleLogSpy.mock.calls.join('\n');
|
||||
expect(logOutput).toContain('Event Type: unsubscribed');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid event missing required fields', async () => {
|
||||
const event = createInvalidEvent();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/webhook')
|
||||
.send(event)
|
||||
.expect(400);
|
||||
|
||||
// Error router returns plain text, not JSON
|
||||
expect(response.text).toBe('bad request');
|
||||
});
|
||||
|
||||
it('should handle URL-encoded form data (MailGun default format)', async () => {
|
||||
const event = createMockDeliveredEvent();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/webhook')
|
||||
.type('form')
|
||||
.send(event)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.status).toBe('received');
|
||||
});
|
||||
|
||||
it('should log timestamp in human-readable format', async () => {
|
||||
const event = createMockDeliveredEvent();
|
||||
|
||||
await request(app)
|
||||
.post('/webhook')
|
||||
.send(event)
|
||||
.expect(200);
|
||||
|
||||
const logOutput = consoleLogSpy.mock.calls.join('\n');
|
||||
expect(logOutput).toContain('Timestamp: 1234567890');
|
||||
// Check that ISO format date is logged
|
||||
expect(logOutput).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
it('should log full event data as JSON', async () => {
|
||||
const event = createMockDeliveredEvent();
|
||||
|
||||
await request(app)
|
||||
.post('/webhook')
|
||||
.send(event)
|
||||
.expect(200);
|
||||
|
||||
const logOutput = consoleLogSpy.mock.calls.join('\n');
|
||||
expect(logOutput).toContain('Full Event Data (JSON)');
|
||||
expect(logOutput).toContain('"event": "delivered"');
|
||||
});
|
||||
});
|
||||
});
|
||||
34
mailgun-webhook/tsconfig.json
Normal file
34
mailgun-webhook/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020", // https://github.com/microsoft/TypeScript/wiki/Node-Target-Mapping
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true, // solves the problem regarding the default importa vs importa *
|
||||
"strict": true,
|
||||
"sourceMap": true, // please do create source maps
|
||||
"skipLibCheck": true, // don't verify typescript of 3rd party modules
|
||||
"rootDir": "src", // root directory under which source files are located - it's subtree will be mirrored in "outDir"
|
||||
"outDir": "build", // where the build files should be stored
|
||||
// "baseUrl" ----- DO NOT USE ... heres why:
|
||||
// NOTE: if "baseUrl" is set then Intellisense while doing autocompletion (Ctrl+Space)
|
||||
// will use and insert absolute module path instead of relative one,
|
||||
// which will make the build fail
|
||||
// "baseUrl": "./", // set a base directory to resolve non-absolute module names - This must be specified if "paths" is used
|
||||
"plugins": [
|
||||
{
|
||||
// The following is used for when building the project
|
||||
// NOTE: build is done by `ttypescript`
|
||||
// which does not know how to interpret what is set in "paths"
|
||||
// > this problem is fixed by "typescript-transform-paths"
|
||||
"transform": "typescript-transform-paths"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": ["src/**/*"], // location of files which need to be compiled
|
||||
// The following is used for debugging the server in VS Code
|
||||
// NOTE: when debugging the module is started using `ts-node`,
|
||||
// which does not know how to interpret what is set in "paths"
|
||||
// > this is fixed by "tsconfig-paths/register"
|
||||
"ts-node": {
|
||||
"require": ["tsconfig-paths/register"]
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user