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:
Knee Cola
2026-01-02 20:56:22 +01:00
parent 4371a9a20a
commit 7aeea9353d
39 changed files with 12015 additions and 14 deletions

View 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
View 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"

View 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
View File

@@ -0,0 +1,3 @@
node_modules
build
coverage/

19
mailgun-webhook/.mcp.json Normal file
View 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
View 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
View File

@@ -0,0 +1,9 @@
{
"jest.jestCommandLine": "npm run test --",
"jest.autoRun": {
"watch": false,
"onSave": "test-file"
},
"jest.nodeEnv": {
}
}

View 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"]

View File

@@ -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
View 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"

View 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: &lt;20240101120000.1.ABC123@mail.example.com&gt;
----------------------------------------
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

View 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

File diff suppressed because it is too large Load Diff

View 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"
]
}
}

View 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
View 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();
});
};

View 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();

View 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);
};

View 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);

View 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);

View 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))
});

View 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);
}
}

View 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}`);
}
};

View 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();
}
}
};

View 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));
}
});

View 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);

View 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);

View 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,
};

View 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'
);
}

View File

@@ -0,0 +1,8 @@
export enum SupportedRoutes {
ping='/ping/',
metricsPath='/metrics',
/**
* MailGun webhook endpoint for receiving email event notifications
*/
webhook='/webhook',
}

View 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 {}

View File

@@ -0,0 +1 @@
export const randomUUID = () => 'mock-uuid-created-by-crypto';

View 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();
};

View 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();

View 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})
}

View 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
});

View 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}`);
});
});

View 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"');
});
});
});

View 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"]
},
}