Compare commits
66 Commits
0b8c8ae6c4
...
feature/em
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a3a02bd6d | ||
|
|
3d02654510 | ||
|
|
0faac8e392 | ||
|
|
f9f33a2b45 | ||
|
|
ccc690c369 | ||
| d2725261d5 | |||
| f169e2c4ba | |||
| c72a06e34e | |||
| 9d6507c3ae | |||
| 6cf9b312c0 | |||
| 45d5507bf9 | |||
|
|
d081386b9f | ||
|
|
5dc78cfcaf | ||
|
|
17fd9ea54c | ||
|
|
fb35e0278e | ||
|
|
0556ad2533 | ||
|
|
7aeea9353d | ||
|
|
4371a9a20a | ||
|
|
997f4acf57 | ||
|
|
494d358130 | ||
|
|
1e83172491 | ||
|
|
d7abd99448 | ||
|
|
7bf7f9580f | ||
|
|
5feab991ec | ||
|
|
08a9215128 | ||
|
|
532ad4c0e0 | ||
|
|
69f891210e | ||
|
|
2bc5cad82d | ||
|
|
7e4ea26a7c | ||
|
|
b44d5afca6 | ||
|
|
2c4e0ee5c0 | ||
|
|
554dd8617f | ||
|
|
7e7eb5a2d8 | ||
|
|
580951b9c6 | ||
|
|
bb66ebe3b1 | ||
|
|
a7d13ba6dc | ||
|
|
c7e81a27ee | ||
|
|
767dda6355 | ||
|
|
3c34627e7e | ||
|
|
4bac7f4677 | ||
|
|
625e468951 | ||
|
|
50c8d230f7 | ||
|
|
5a7fb35bd8 | ||
|
|
35e20c8195 | ||
|
|
3db0348b8d | ||
|
|
5f99ba26c4 | ||
|
|
742521ef4a | ||
|
|
1ed82898c6 | ||
|
|
7b4e1b2710 | ||
|
|
1c98b3b2e6 | ||
|
|
63b575e07a | ||
|
|
3e769d30f9 | ||
|
|
2e08289e47 | ||
|
|
aa6ae91db8 | ||
|
|
e26a478577 | ||
|
|
2b6999d728 | ||
|
|
6a1a5e0dca | ||
|
|
a901980a6f | ||
|
|
33ab06e22e | ||
|
|
4a6896c910 | ||
|
|
f8291f9f7b | ||
|
|
e9012ed231 | ||
|
|
4906cc1990 | ||
|
|
3e4d8fb95c | ||
|
|
9d6ad17452 | ||
|
|
25c2f09eef |
@@ -24,7 +24,12 @@
|
||||
"Bash(git mv:*)",
|
||||
"Bash(rmdir:*)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(git diff:*)"
|
||||
"Bash(git diff:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(for file in CommError HTTPResponseError JsonParsingError clientIpAddress)",
|
||||
"Bash(do echo \"=== $file ===\")",
|
||||
"Bash(done)",
|
||||
"Bash(git restore:*)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -52,3 +52,5 @@ mongo-backup/
|
||||
.serena/
|
||||
*.serena-memory
|
||||
mongo-backup/
|
||||
|
||||
.git/
|
||||
133
CLAUDE.md
133
CLAUDE.md
@@ -8,6 +8,7 @@ This is a multi-project repository containing:
|
||||
- **web-app/**: Next.js 14 utility bills tracking application
|
||||
- **docker-stack/**: Docker Compose configurations and deployment scripts
|
||||
- **housekeeping/**: Database backup and maintenance scripts
|
||||
- **email-worker/**: Background worker service with HTTP health monitoring
|
||||
|
||||
Each project is self-contained with its own dependencies.
|
||||
|
||||
@@ -29,6 +30,13 @@ All commands should be run from within the respective project directory.
|
||||
- `./db-dump--standalone.sh` - Run standalone database dump
|
||||
- See housekeeping/README.md for more details
|
||||
|
||||
**Email Server Worker** (`cd email-worker`):
|
||||
- `npm install` - Install dependencies
|
||||
- `npm run start` - Start development server with nodemon
|
||||
- `npm run build` - Build TypeScript to JavaScript
|
||||
- `npm run test` - Run tests with Jest in watch mode
|
||||
- `npm run run-server` - Run built server from ./build directory
|
||||
|
||||
## Deployment Commands
|
||||
|
||||
**Building Docker Image** (`cd web-app`):
|
||||
@@ -91,4 +99,127 @@ export const actionName = withUser(async (user: AuthenticatedUser, ...args) => {
|
||||
|
||||
### Testing & Code Quality
|
||||
- ESLint with Next.js and Prettier configurations
|
||||
- No specific test framework configured - check with user before assuming testing approach
|
||||
- No specific test framework configured - check with user before assuming testing approach
|
||||
|
||||
## Email Server Worker Architecture
|
||||
|
||||
The email-worker is a TypeScript-based background worker service that combines periodic task execution with HTTP health monitoring and metrics collection.
|
||||
|
||||
### Tech Stack
|
||||
- **Runtime**: Node.js with TypeScript
|
||||
- **Framework**: Express for HTTP endpoints
|
||||
- **Metrics**: Prometheus (prom-client) with custom PRTG adapter
|
||||
- **Testing**: Jest with TypeScript support
|
||||
|
||||
### Core Architecture: Worker Pattern
|
||||
|
||||
The service implements a **self-contained worker pattern** that runs periodic background tasks while exposing HTTP endpoints for monitoring.
|
||||
|
||||
**Entry Point** (`email-worker/src/entry.ts:1`):
|
||||
- Creates Express HTTP server with graceful shutdown support (stoppable)
|
||||
- Starts the worker via `startSyncWorker()` from `email-worker/src/workRunner.ts:134`
|
||||
- Handles SIGTERM/SIGINT for graceful shutdown (Docker-compatible)
|
||||
- Calls `disposeSyncWorker()` on shutdown to allow pending work to complete
|
||||
|
||||
**Work Runner** (`email-worker/src/workRunner.ts:1`):
|
||||
The work runner implements a self-scheduling loop with the following characteristics:
|
||||
|
||||
- **Self-Scheduling Loop**: After completing work, schedules next execution via `setTimeout(workRunner, PULL_INTERVAL)` at `email-worker/src/workRunner.ts:113`
|
||||
- **Graceful Shutdown**: Tracks pending work via Promise, allows in-flight operations to complete before shutdown
|
||||
- **Status Tracking**: Exports `workerRunnerInfo` with `status` and `lastWorkTime` for health monitoring
|
||||
- **Error Isolation**: Worker errors don't crash the process - caught, logged, and execution continues
|
||||
- **Metrics Integration**: Automatic Prometheus metrics collection (duration, success/failure counters)
|
||||
- **Single Work Instance**: Ensures only one work cycle runs at a time via `pendingWork` Promise
|
||||
|
||||
Work Runner States (WorkerRunnerStatus enum):
|
||||
- `init` - Initial state before first run
|
||||
- `beginWork` - Work cycle started
|
||||
- `workDone` - Work completed successfully
|
||||
- `disposed` - Worker stopped, no longer scheduling
|
||||
- Other states track Prometheus stats updates
|
||||
|
||||
**Worker Implementation Pattern**:
|
||||
Workers must export a `doWork` function with signature:
|
||||
```typescript
|
||||
export const doWork = async () => {
|
||||
// Perform periodic work here
|
||||
// Throw errors to increment failedRequestCounter
|
||||
// Return normally to increment successfulRequestCounter
|
||||
};
|
||||
```
|
||||
|
||||
The work runner imports and calls this function at `email-worker/src/workRunner.ts:88`.
|
||||
|
||||
### Key Files & Responsibilities
|
||||
|
||||
**Core Worker Files**:
|
||||
- `email-worker/src/entry.ts` - HTTP server setup, signal handling, worker lifecycle management
|
||||
- `email-worker/src/workRunner.ts` - Self-scheduling loop, graceful shutdown, metrics integration
|
||||
- `email-worker/src/app.ts` - Express app configuration, route registration
|
||||
- `email-worker/src/lib/logger.ts` - Debug logger factory (uses 'debug' package)
|
||||
|
||||
**HTTP Routes** (`email-worker/src/routes/`):
|
||||
- `healthcheckRouter.ts` - Health check endpoint (checks worker status via `workerRunnerInfo`)
|
||||
- `metricsRouter.ts` - Prometheus metrics endpoint
|
||||
- `prtgMetricsRouter.ts` - PRTG-compatible metrics adapter
|
||||
- `pingRouter.ts` - Simple ping/pong endpoint
|
||||
- `errorRouter.ts` - Structured error handler for expected errors
|
||||
- `finalErrorRouter.ts` - Catch-all error handler for unexpected errors
|
||||
|
||||
**Infrastructure**:
|
||||
- `email-worker/src/lib/metricsCounters.ts` - Prometheus counter/histogram definitions
|
||||
- `email-worker/src/lib/initTools.ts` - Utility functions (coalesce, etc.)
|
||||
- `email-worker/src/lib/serializeError.ts` - Error serialization for logging
|
||||
- `email-worker/src/lib/Prometheus2Prtg.ts` - Converts Prometheus metrics to PRTG XML format
|
||||
|
||||
### Environment Variables
|
||||
|
||||
**Required**:
|
||||
- `PULL_INTERVAL` - Milliseconds between work cycles (default: "10000")
|
||||
|
||||
**Optional**:
|
||||
- `PORT` - HTTP server port (default: "3000")
|
||||
- `PROMETHEUS_APP_LABEL` - App label for Prometheus metrics (default: "evo-open-table-sync-svc")
|
||||
- `PROMETHEUS_HISTOGRAM_BUCKETS` - Histogram bucket sizes (default: "0.1, 0.5, 1, 5, 10")
|
||||
- `DEBUG` - Debug namespaces for console logging (e.g., "server:server")
|
||||
- `ENV` - Environment mode: "dev", "jest" (affects logging behavior)
|
||||
|
||||
### Creating a New Worker
|
||||
|
||||
To implement a new worker task:
|
||||
|
||||
1. **Create worker file** (e.g., `email-worker/src/myWorker.ts`):
|
||||
```typescript
|
||||
export const doWork = async () => {
|
||||
// Implement your periodic task here
|
||||
logger.info("Work Title", "Work completed successfully");
|
||||
|
||||
// Throw errors to mark as failed:
|
||||
// throw new Error("Something went wrong");
|
||||
};
|
||||
```
|
||||
|
||||
2. **Update `workRunner.ts`** import at line 6:
|
||||
```typescript
|
||||
import { doWork } from "./myWorker";
|
||||
```
|
||||
|
||||
3. **Add environment variables** to `email-worker/src/types/environment.d.ts` as needed
|
||||
|
||||
4. **Update `package.json` metadata** if the service purpose changes (name, description)
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
- Uses `stoppable` library for graceful shutdown (10-second timeout before force-close)
|
||||
- Health check endpoint at `/healthcheck` verifies worker is running and not stalled
|
||||
- Prometheus metrics at `/metrics` for monitoring
|
||||
- PRTG-compatible metrics at `/prtg` for legacy monitoring systems
|
||||
- Graceful shutdown ensures work in progress completes before container stops
|
||||
|
||||
### Testing
|
||||
|
||||
- **Framework**: Jest with esbuild-jest for TypeScript
|
||||
- **Test Location**: `email-worker/tests/`
|
||||
- **Mocks**: Common mocks in `email-worker/tests/__mocks__/` (prom-client)
|
||||
- **Test Pattern**: Co-located with source in `tests/` mirroring `src/` structure
|
||||
- **Run Tests**: `npm run test` (watch mode)
|
||||
36
ci-build.sh
Executable file
36
ci-build.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
|
||||
# CI/CD script to build and push Docker images for workspace projects
|
||||
# Uses version from package.json and automatically pushes to registry
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# List of workspaces to build
|
||||
WORKSPACES=(
|
||||
"mailgun-webhook"
|
||||
)
|
||||
|
||||
printf "\n=== CI/CD Docker Image Build ===\n"
|
||||
printf "Building %d workspace(s)\n\n" "${#WORKSPACES[@]}"
|
||||
|
||||
for WORKSPACE_DIR in "${WORKSPACES[@]}"; do
|
||||
printf "\n--- Building workspace: %s ---\n\n" "$WORKSPACE_DIR"
|
||||
|
||||
if [ ! -d "$WORKSPACE_DIR" ]; then
|
||||
printf "\nERROR: Directory '%s' does not exist. Skipping.\n\n" "$WORKSPACE_DIR"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ ! -f "$WORKSPACE_DIR/build-image.sh" ]; then
|
||||
printf "\nERROR: build-image.sh not found in '%s'. Skipping.\n\n" "$WORKSPACE_DIR"
|
||||
continue
|
||||
fi
|
||||
|
||||
cd "$WORKSPACE_DIR"
|
||||
./build-image.sh --auto-version --auto-push
|
||||
cd ..
|
||||
|
||||
printf "\n--- Completed: %s ---\n" "$WORKSPACE_DIR"
|
||||
done
|
||||
|
||||
printf "\n=== All builds completed successfully! ===\n\n"
|
||||
@@ -79,3 +79,22 @@ services:
|
||||
- traefik.http.routers.mongo-express.entrypoints=http
|
||||
- traefik.http.routers.mongo-express.rule=Host(`mongo.rezije.app`)
|
||||
|
||||
mailgun-webhook:
|
||||
image: registry.budakova.org/mailgun-webhook-service:${MAILGUN_WEBHOOK_VERSION:-latest}
|
||||
networks:
|
||||
- traefik-network
|
||||
environment:
|
||||
PORT: 3000
|
||||
PROMETHEUS_APP_LABEL: mailgun-webhook-service
|
||||
PROMETHEUS_HISTOGRAM_BUCKETS: 0.1,0.5,1,5,10
|
||||
DEBUG: server:*,app:*
|
||||
MAILGUN_WEBHOOK_SIGNING_KEY: ${MAILGUN_WEBHOOK_SIGNING_KEY}
|
||||
container_name: evidencija-rezija__mailgun-webhook
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik-network
|
||||
- traefik.http.services.mailgun-webhook.loadbalancer.server.port=3000
|
||||
- traefik.http.routers.mailgun-webhook.entrypoints=http
|
||||
- traefik.http.routers.mailgun-webhook.rule=Host(`mailgun-webhook.rezije.app`)
|
||||
|
||||
|
||||
@@ -79,3 +79,25 @@ services:
|
||||
- traefik.http.routers.mongo-express.entrypoints=http
|
||||
- traefik.http.routers.mongo-express.rule=Host(`mongo.rezije.app`)
|
||||
|
||||
mailgun-webhook:
|
||||
image: registry.budakova.org/mailgun-webhook-service:${MAILGUN_WEBHOOK_VERSION:-latest}
|
||||
networks:
|
||||
- traefik-network
|
||||
environment:
|
||||
PORT: 3000
|
||||
PROMETHEUS_APP_LABEL: mailgun-webhook-service
|
||||
PROMETHEUS_HISTOGRAM_BUCKETS: 0.1,0.5,1,5,10
|
||||
DEBUG: server:*,app:*
|
||||
MAILGUN_WEBHOOK_SIGNING_KEY: ${MAILGUN_WEBHOOK_SIGNING_KEY}
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: any
|
||||
delay: 5s
|
||||
max_attempts: 0
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik-network
|
||||
- traefik.http.services.mailgun-webhook.loadbalancer.server.port=3000
|
||||
- traefik.http.routers.mailgun-webhook.entrypoints=http
|
||||
- traefik.http.routers.mailgun-webhook.rule=Host(`mailgun-webhook.rezije.app`)
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Email Server Worker
|
||||
|
||||
This workspace contains the email server worker service for the Evidencija Režija tenant notification system.
|
||||
|
||||
## Purpose
|
||||
|
||||
This service manages email operations by:
|
||||
- Polling MongoDB for email status changes
|
||||
- Detecting unverified tenant emails (EmailStatus.Unverified)
|
||||
- Sending verification emails to tenants
|
||||
- Updating email status to VerificationPending
|
||||
- Sending scheduled notifications (rent due, utility bills)
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a standalone background worker service that:
|
||||
- Runs independently from the Next.js web-app
|
||||
- Communicates via the shared MongoDB database
|
||||
- Integrates with email service provider (e.g., Mailgun, SendGrid)
|
||||
|
||||
## Setup
|
||||
|
||||
TBD
|
||||
|
||||
## Environment Variables
|
||||
|
||||
TBD
|
||||
7
email-worker/.dockerignore
Normal file
7
email-worker/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
tests
|
||||
.git
|
||||
coverage
|
||||
node_modules
|
||||
jest.config.ts
|
||||
service-tester.sh
|
||||
build-image.sh
|
||||
27
email-worker/.env
Normal file
27
email-worker/.env
Normal file
@@ -0,0 +1,27 @@
|
||||
# Worker Configuration
|
||||
PULL_INTERVAL=60000
|
||||
EMAIL_BUDGET=10
|
||||
|
||||
# MongoDB Configuration
|
||||
MONGODB_URI=mongodb://root:HjktJCPWMBtM1ACrDaw7@localhost:27017
|
||||
|
||||
# Mailgun Configuration
|
||||
# MAILGUN_API_KEY=d660e320e0cdeecc125d4ecd6bc5cd66-e61ae8dd-01bdd61a
|
||||
MAILGUN_API_KEY=f581edcac21ec14d086ef25e36f04432-e61ae8dd-e207f22b
|
||||
MAILGUN_DOMAIN=rezije.app
|
||||
|
||||
# Security
|
||||
SHARE_LINK_SECRET=fb831e43b5ab594106e093f86fa8cb2a2405c564a61c3a7681079ec416528654
|
||||
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
|
||||
# Logging
|
||||
DEBUG=*
|
||||
|
||||
# Prometheus Metrics (optional)
|
||||
PROMETHEUS_APP_LABEL=email-worker
|
||||
PROMETHEUS_HISTOGRAM_BUCKETS=0.1, 0.5, 1, 5, 10
|
||||
|
||||
# Environment
|
||||
ENV=dev
|
||||
26
email-worker/.env.example
Normal file
26
email-worker/.env.example
Normal file
@@ -0,0 +1,26 @@
|
||||
# Worker Configuration
|
||||
PULL_INTERVAL=60000
|
||||
EMAIL_BUDGET=10
|
||||
|
||||
# MongoDB Configuration
|
||||
MONGODB_URI=mongodb://localhost:27017/utility-bills
|
||||
|
||||
# Mailgun Configuration
|
||||
MAILGUN_API_KEY=d660e320e0cdeecc125d4ecd6bc5cd66-e61ae8dd-01bdd61a
|
||||
MAILGUN_DOMAIN=rezije.app
|
||||
|
||||
# Security
|
||||
SHARE_LINK_SECRET=fb831e43b5ab594106e093f86fa8cb2a2405c564a61c3a7681079ec416528654
|
||||
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
|
||||
# Logging
|
||||
DEBUG=worker:*,email:*,db:*
|
||||
|
||||
# Prometheus Metrics (optional)
|
||||
PROMETHEUS_APP_LABEL=email-worker
|
||||
PROMETHEUS_HISTOGRAM_BUCKETS=0.1, 0.5, 1, 5, 10
|
||||
|
||||
# Environment
|
||||
ENV=dev
|
||||
2
email-worker/.gitignore
vendored
Normal file
2
email-worker/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
build
|
||||
58
email-worker/.vscode/launch.json
vendored
Normal file
58
email-worker/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
// 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,
|
||||
},
|
||||
{
|
||||
"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"
|
||||
]
|
||||
},
|
||||
|
||||
]
|
||||
}
|
||||
10
email-worker/.vscode/settings.json
vendored
Normal file
10
email-worker/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"jest.jestCommandLine": "npm run test --",
|
||||
"jest.autoRun": {
|
||||
"watch": false,
|
||||
"onSave": "test-file"
|
||||
},
|
||||
"jest.nodeEnv": {
|
||||
|
||||
}
|
||||
}
|
||||
65
email-worker/Dockerfile
Normal file
65
email-worker/Dockerfile
Normal file
@@ -0,0 +1,65 @@
|
||||
#--------------------------------------------
|
||||
# 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:18 as package-stage
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./package*.json ./
|
||||
|
||||
# instaliram SAMO produkcijske
|
||||
RUN npm i --only=production && npm cache clean --force
|
||||
|
||||
#--------------------------------------------
|
||||
# Stage: priprema finalnog image-a
|
||||
#--------------------------------------------
|
||||
FROM gcr.io/distroless/nodejs20-debian12:nonroot as assembly-stage
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG PORT="3000"
|
||||
ENV PORT=${PORT}
|
||||
|
||||
# prometheus config
|
||||
ARG PROMETHEUS_APP_LABEL
|
||||
ENV PROMETHEUS_APP_LABEL=${PROMETHEUS_APP_LABEL}
|
||||
|
||||
ARG PROMETHEUS_HISTOGRAM_BUCKETS
|
||||
ENV PROMETHEUS_HISTOGRAM_BUCKETS=${PROMETHEUS_HISTOGRAM_BUCKETS}
|
||||
|
||||
# (optional) logiranje na stdout (moguće opcije: "server:server", "server:metrics", "server:healthcheck" )
|
||||
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"]
|
||||
115
email-worker/README.md
Normal file
115
email-worker/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Email Server Worker
|
||||
|
||||
Background worker service with HTTP health monitoring and metrics collection.
|
||||
|
||||
## Overview
|
||||
|
||||
This is a TypeScript-based background worker service that combines periodic task execution with HTTP health monitoring and metrics collection. It implements a self-scheduling worker pattern with graceful shutdown support.
|
||||
|
||||
## Features
|
||||
|
||||
- **Periodic Task Execution**: Self-scheduling worker loop with configurable interval
|
||||
- **Graceful Shutdown**: Ensures in-flight work completes before shutdown (Docker-compatible)
|
||||
- **Health Monitoring**: HTTP health check endpoint to verify worker status
|
||||
- **Metrics Collection**: Prometheus metrics with PRTG adapter
|
||||
- **Error Isolation**: Worker errors don't crash the process
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run start # Start with nodemon (auto-reload)
|
||||
```
|
||||
|
||||
### Build & Run
|
||||
|
||||
```bash
|
||||
npm run build # Compile TypeScript
|
||||
npm run run-server # Run compiled version
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
npm run test # Run Jest in watch mode
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Required
|
||||
|
||||
- `PULL_INTERVAL` - Milliseconds between work cycles (default: `"10000"`)
|
||||
|
||||
### Optional
|
||||
|
||||
- `PORT` - HTTP server port (default: `"3000"`)
|
||||
- `PROMETHEUS_APP_LABEL` - App label for Prometheus metrics (default: `"email-worker"`)
|
||||
- `PROMETHEUS_HISTOGRAM_BUCKETS` - Histogram bucket sizes (default: `"0.1, 0.5, 1, 5, 10"`)
|
||||
- `DEBUG` - Debug namespaces for console logging (e.g., `"server:server"`)
|
||||
- `ENV` - Environment mode: `"dev"`, `"jest"` (affects logging)
|
||||
|
||||
## HTTP Endpoints
|
||||
|
||||
- `GET /healthcheck` - Health check endpoint (verifies worker is running)
|
||||
- `GET /metrics` - Prometheus metrics
|
||||
- `GET /prtg` - PRTG-compatible metrics (XML format)
|
||||
- `GET /ping` - Simple ping/pong endpoint
|
||||
|
||||
## Creating a Worker
|
||||
|
||||
See `src/exampleWorker.ts` for the worker template. The worker must export a `doWork` function:
|
||||
|
||||
```typescript
|
||||
|
||||
export const doWork = async () => {
|
||||
// Your periodic task logic here
|
||||
logger.info("Task Completed", "Work done successfully");
|
||||
|
||||
// Throw errors to mark as failed:
|
||||
// throw new Error("Something went wrong");
|
||||
};
|
||||
```
|
||||
|
||||
Update `src/workRunner.ts` line 6 to import your worker:
|
||||
|
||||
```typescript
|
||||
import { doWork } from "./yourWorker";
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **entry.ts** - HTTP server setup, signal handling, worker lifecycle
|
||||
- **workRunner.ts** - Self-scheduling loop, metrics, graceful shutdown
|
||||
- **app.ts** - Express app configuration, routes
|
||||
- **src/lib/** - Shared utilities (logger, metrics, etc.)
|
||||
- **src/routes/** - HTTP route handlers
|
||||
|
||||
## Deployment
|
||||
|
||||
The service uses the `stoppable` library for graceful shutdown with a 10-second timeout before force-closing connections. Docker containers will receive SIGTERM signals and shut down gracefully.
|
||||
|
||||
## Logging
|
||||
|
||||
The service supports two logging mechanisms:
|
||||
|
||||
1. **Console Logging**: Uses the `debug` package, controlled by `DEBUG` env variable
|
||||
|
||||
## Metrics
|
||||
|
||||
Prometheus metrics are automatically collected:
|
||||
|
||||
- `request_operations_total` - Total work cycles executed
|
||||
- `request_operations_ok` - Successful work cycles
|
||||
- `request_operations_failed` - Failed work cycles
|
||||
- `request_duration_seconds` - Duration histogram of work cycles
|
||||
|
||||
## Documentation
|
||||
|
||||
See `CLAUDE.md` in the repository root for complete architecture documentation and guidance.
|
||||
29
email-worker/build-image.sh
Executable file
29
email-worker/build-image.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ "$1" == "" ] ; then
|
||||
printf "\nDocker image version not set - please specify the 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=evo-open-table-sync-svc
|
||||
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"
|
||||
@@ -0,0 +1,143 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Verify Your Email - rezije.app</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||
<!-- Wrapper Table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
|
||||
<tr>
|
||||
<td style="padding: 20px 0;">
|
||||
<!-- Main Container Table (3 columns) -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
|
||||
<tr>
|
||||
<!-- Left Spacer Column -->
|
||||
<td width="10%" style="background-color: #f4f4f4;"></td>
|
||||
|
||||
<!-- Center Content Column -->
|
||||
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
|
||||
<!-- Header with Logo -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
|
||||
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
|
||||
</td>
|
||||
<td style="vertical-align: middle; text-align: left;">
|
||||
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
|
||||
rezije​.​app
|
||||
</h1>
|
||||
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
|
||||
Property Management Made Easy
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Content -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 30px 30px 20px 30px;">
|
||||
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Hello <strong>${location.tenantName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
You have received this email because your landlord <strong>${ownerName}</strong> wants to us to send you rent and utility bills invoices for <strong>${location.name}</strong> each month.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
<strong>What is this all about?</strong><br>
|
||||
<strong>rezije​.​app</strong> is an online app which helps property owners manage expenses related to properties they lease.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
By sending rent and utility bills invoices via email, your landlord can ensure that you receive accurate and timely notifications about your payments, making it easier for both of you to keep track of your financial obligations.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
<strong>How many e-mails will I receive?</strong><br>
|
||||
E-mails are sent two times a month at most: once the rent is due, and once when all the utility bills ready.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
<strong>We need your confirmation</strong><br>
|
||||
Before we start sending you any bills via email, we would ask you for your permission.
|
||||
If you accept, please click the button below:
|
||||
</p>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="text-align: center; padding: 0 0 30px 0;">
|
||||
<a href="https://rezije.app/en/email/verify/${shareId}"
|
||||
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6 ; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
|
||||
I accept
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
|
||||
Or copy and paste this link into your browser:
|
||||
</p>
|
||||
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
|
||||
https://rezije.app/en/email/verify/${shareId}
|
||||
</p>
|
||||
|
||||
<!-- Divider -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 20px 0;">
|
||||
<div style="border-top: 1px solid #E5E7EB;"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
|
||||
<strong>Don't want to receive these emails?</strong><br>
|
||||
You can ignore this email if you don't want to receive notifications. You can also unsubscribe at any time using the link included in every notification email.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Thank you!
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Footer -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
|
||||
<a href="https://rezije.app" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
|
||||
Visit rezije.app
|
||||
</a>
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 12px; color: #999999;">
|
||||
© 2025 rezije.app. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
|
||||
<!-- Right Spacer Column -->
|
||||
<td width="10%" style="background-color: #f4f4f4;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,143 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="hr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Potvrdite svoju e-mail adresu - rezije.app</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||
<!-- Wrapper Table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
|
||||
<tr>
|
||||
<td style="padding: 20px 0;">
|
||||
<!-- Main Container Table (3 columns) -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
|
||||
<tr>
|
||||
<!-- Left Spacer Column -->
|
||||
<td width="10%" style="background-color: #f4f4f4;"></td>
|
||||
|
||||
<!-- Center Content Column -->
|
||||
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
|
||||
<!-- Header with Logo -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
|
||||
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
|
||||
</td>
|
||||
<td style="vertical-align: middle; text-align: left;">
|
||||
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
|
||||
rezije​.​app
|
||||
</h1>
|
||||
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
|
||||
Jednostavno upravljanje nekretninama
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Content -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 30px 30px 20px 30px;">
|
||||
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Poštovani <strong>${location.tenantName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Primili ste ovaj email jer Vaš vlasnik nekretnine <strong>${ownerName}</strong> želi da Vam mjesečno šaljemo obavijesti o najamnini i režijama za <strong>${location.name}</strong>.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
<strong>O čemu se radi?</strong><br>
|
||||
<strong>rezije​.​app</strong> je online aplikacija koja pomaže vlasnicima nekretnina da upravljaju troškovima vezanim uz nekretnine koje iznajmljuju.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Slanjem obavijesti o najamnini i režijama putem e-maila, Vaš vlasnik može osigurati da primite točne i pravovremene obavijesti o plaćanjima, što olakšava praćenje financijskih obveza za oboje.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
<strong>Koliko e-mailova ću primati?</strong><br>
|
||||
E-mailovi se šalju maksimalno dva puta mjesečno: jednom kada dospijeva najamnina, i jednom kada su sve režije spremne.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
<strong>Potrebna je Vaša potvrda</strong><br>
|
||||
Prije nego što počnemo slati Vam račune putem e-maila, molimo Vas za dozvolu.
|
||||
Ako prihvaćate, molimo kliknite gumb ispod:
|
||||
</p>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="text-align: center; padding: 0 0 30px 0;">
|
||||
<a href="https://rezije.app/hr/email/verify/${shareId}"
|
||||
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6 ; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
|
||||
Prihvaćam
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
|
||||
Ili kopirajte i zalijepite ovaj link u svoj preglednik:
|
||||
</p>
|
||||
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
|
||||
https://rezije.app/hr/email/verify/${shareId}
|
||||
</p>
|
||||
|
||||
<!-- Divider -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 20px 0;">
|
||||
<div style="border-top: 1px solid #E5E7EB;"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
|
||||
<strong>Ne želite primati ove e-mailove?</strong><br>
|
||||
Možete zanemariti ovaj email ako ne želite primati obavijesti. Također se možete odjaviti u bilo kojem trenutku koristeći link koji se nalazi u svakoj obavijesti.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Hvala!
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Footer -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
|
||||
<a href="https://rezije.app" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
|
||||
Posjetite rezije.app
|
||||
</a>
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 12px; color: #999999;">
|
||||
© 2025 rezije.app. Sva prava pridržana.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
|
||||
<!-- Right Spacer Column -->
|
||||
<td width="10%" style="background-color: #f4f4f4;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
145
email-worker/email-templates/email-template--rent-due--en.html
Normal file
145
email-worker/email-templates/email-template--rent-due--en.html
Normal file
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Rent Payment Due - rezije.app</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||
<!-- Wrapper Table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
|
||||
<tr>
|
||||
<td style="padding: 20px 0;">
|
||||
<!-- Main Container Table (3 columns) -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
|
||||
<tr>
|
||||
<!-- Left Spacer Column -->
|
||||
<td width="10%" style="background-color: #f4f4f4;"></td>
|
||||
|
||||
<!-- Center Content Column -->
|
||||
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
|
||||
<!-- Header with Logo -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
|
||||
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
|
||||
</td>
|
||||
<td style="vertical-align: middle; text-align: left;">
|
||||
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
|
||||
rezije​.​app
|
||||
</h1>
|
||||
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
|
||||
Property Management Made Easy
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Content -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 30px 30px 20px 30px;">
|
||||
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Hello <strong>${location.tenantName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
This is a friendly reminder that your rent payment for <strong>${location.name}</strong> is due on <strong>${rentDueDate}</strong>.
|
||||
</p>
|
||||
|
||||
<!-- Rent Amount Box -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
|
||||
<tr>
|
||||
<td style="background-color: #F0F9FF; border-left: 4px solid #0070F3; padding: 20px; border-radius: 4px;">
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
Amount Due
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 32px; font-weight: bold; color: #0070F3;">
|
||||
${rentAmount} ${currency}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
To view payment details and attach your proof of payment, please click the button below:
|
||||
</p>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="text-align: center; padding: 0 0 30px 0;">
|
||||
<a href="https://rezije.app/en/share/rent-due/${shareId}"
|
||||
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
|
||||
View Payment Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
|
||||
Or copy and paste this link into your browser:
|
||||
</p>
|
||||
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
|
||||
https://rezije.app/en/share/rent-due/${shareId}
|
||||
</p>
|
||||
|
||||
<!-- Divider -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 20px 0;">
|
||||
<div style="border-top: 1px solid #E5E7EB;"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
|
||||
If you have any questions or concerns about this payment, please contact your landlord <strong>${ownerName}</strong> directly.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Thank you!
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Footer -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
|
||||
<a href="https://rezije.app/en/email/unsubscribe/${shareId}" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
|
||||
Unsubscribe from these emails
|
||||
</a>
|
||||
</p>
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
|
||||
<a href="https://rezije.app/en" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
|
||||
Visit rezije.app
|
||||
</a>
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 12px; color: #999999;">
|
||||
© 2025 rezije.app. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
|
||||
<!-- Right Spacer Column -->
|
||||
<td width="10%" style="background-color: #f4f4f4;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
145
email-worker/email-templates/email-template--rent-due--hr.html
Normal file
145
email-worker/email-templates/email-template--rent-due--hr.html
Normal file
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="hr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dospjela najamnina - rezije.app</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||
<!-- Wrapper Table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
|
||||
<tr>
|
||||
<td style="padding: 20px 0;">
|
||||
<!-- Main Container Table (3 columns) -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
|
||||
<tr>
|
||||
<!-- Left Spacer Column -->
|
||||
<td width="10%" style="background-color: #f4f4f4;"></td>
|
||||
|
||||
<!-- Center Content Column -->
|
||||
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
|
||||
<!-- Header with Logo -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
|
||||
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
|
||||
</td>
|
||||
<td style="vertical-align: middle; text-align: left;">
|
||||
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
|
||||
rezije​.​app
|
||||
</h1>
|
||||
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
|
||||
Jednostavno upravljanje nekretninama
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Content -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 30px 30px 20px 30px;">
|
||||
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Poštovani <strong>${location.tenantName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Ovo je podsjetnik da Vaša najamnina za <strong>${location.name}</strong> dospijeva <strong>${rentDueDate}</strong>.
|
||||
</p>
|
||||
|
||||
<!-- Rent Amount Box -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
|
||||
<tr>
|
||||
<td style="background-color: #F0F9FF; border-left: 4px solid #0070F3; padding: 20px; border-radius: 4px;">
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
Iznos za uplatu
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 32px; font-weight: bold; color: #0070F3;">
|
||||
${rentAmount} ${currency}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Za detalje o uplatama i prilaganje potvrde o plaćanju, molimo kliknite gumb ispod:
|
||||
</p>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="text-align: center; padding: 0 0 30px 0;">
|
||||
<a href="https://rezije.app/hr/share/rent-due/${shareId}"
|
||||
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
|
||||
Pogledaj detalje uplate
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
|
||||
Ili kopirajte i zalijepite ovaj link u svoj preglednik:
|
||||
</p>
|
||||
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
|
||||
https://rezije.app/hr/share/rent-due/${shareId}
|
||||
</p>
|
||||
|
||||
<!-- Divider -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 20px 0;">
|
||||
<div style="border-top: 1px solid #E5E7EB;"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
|
||||
Ako imate bilo kakvih pitanja ili nedoumica u vezi ovog plaćanja, molimo kontaktirajte svog vlasnika <strong>${ownerName}</strong> izravno.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Hvala!
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Footer -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
|
||||
<a href="https://rezije.app/hr/email/unsubscribe/${shareId}" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
|
||||
Odjavi se od ovih e-mailova
|
||||
</a>
|
||||
</p>
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
|
||||
<a href="https://rezije.app" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
|
||||
Posjetite rezije.app
|
||||
</a>
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 12px; color: #999999;">
|
||||
© 2025 rezije.app. Sva prava pridržana.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
|
||||
<!-- Right Spacer Column -->
|
||||
<td width="10%" style="background-color: #f4f4f4;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Utility Bills Available - rezije.app</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||
<!-- Wrapper Table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
|
||||
<tr>
|
||||
<td style="padding: 20px 0;">
|
||||
<!-- Main Container Table (3 columns) -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
|
||||
<tr>
|
||||
<!-- Left Spacer Column -->
|
||||
<td width="10%" style="background-color: #f4f4f4;"></td>
|
||||
|
||||
<!-- Center Content Column -->
|
||||
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
|
||||
<!-- Header with Logo -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
|
||||
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
|
||||
</td>
|
||||
<td style="vertical-align: middle; text-align: left;">
|
||||
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
|
||||
rezije​.​app
|
||||
</h1>
|
||||
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
|
||||
Property Management Made Easy
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Content -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 30px 30px 20px 30px;">
|
||||
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Hello <strong>${location.tenantName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
All utility bills for <strong>${location.name}</strong> are now due.
|
||||
</p>
|
||||
|
||||
<!-- Notification Box -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
|
||||
<tr>
|
||||
<td style="background-color: #F0F9FF; border-left: 4px solid #0070F3; padding: 20px; border-radius: 4px;">
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
Total Amount Due
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 32px; font-weight: bold; color: #0070F3;">
|
||||
${totalAmount} ${currency}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
To view bills and payment information, please click the button below:
|
||||
</p>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="text-align: center; padding: 0 0 30px 0;">
|
||||
<a href="https://rezije.app/en/share/bills-due/${shareId}"
|
||||
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
|
||||
View Payment Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
|
||||
Or copy and paste this link into your browser:
|
||||
</p>
|
||||
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
|
||||
https://rezije.app/en/share/bills-due/${shareId}
|
||||
</p>
|
||||
|
||||
<!-- Divider -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 20px 0;">
|
||||
<div style="border-top: 1px solid #E5E7EB;"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
|
||||
If you have any questions or concerns, please contact your landlord <strong>${ownerName}</strong> directly.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Thank you!
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Footer -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
|
||||
<a href="https://rezije.app/en/email/unsubscribe/${shareId}" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
|
||||
Unsubscribe from these emails
|
||||
</a>
|
||||
</p>
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
|
||||
<a href="https://rezije.app/en" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
|
||||
Visit rezije.app
|
||||
</a>
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 12px; color: #999999;">
|
||||
© 2025 rezije.app. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
|
||||
<!-- Right Spacer Column -->
|
||||
<td width="10%" style="background-color: #f4f4f4;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="hr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dostupne režije - rezije.app</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||
<!-- Wrapper Table -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
|
||||
<tr>
|
||||
<td style="padding: 20px 0;">
|
||||
<!-- Main Container Table (3 columns) -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
|
||||
<tr>
|
||||
<!-- Left Spacer Column -->
|
||||
<td width="10%" style="background-color: #f4f4f4;"></td>
|
||||
|
||||
<!-- Center Content Column -->
|
||||
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
|
||||
<!-- Header with Logo -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
|
||||
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
|
||||
</td>
|
||||
<td style="vertical-align: middle; text-align: left;">
|
||||
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
|
||||
rezije​.​app
|
||||
</h1>
|
||||
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
|
||||
Jednostavno upravljanje nekretninama
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Content -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 30px 30px 20px 30px;">
|
||||
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Poštovani <strong>${location.tenantName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Sve režije za <strong>${location.name}</strong> sada su dostupne.
|
||||
</p>
|
||||
|
||||
<!-- Notification Box -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
|
||||
<tr>
|
||||
<td style="background-color: #F0F9FF; border-left: 4px solid #0070F3; padding: 20px; border-radius: 4px;">
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
Ukupan iznos za uplatu
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 32px; font-weight: bold; color: #0070F3;">
|
||||
${totalAmount} ${currency}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Za pregled računa i informacije o uplatama, molimo kliknite gumb ispod:
|
||||
</p>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="text-align: center; padding: 0 0 30px 0;">
|
||||
<a href="https://rezije.app/hr/share/bills-due/${shareId}"
|
||||
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
|
||||
Pogledaj detalje uplate
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
|
||||
Ili kopirajte i zalijepite ovaj link u svoj preglednik:
|
||||
</p>
|
||||
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
|
||||
https://rezije.app/hr/share/bills-due/${shareId}
|
||||
</p>
|
||||
|
||||
<!-- Divider -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 20px 0;">
|
||||
<div style="border-top: 1px solid #E5E7EB;"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
|
||||
Ako imate bilo kakvih pitanja ili nedoumica, molimo kontaktirajte svog vlasnika <strong>${ownerName}</strong> izravno.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
|
||||
Hvala!
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Footer -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
|
||||
<a href="https://rezije.app/hr/email/unsubscribe/${shareId}" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
|
||||
Odjavi se od ovih e-mailova
|
||||
</a>
|
||||
</p>
|
||||
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
|
||||
<a href="https://rezije.app" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
|
||||
Posjetite rezije.app
|
||||
</a>
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 12px; color: #999999;">
|
||||
© 2025 rezije.app. Sva prava pridržana.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
|
||||
<!-- Right Spacer Column -->
|
||||
<td width="10%" style="background-color: #f4f4f4;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
39
email-worker/jest.config.ts
Normal file
39
email-worker/jest.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/** @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',
|
||||
// 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,
|
||||
setupFiles: [
|
||||
'dotenv/config', // učitaj varijable iz .env i učini ih dostupne testiranom software-u
|
||||
]
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
7
email-worker/nodemon.json
Normal file
7
email-worker/nodemon.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"watch": ["src"],
|
||||
"ext": "ts",
|
||||
"execMap": {
|
||||
"ts": "node -r dotenv/config -r ts-node/register"
|
||||
}
|
||||
}
|
||||
9788
email-worker/package-lock.json
generated
Normal file
9788
email-worker/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
54
email-worker/package.json
Normal file
54
email-worker/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "email-worker",
|
||||
"version": "0.1.0",
|
||||
"description": "Email notification worker service for sending verification requests, rent due notices, and utility bills notifications",
|
||||
"main": "entry.ts",
|
||||
"scripts": {
|
||||
"start": "nodemon ./src/entry.ts",
|
||||
"run-server": "DEBUG=* node --enable-source-maps ./build/entry.js",
|
||||
"build": "ttsc --project ./",
|
||||
"test": "ENV=jest jest --watch"
|
||||
},
|
||||
"author": "Nikola",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@evidencija-rezija/shared-code": "^1.0.0",
|
||||
"debug": "^2.6.9",
|
||||
"express": "^4.18.2",
|
||||
"form-data": "^4.0.5",
|
||||
"http-errors": "^1.7.2",
|
||||
"mailgun.js": "^12.4.1",
|
||||
"mongodb": "^7.0.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"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/mongodb": "^4.0.6",
|
||||
"@types/node": "^16.10.2",
|
||||
"@types/node-fetch": "^2.6.2",
|
||||
"@types/stoppable": "^1.1.1",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"dotenv": "^16.0.3",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
13
email-worker/run-image.sh
Executable file
13
email-worker/run-image.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ "$1" == "" ] ; then
|
||||
printf "\nNisi zadao verziju Docker image-a koji treba pokrenuti"
|
||||
printf "\n\nSintaksa:\n\n run-image.sh 1.0.0\n\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IMAGE_TAG=evo-open-table-sync-svc:$1
|
||||
|
||||
docker run -p 3000:3000 \
|
||||
--env DEBUG=* \
|
||||
$IMAGE_TAG
|
||||
@@ -13,7 +13,7 @@ async function sendSimpleMessage() {
|
||||
console.log("Sending email...");
|
||||
const data = await mg.messages.create("rezije.app", {
|
||||
from: "Mailgun Sandbox <support@rezije.app>",
|
||||
to: ["Nikola Derezic <nikola.derezic@gmail.com>"],
|
||||
to: ["Nikola Derezic <armful-grief-knoll@duck.com>"],
|
||||
subject: "Hello Nikola Derezic",
|
||||
text: "Congratulations Nikola Derezic, you just sent an email with Mailgun! You are truly awesome!",
|
||||
});
|
||||
34
email-worker/src/app.ts
Normal file
34
email-worker/src/app.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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 { pingRouter } from './routes/pingRouter';
|
||||
import { healthcheckRouter } from './routes/healthcheckRouter';
|
||||
|
||||
import { SupportedRoutes } from './types/enums/SupportedRoutes';
|
||||
|
||||
const app = express();
|
||||
|
||||
// u slučaju kada se server vrti iza proxy-a
|
||||
// ovaj flag će natjerati Express da informacije poput
|
||||
// IP adrese klijenta, protokola uzima iz X-Forward-*
|
||||
// HTTP header polja, koja postavlja proxy
|
||||
app.set('trust proxy', true);
|
||||
|
||||
// prometheus sa ove rute dohvaća zadnje važeću statistiku
|
||||
app.use(SupportedRoutes.metricsPath, metricsRouter);
|
||||
app.use(SupportedRoutes.ping, pingRouter);
|
||||
app.use(SupportedRoutes.healthcheck, healthcheckRouter);
|
||||
|
||||
// default handler
|
||||
app.use((req, res, next) => next(createError(404)));
|
||||
|
||||
// error handler za sve predviđene greške
|
||||
app.use(errorRouter);
|
||||
|
||||
// error router za nepredviđene greške
|
||||
app.use(finalErrorRouter);
|
||||
|
||||
export default app;
|
||||
70
email-worker/src/emailWorker.ts
Normal file
70
email-worker/src/emailWorker.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { connectToDatabase, disconnectFromDatabase } from './lib/dbClient';
|
||||
import { sendVerificationRequests, sendRentDueNotifications, sendUtilityBillsNotifications } from './lib/emailSenders';
|
||||
import { createLogger } from './lib/logger';
|
||||
|
||||
const log = createLogger("worker:email");
|
||||
|
||||
/**
|
||||
* Email worker implementation
|
||||
*
|
||||
* Sends three types of emails in priority order:
|
||||
* 1. Email verification requests (highest priority)
|
||||
* 2. Rent due notifications
|
||||
* 3. Utility bills due notifications
|
||||
*
|
||||
* Uses a budget system to limit total emails sent per run.
|
||||
*/
|
||||
export const doWork = async () => {
|
||||
const startTime = Date.now();
|
||||
const emailBudget = parseInt(process.env.EMAIL_BUDGET || '10', 10);
|
||||
|
||||
log(`Starting email worker run with budget: ${emailBudget}`);
|
||||
|
||||
let remainingBudget = emailBudget;
|
||||
let totalSent = 0;
|
||||
|
||||
try {
|
||||
// Connect to database
|
||||
const db = await connectToDatabase();
|
||||
|
||||
// 1. Send verification requests (highest priority)
|
||||
const verificationsSent = await sendVerificationRequests(db, remainingBudget);
|
||||
totalSent += verificationsSent;
|
||||
remainingBudget -= verificationsSent;
|
||||
log(`Verification emails sent: ${verificationsSent}, remaining budget: ${remainingBudget}`);
|
||||
|
||||
// 2. Send rent due notifications
|
||||
if (remainingBudget > 0) {
|
||||
const rentSent = await sendRentDueNotifications(db, remainingBudget);
|
||||
totalSent += rentSent;
|
||||
remainingBudget -= rentSent;
|
||||
log(`Rent due emails sent: ${rentSent}, remaining budget: ${remainingBudget}`);
|
||||
}
|
||||
|
||||
// 3. Send utility bills notifications
|
||||
if (remainingBudget > 0) {
|
||||
const billsSent = await sendUtilityBillsNotifications(db, remainingBudget);
|
||||
totalSent += billsSent;
|
||||
remainingBudget -= billsSent;
|
||||
log(`Utility bills emails sent: ${billsSent}, remaining budget: ${remainingBudget}`);
|
||||
}
|
||||
|
||||
// Disconnect from database
|
||||
await disconnectFromDatabase();
|
||||
|
||||
const workDuration = Date.now() - startTime;
|
||||
log(`Email worker completed in ${workDuration}ms. Total emails sent: ${totalSent}`);
|
||||
|
||||
} catch (error) {
|
||||
log(`Email worker failed: ${error}`);
|
||||
|
||||
// Try to disconnect even on error
|
||||
try {
|
||||
await disconnectFromDatabase();
|
||||
} catch (disconnectError) {
|
||||
log(`Failed to disconnect from database: ${disconnectError}`);
|
||||
}
|
||||
|
||||
throw error; // Re-throw to mark work as failed
|
||||
}
|
||||
};
|
||||
122
email-worker/src/entry.ts
Executable file
122
email-worker/src/entry.ts
Executable file
@@ -0,0 +1,122 @@
|
||||
import app from './app';
|
||||
import http from 'http';
|
||||
import stoppable from 'stoppable';
|
||||
|
||||
import { createLogger } from './lib/logger';
|
||||
import { disposeSyncWorker, startSyncWorker } from './workRunner';
|
||||
const logger = createLogger("server:server");
|
||||
|
||||
/**
|
||||
* 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;
|
||||
logger(`⚡️[server]: 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);
|
||||
|
||||
/**
|
||||
* Starting sync worker process
|
||||
*/
|
||||
startSyncWorker();
|
||||
|
||||
// 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', () => {
|
||||
logger('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', () => {
|
||||
logger('Got SIGTERM (docker container stop). Graceful shutdown ', new Date().toISOString());
|
||||
shutdown();
|
||||
});
|
||||
|
||||
// shut down server
|
||||
const shutdown = async () => {
|
||||
|
||||
await disposeSyncWorker();
|
||||
|
||||
// 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 {
|
||||
logger('Exiting server process...');
|
||||
}
|
||||
process.exit();
|
||||
});
|
||||
};
|
||||
33
email-worker/src/exampleWorker.ts
Normal file
33
email-worker/src/exampleWorker.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Example worker implementation
|
||||
*
|
||||
* This is a placeholder worker that demonstrates the worker pattern.
|
||||
* Replace this with your actual worker implementation.
|
||||
*
|
||||
* The worker is called periodically by workRunner.ts based on PULL_INTERVAL.
|
||||
*
|
||||
* @throws Error to increment failedRequestCounter in Prometheus
|
||||
* @returns Promise that resolves when work is complete (increments successfulRequestCounter)
|
||||
*/
|
||||
export const doWork = async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// TODO: Implement your periodic worker logic here
|
||||
// Examples:
|
||||
// - Fetch data from external API
|
||||
// - Process queued tasks from database
|
||||
// - Send scheduled emails
|
||||
// - Clean up expired records
|
||||
// - Sync data between systems
|
||||
|
||||
const workDuration = Date.now() - startTime;
|
||||
|
||||
// Log success (only in non-test environments)
|
||||
if (process.env.ENV !== "jest") {
|
||||
const logMessage = `Example worker completed in ${workDuration}ms`;
|
||||
console.log(logMessage);
|
||||
}
|
||||
|
||||
// Note: Throw errors to mark work as failed:
|
||||
// throw new Error("Something went wrong");
|
||||
};
|
||||
29
email-worker/src/healthcheck.ts
Normal file
29
email-worker/src/healthcheck.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createLogger } from "./lib/logger";
|
||||
|
||||
import http, { IncomingMessage } from "http";
|
||||
const logger = createLogger("server:healthcheck");
|
||||
|
||||
const options = {
|
||||
host: "localhost",
|
||||
port: "3000",
|
||||
timeout: 2000,
|
||||
path: '/healthcheck/'
|
||||
};
|
||||
|
||||
const request = http.request(options, (res:IncomingMessage) => {
|
||||
|
||||
logger(`Healthcheck: STATUS ${res.statusCode}`);
|
||||
|
||||
if (res.statusCode == 200) {
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
request.on("error", function (err:any) {
|
||||
logger("Healthcheck: ERROR");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
request.end();
|
||||
53
email-worker/src/lib/dbClient.ts
Normal file
53
email-worker/src/lib/dbClient.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { MongoClient, Db } from 'mongodb';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const log = createLogger("db:client");
|
||||
|
||||
let client: MongoClient | null = null;
|
||||
let db: Db | null = null;
|
||||
|
||||
/**
|
||||
* Connect to MongoDB
|
||||
* @returns Database instance
|
||||
*/
|
||||
export async function connectToDatabase(): Promise<Db> {
|
||||
if (!process.env.MONGODB_URI) {
|
||||
throw new Error('MONGODB_URI environment variable is not set');
|
||||
}
|
||||
|
||||
if (db) {
|
||||
log('Reusing existing database connection');
|
||||
return db;
|
||||
}
|
||||
|
||||
log('Creating new database connection');
|
||||
client = new MongoClient(process.env.MONGODB_URI);
|
||||
await client.connect();
|
||||
db = client.db("utility-bills");
|
||||
|
||||
log('Connected to database');
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from MongoDB
|
||||
*/
|
||||
export async function disconnectFromDatabase(): Promise<void> {
|
||||
if (client) {
|
||||
log('Disconnecting from database');
|
||||
await client.close();
|
||||
client = null;
|
||||
db = null;
|
||||
log('Disconnected from database');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current database instance (must call connectToDatabase first)
|
||||
*/
|
||||
export function getDatabase(): Db {
|
||||
if (!db) {
|
||||
throw new Error('Database not connected. Call connectToDatabase() first.');
|
||||
}
|
||||
return db;
|
||||
}
|
||||
284
email-worker/src/lib/emailSenders.ts
Normal file
284
email-worker/src/lib/emailSenders.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { Db, ObjectId } from 'mongodb';
|
||||
import { BillingLocation, BillsNotificationStatus, EmailStatus, RentNotificationStatus, UserSettings, generateShareId } from '@evidencija-rezija/shared-code';
|
||||
import { sendEmail } from './mailgunService';
|
||||
import { createLogger } from './logger';
|
||||
import { loadAndRender } from './emailTemplates';
|
||||
|
||||
const log = createLogger("email:senders");
|
||||
|
||||
/**
|
||||
* Send email verification requests
|
||||
* @param db Database instance
|
||||
* @param budget Remaining email budget
|
||||
* @returns Number of emails sent
|
||||
*/
|
||||
export async function sendVerificationRequests(db: Db, budget: number): Promise<number> {
|
||||
if (budget <= 0) {
|
||||
log('Budget exhausted, skipping verification requests');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth() + 1; // JavaScript months are 0-indexed
|
||||
|
||||
log(`Fetching locations for verification: year=${currentYear}, month=${currentMonth}`);
|
||||
|
||||
const locations = await db.collection<BillingLocation>('lokacije')
|
||||
.find({
|
||||
'yearMonth.year': currentYear,
|
||||
'yearMonth.month': currentMonth,
|
||||
'tenantEmailStatus': EmailStatus.Unverified
|
||||
})
|
||||
.toArray();
|
||||
|
||||
log(`Found ${locations.length} locations needing verification`);
|
||||
|
||||
let sentCount = 0;
|
||||
|
||||
for (const location of locations) {
|
||||
if (budget <= 0) {
|
||||
log('Budget exhausted during verification sending');
|
||||
break;
|
||||
}
|
||||
|
||||
if (!location.tenantEmail) {
|
||||
log(`Skipping location ${location._id}: no tenant email`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch user settings
|
||||
const userSettings = await db.collection<UserSettings>('userSettings')
|
||||
.findOne({ userId: location.userId });
|
||||
|
||||
const ownerName = userSettings?.ownerName || '';
|
||||
const shareId = generateShareId(location._id.toString());
|
||||
|
||||
const html = loadAndRender('email-validation', {
|
||||
'location.tenantName': location.tenantName || 'there',
|
||||
'ownerName': ownerName,
|
||||
'location.name': location.name,
|
||||
'shareId': shareId
|
||||
}, location.tenantEmailLanguage || 'hr');
|
||||
|
||||
const success = await sendEmail({
|
||||
to: location.tenantEmail,
|
||||
subject: `${ownerName} has invited you to rezije.app`,
|
||||
html
|
||||
});
|
||||
|
||||
// Update location status
|
||||
const newStatus = success ? EmailStatus.VerificationPending : EmailStatus.VerificationFailed;
|
||||
await db.collection<BillingLocation>('lokacije').updateOne(
|
||||
{ _id: location._id },
|
||||
{ $set: { tenantEmailStatus: newStatus } }
|
||||
);
|
||||
|
||||
if (success) {
|
||||
sentCount++;
|
||||
log(`Verification email sent to ${location.tenantEmail}`);
|
||||
} else {
|
||||
log(`Failed to send verification email to ${location.tenantEmail}`);
|
||||
}
|
||||
|
||||
budget--;
|
||||
}
|
||||
|
||||
log(`Sent ${sentCount} verification emails`);
|
||||
return sentCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send rent due notifications
|
||||
* @param db Database instance
|
||||
* @param budget Remaining email budget
|
||||
* @returns Number of emails sent
|
||||
*/
|
||||
export async function sendRentDueNotifications(db: Db, budget: number): Promise<number> {
|
||||
if (budget <= 0) {
|
||||
log('Budget exhausted, skipping rent due notifications');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Use CET timezone
|
||||
const cetDate = new Date(now.toLocaleString('en-US', { timeZone: 'Europe/Belgrade' }));
|
||||
const currentYear = cetDate.getFullYear();
|
||||
const currentMonth = cetDate.getMonth() + 1;
|
||||
const currentDay = cetDate.getDate();
|
||||
|
||||
log(`Fetching locations for rent due: year=${currentYear}, month=${currentMonth}, day=${currentDay}`);
|
||||
|
||||
const locations = await db.collection<BillingLocation>('lokacije')
|
||||
.find({
|
||||
'yearMonth.year': currentYear,
|
||||
'yearMonth.month': currentMonth,
|
||||
'tenantEmailStatus': EmailStatus.Verified,
|
||||
'rentNotificationEnabled': true,
|
||||
'rentDueDay': currentDay,
|
||||
$or: [
|
||||
{ 'rentNotificationStatus': { $exists: false } },
|
||||
{ 'rentNotificationStatus': null }
|
||||
]
|
||||
})
|
||||
.toArray();
|
||||
|
||||
log(`Found ${locations.length} locations needing rent due notifications`);
|
||||
|
||||
let sentCount = 0;
|
||||
|
||||
for (const location of locations) {
|
||||
if (budget <= 0) {
|
||||
log('Budget exhausted during rent due sending');
|
||||
break;
|
||||
}
|
||||
|
||||
if (!location.tenantEmail) {
|
||||
log(`Skipping location ${location._id}: no tenant email`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch user settings
|
||||
const userSettings = await db.collection<UserSettings>('userSettings')
|
||||
.findOne({ userId: location.userId });
|
||||
|
||||
const ownerName = userSettings?.ownerName || '';
|
||||
const shareId = generateShareId(location._id.toString());
|
||||
|
||||
// Format rent due date
|
||||
const rentDueDate = `${location.yearMonth.month}/${location.rentDueDay}/${location.yearMonth.year}`;
|
||||
|
||||
// Format rent amount (convert from cents to display format)
|
||||
const rentAmount = location.rentAmount ? (location.rentAmount).toFixed(2) : '0.00';
|
||||
const currency = userSettings?.currency || 'EUR';
|
||||
|
||||
const html = loadAndRender('rent-due', {
|
||||
'location.tenantName': location.tenantName || 'there',
|
||||
'location.name': location.name,
|
||||
'rentDueDate': rentDueDate,
|
||||
'rentAmount': rentAmount,
|
||||
'currency': currency,
|
||||
'ownerName': ownerName,
|
||||
'shareId': shareId
|
||||
}, location.tenantEmailLanguage || 'hr');
|
||||
|
||||
const success = await sendEmail({
|
||||
to: location.tenantEmail,
|
||||
subject: `Rent due for ${location.tenantName || 'your apartment'}`,
|
||||
html
|
||||
});
|
||||
|
||||
// Update location status
|
||||
const newStatus = success ? RentNotificationStatus.Sent : RentNotificationStatus.Failed;
|
||||
await db.collection<BillingLocation>('lokacije').updateOne(
|
||||
{ _id: location._id },
|
||||
{ $set: { rentNotificationStatus: newStatus } }
|
||||
);
|
||||
|
||||
if (success) {
|
||||
sentCount++;
|
||||
log(`Rent due notification sent to ${location.tenantEmail}`);
|
||||
} else {
|
||||
log(`Failed to send rent due notification to ${location.tenantEmail}`);
|
||||
}
|
||||
|
||||
budget--;
|
||||
}
|
||||
|
||||
log(`Sent ${sentCount} rent due notifications`);
|
||||
return sentCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send utility bills due notifications
|
||||
* @param db Database instance
|
||||
* @param budget Remaining email budget
|
||||
* @returns Number of emails sent
|
||||
*/
|
||||
export async function sendUtilityBillsNotifications(db: Db, budget: number): Promise<number> {
|
||||
if (budget <= 0) {
|
||||
log('Budget exhausted, skipping utility bills notifications');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth() + 1;
|
||||
|
||||
log(`Fetching locations for utility bills: year=${currentYear}, month=${currentMonth}`);
|
||||
|
||||
const locations = await db.collection<BillingLocation>('lokacije')
|
||||
.find({
|
||||
'yearMonth.year': currentYear,
|
||||
'yearMonth.month': currentMonth,
|
||||
'tenantEmailStatus': EmailStatus.Verified,
|
||||
'billsNotificationEnabled': true,
|
||||
'billsNotificationStatus': BillsNotificationStatus.Scheduled
|
||||
})
|
||||
.toArray();
|
||||
|
||||
log(`Found ${locations.length} locations needing utility bills notifications`);
|
||||
|
||||
let sentCount = 0;
|
||||
|
||||
for (const location of locations) {
|
||||
if (budget <= 0) {
|
||||
log('Budget exhausted during utility bills sending');
|
||||
break;
|
||||
}
|
||||
|
||||
if (!location.tenantEmail) {
|
||||
log(`Skipping location ${location._id}: no tenant email`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch user settings
|
||||
const userSettings = await db.collection<UserSettings>('userSettings')
|
||||
.findOne({ userId: location.userId });
|
||||
|
||||
const ownerName = userSettings?.ownerName || '';
|
||||
const shareId = generateShareId(location._id.toString());
|
||||
|
||||
// Calculate total amount from all bills
|
||||
const totalAmountCents = (location.bills || []).reduce((sum, bill) => {
|
||||
return sum + (bill.payedAmount || 0);
|
||||
}, 0);
|
||||
const totalAmount = (totalAmountCents / 100).toFixed(2);
|
||||
const currency = userSettings?.currency || 'EUR';
|
||||
|
||||
const html = loadAndRender('util-bills-due', {
|
||||
'location.tenantName': location.tenantName || 'there',
|
||||
'location.name': location.name,
|
||||
'totalAmount': totalAmount,
|
||||
'currency': currency,
|
||||
'ownerName': ownerName,
|
||||
'shareId': shareId
|
||||
}, location.tenantEmailLanguage || 'hr');
|
||||
|
||||
const success = await sendEmail({
|
||||
to: location.tenantEmail,
|
||||
subject: `Utility bills due for ${location.tenantName || 'your apartment'}`,
|
||||
html
|
||||
});
|
||||
|
||||
// Update location status
|
||||
const newStatus = success ? BillsNotificationStatus.Sent : BillsNotificationStatus.Failed;
|
||||
await db.collection<BillingLocation>('lokacije').updateOne(
|
||||
{ _id: location._id },
|
||||
{ $set: { billsNotificationStatus: newStatus } }
|
||||
);
|
||||
|
||||
if (success) {
|
||||
sentCount++;
|
||||
log(`Utility bills notification sent to ${location.tenantEmail}`);
|
||||
} else {
|
||||
log(`Failed to send utility bills notification to ${location.tenantEmail}`);
|
||||
}
|
||||
|
||||
budget--;
|
||||
}
|
||||
|
||||
log(`Sent ${sentCount} utility bills notifications`);
|
||||
return sentCount;
|
||||
}
|
||||
87
email-worker/src/lib/emailTemplates.ts
Normal file
87
email-worker/src/lib/emailTemplates.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const log = createLogger('email:templates');
|
||||
|
||||
// Cache for loaded templates
|
||||
const templateCache = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Template variable type for type-safe template rendering
|
||||
*/
|
||||
export type TemplateVariables = {
|
||||
[key: string]: string | number | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load an email template from the templates directory
|
||||
* @param templateName Name of the template file (without extension)
|
||||
* @param language Language code (default: 'en')
|
||||
* @returns Template content as string
|
||||
*/
|
||||
export function loadTemplate(templateName: string, language: string = 'hr'): string {
|
||||
const cacheKey = `${templateName}--${language}`;
|
||||
|
||||
// Check cache first
|
||||
if (templateCache.has(cacheKey)) {
|
||||
log(`Using cached template: ${cacheKey}`);
|
||||
return templateCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
// Construct template file path
|
||||
const templateFileName = `email-template--${templateName}--${language}.html`;
|
||||
const templatePath = path.join(__dirname, '../../email-templates', templateFileName);
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(templatePath, 'utf-8');
|
||||
templateCache.set(cacheKey, content);
|
||||
log(`Loaded template: ${templateFileName}`);
|
||||
return content;
|
||||
} catch (error) {
|
||||
log(`Failed to load template ${templateFileName}: ${error}`);
|
||||
throw new Error(`Template not found: ${templateFileName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a template by replacing variables
|
||||
* @param template Template content
|
||||
* @param variables Object with variable values
|
||||
* @returns Rendered HTML string
|
||||
*/
|
||||
export function renderTemplate(template: string, variables: TemplateVariables): string {
|
||||
let rendered = template;
|
||||
|
||||
// Replace all ${variable} occurrences
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
if (value !== undefined) {
|
||||
const regex = new RegExp(`\\$\\{${key}\\}`, 'g');
|
||||
rendered = rendered.replace(regex, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
// Log warning if there are unreplaced variables
|
||||
const unreplacedMatches = rendered.match(/\$\{[^}]+\}/g);
|
||||
if (unreplacedMatches) {
|
||||
log(`Warning: Unreplaced variables in template: ${unreplacedMatches.join(', ')}`);
|
||||
}
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and render an email template in one step
|
||||
* @param templateName Name of the template file (without extension)
|
||||
* @param variables Object with variable values
|
||||
* @param language Language code (default: 'en')
|
||||
* @returns Rendered HTML string
|
||||
*/
|
||||
export function loadAndRender(
|
||||
templateName: string,
|
||||
variables: TemplateVariables,
|
||||
language: string = 'hr'
|
||||
): string {
|
||||
const template = loadTemplate(templateName, language);
|
||||
return renderTemplate(template, variables);
|
||||
}
|
||||
8
email-worker/src/lib/initTools.ts
Normal file
8
email-worker/src/lib/initTools.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
/**
|
||||
* Za neinicijaliziranu env varijablu vraća default vrijednost
|
||||
* @param value vrijednost env varijable
|
||||
* @param defaultValue default vrijednost
|
||||
* @returns
|
||||
*/
|
||||
export const coalesce = (value:string|undefined, defaultValue:string):string => value===undefined ? defaultValue : (value==="" ? defaultValue : value);
|
||||
21
email-worker/src/lib/logger.ts
Normal file
21
email-worker/src/lib/logger.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import debug from 'debug';
|
||||
|
||||
/**
|
||||
* Logs to console / stdout
|
||||
* @param namespace
|
||||
* @returns instance of Debug
|
||||
*/
|
||||
export const createLogger = (namespace:string):debug.Debugger => {
|
||||
const dbg = debug(namespace);
|
||||
|
||||
const rx = /nodemon/gi;
|
||||
|
||||
if(rx.test(process.env?.npm_lifecycle_script ?? "")) {
|
||||
// When started via nodemon:
|
||||
// forcing the use of console insted of stdout
|
||||
// -> nodemon doesn't work with stdout
|
||||
dbg.log = console.log.bind(console);
|
||||
}
|
||||
|
||||
return(dbg);
|
||||
};
|
||||
67
email-worker/src/lib/mailgunService.ts
Normal file
67
email-worker/src/lib/mailgunService.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import formData from 'form-data';
|
||||
import Mailgun from 'mailgun.js';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const log = createLogger("email:mailgun");
|
||||
|
||||
export interface EmailMessage {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
let mailgunClient: any = null;
|
||||
|
||||
/**
|
||||
* Initialize Mailgun client
|
||||
*/
|
||||
function getMailgunClient() {
|
||||
if (mailgunClient) {
|
||||
return mailgunClient;
|
||||
}
|
||||
|
||||
const apiKey = process.env.MAILGUN_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('MAILGUN_API_KEY environment variable is not set');
|
||||
}
|
||||
|
||||
const mailgun = new Mailgun(formData);
|
||||
mailgunClient = mailgun.client({
|
||||
username: 'api',
|
||||
key: apiKey,
|
||||
url: "https://api.eu.mailgun.net"
|
||||
});
|
||||
|
||||
return mailgunClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using Mailgun
|
||||
* @param message Email message to send
|
||||
* @returns True if successful, false otherwise
|
||||
*/
|
||||
export async function sendEmail(message: EmailMessage): Promise<boolean> {
|
||||
try {
|
||||
const client = getMailgunClient();
|
||||
const domain = process.env.MAILGUN_DOMAIN || 'rezije.app';
|
||||
|
||||
const messageData = {
|
||||
from: 'rezije.app <noreply@rezije.app>',
|
||||
to: message.to,
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
"v:locationID": "12345" // testing custom variable - webhook should pick this up
|
||||
};
|
||||
|
||||
log(`Sending email to ${message.to}: ${message.subject}`);
|
||||
|
||||
const response = await client.messages.create(domain, messageData);
|
||||
|
||||
log(`Email sent successfully to ${message.to}, ID: ${response.id}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
log(`Failed to send email to ${message.to}: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
50
email-worker/src/lib/metricsCounters.ts
Normal file
50
email-worker/src/lib/metricsCounters.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Counter, Histogram, register } from 'prom-client';
|
||||
import { coalesce } from './initTools';
|
||||
|
||||
/** Histogram Buckets */
|
||||
const PROMETHEUS_HISTOGRAM_BUCKETS = coalesce(process.env.PROMETHEUS_HISTOGRAM_BUCKETS, "0.1, 0.5, 1, 5, 10");
|
||||
|
||||
/** Labela kojom želimo da bude označena metrika prikupljena na ovom web servisu */
|
||||
const PROMETHEUS_APP_LABEL = coalesce(process.env.PROMETHEUS_APP_LABEL, 'email-worker');
|
||||
|
||||
// na "app" labele ćemo razdvajanje rezultata u Grafani
|
||||
register.setDefaultLabels({ app: PROMETHEUS_APP_LABEL });
|
||||
|
||||
/**
|
||||
* Broji koliko je ukupno zahtjeva zaprimljeno za obradu
|
||||
*/
|
||||
export const totalRequestCounter = new Counter({
|
||||
name: "request_operations_total",
|
||||
help: "ukupan broj zaprimljenih zahtjeva",
|
||||
/** countere razdvajamo po vrsti zahtjeva */
|
||||
labelNames: ['path'],
|
||||
});
|
||||
|
||||
/**
|
||||
* Broji zahtjeve koji su uspješno obrađeni
|
||||
*/
|
||||
export const successfulRequestCounter = new Counter({
|
||||
name: "request_operations_ok",
|
||||
help: "broj zahtjeva koji su uspješno obrađeni",
|
||||
/** countere razdvajamo po vrsti zahtjeva */
|
||||
labelNames: ['path'],
|
||||
});
|
||||
|
||||
/**
|
||||
* Broji zahtjeve kod čije obrade je došlo do greške
|
||||
*/
|
||||
export const failedRequestCounter = new Counter({
|
||||
name: "request_operations_failed",
|
||||
help: "broj zahtjeva kod čije obrade je došlo do greške",
|
||||
/** countere razdvajamo po vrsti zahtjeva i rezultatu izvođenja */
|
||||
labelNames: ["path", "status"],
|
||||
});
|
||||
|
||||
/** Histogram mjeri koliko traje obrada pristiglog zahtjeva */
|
||||
export const requestDurationHistogram = new Histogram({
|
||||
name: "request_duration_seconds",
|
||||
help: "Trajanje request-a u sekundama",
|
||||
/** countere razdvajamo po vrsti zahtjeva i rezultatu izvođenja */
|
||||
labelNames: ["path", "status"],
|
||||
buckets: PROMETHEUS_HISTOGRAM_BUCKETS?.split(',').map((el) => parseFloat(el))
|
||||
});
|
||||
19
email-worker/src/lib/serializeError.ts
Normal file
19
email-worker/src/lib/serializeError.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
|
||||
|
||||
/**
|
||||
* This function serializes an error object into a string that can be logged
|
||||
* @param ex error object
|
||||
* @returns string
|
||||
* @description SQL Server may generate more than one error for one request so you can access preceding errors with `err.precedingErrors`, while the `ex` itself is a generic error without any useful information
|
||||
*/
|
||||
export const serializeError = (ex:Error | Error & { precedingErrors?:Error[] }):string => {
|
||||
const { name, message, stack, precedingErrors } = (ex as Error & { precedingErrors?:Error[] });
|
||||
|
||||
// SQL Server may generate more than one error for one request so you can access preceding errors with `ex.precedingErrors`,
|
||||
// while the `ex` itself is a generic error without any useful information
|
||||
if(precedingErrors) {
|
||||
return(serializeError(precedingErrors[0]));
|
||||
}
|
||||
|
||||
return `${name}:${message}`;
|
||||
}
|
||||
81
email-worker/src/routes/errorRouter.ts
Normal file
81
email-worker/src/routes/errorRouter.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { ErrorRequestHandler, Request, Response } from "express";
|
||||
import createHttpError, { HttpError } from "http-errors";
|
||||
import { createLogger } from '../lib/logger';
|
||||
import { NgitLocals } from "../types/NgitLocals";
|
||||
import { failedRequestCounter } from "../lib/metricsCounters";
|
||||
import { SupportedRoutes } from "../types/enums/SupportedRoutes";
|
||||
|
||||
const consoleLog = createLogger("server:server");
|
||||
|
||||
/**
|
||||
* Router koji se zadnji poziva, a koji sastavlja odgovor u slučaju greške
|
||||
* @param err
|
||||
* @param req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
export const errorRouter:ErrorRequestHandler = async (err:HttpError, req, res, next) => {
|
||||
|
||||
const requestPath = req.path as SupportedRoutes;
|
||||
|
||||
// kako je ovaj error handler dosta složen, moguće je da negdje baci grešku
|
||||
// > zato je zamotan u try-catch
|
||||
// > na taj način osiguravam da neće srušiti cijeli proces
|
||||
try {
|
||||
|
||||
let { name:errorLogName, message:errorLogText } = err;
|
||||
let responseBody:string = "";
|
||||
|
||||
switch(err.status) {
|
||||
case 400:
|
||||
responseBody = 'bad request';
|
||||
break;
|
||||
case 401:
|
||||
responseBody = 'unauthorized';
|
||||
break;
|
||||
case 403:
|
||||
responseBody = 'forbidden';
|
||||
break;
|
||||
case 404:
|
||||
consoleLog(`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}`;
|
||||
}
|
||||
|
||||
consoleLog(`${errorLogName}:${errorLogText}`);
|
||||
|
||||
// `headersSent` će biti TRUE ako je router kod kojeg se dogodila greška već poslao header-e
|
||||
// > ako ih probam ponovo postaviti, to će baciti grešku ... a to ovdje mogu izbjeći
|
||||
if(!res.headersSent) {
|
||||
res.status(err.status);
|
||||
res.setHeader('Content-Type', "text/html");
|
||||
res.end(responseBody);
|
||||
} else {
|
||||
// AKO nije pozvan `end` - pozovi ga i završi obradu zahtjeva
|
||||
// ... u suprotnom će konekcija ostati otvorena do timeout-a
|
||||
if(!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
|
||||
} catch(ex:any) {
|
||||
// ovu grešku će obraditi `finalErrorRouter`
|
||||
next(createHttpError(500, ex));
|
||||
}
|
||||
|
||||
// ne mogu dopustiti da prometheus client sruši server
|
||||
try {
|
||||
failedRequestCounter.inc({ path: requestPath, status: err.status });
|
||||
(res.locals as NgitLocals).stopPrometheusTimer({ path: req.path, status: err.status });
|
||||
} catch(ex:any) {
|
||||
console.error(ex);
|
||||
}
|
||||
};
|
||||
34
email-worker/src/routes/finalErrorRouter.ts
Normal file
34
email-worker/src/routes/finalErrorRouter.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ErrorRequestHandler, Request, Response } from "express";
|
||||
import { HttpError } from "http-errors";
|
||||
import { createLogger } from '../lib/logger';
|
||||
import { NgitLocals } from "../types/NgitLocals";
|
||||
|
||||
const consoleLog = createLogger("server:server");
|
||||
|
||||
/**
|
||||
* Router koji se izvršava u slučaju grube greške koja nije obrađena nigdje prije
|
||||
* @param err error objekt
|
||||
* @param req express request
|
||||
* @param res express response
|
||||
* @param next
|
||||
*/
|
||||
export const finalErrorRouter:ErrorRequestHandler = async (err:HttpError, req, res, next) => {
|
||||
|
||||
const errorLogText:string = JSON.stringify({ message:err.message, name:err.name, stack:err.stack });
|
||||
|
||||
consoleLog(`Server Error ${err.status}\n${errorLogText}`);
|
||||
|
||||
// `headersSent` će biti TRUE ako je router kod kojeg se dogodila greška već poslao header-e
|
||||
// > ako ih probam ponovo postaviti, to će baciti grešku i u ovom slučaju SRUŠITI SERVER - to ne smijemo dopustiti
|
||||
if(!res.headersSent) {
|
||||
res.status(err.status);
|
||||
res.setHeader('Content-Type', "text/html");
|
||||
res.end(`unhandled server error`);
|
||||
} else {
|
||||
// AKO nije pozvan `end` - pozovi ga i završi obradu zahtjeva
|
||||
// ... u suprotnom će konekcija ostati otvorena do timeout-a
|
||||
if(!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
};
|
||||
35
email-worker/src/routes/healthcheckRouter.ts
Normal file
35
email-worker/src/routes/healthcheckRouter.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { RequestHandler, Router } from "express";
|
||||
import { workerRunnerInfo } from "../workRunner";
|
||||
import { coalesce } from "../lib/initTools";
|
||||
|
||||
const PULL_INTERVAL = parseInt(coalesce(process.env.PULL_INTERVAL, "10000"));
|
||||
|
||||
/** Maximum time between two worker jobs */
|
||||
const MAX_WORKER_LATENCY = PULL_INTERVAL * 2.5;
|
||||
|
||||
/**
|
||||
* Router koji se izvršava u slučaju grube greške koja nije obrađena nigdje prije
|
||||
* @param req express request
|
||||
* @param res express response
|
||||
* @param next
|
||||
*/
|
||||
export const healthcheckRouter:RequestHandler = async (req, res, next) => {
|
||||
const workerLatency = Date.now() - workerRunnerInfo.lastWorkTime;
|
||||
|
||||
if(workerLatency > MAX_WORKER_LATENCY) {
|
||||
const msg = `No work done in ${workerLatency}ms. Last worker status = "${workerRunnerInfo.status}"`;
|
||||
|
||||
console.warn(msg)
|
||||
|
||||
res.status(500);
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.end(msg);
|
||||
} else {
|
||||
res.status(200);
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.end('OK');
|
||||
}
|
||||
};
|
||||
|
||||
export const pingRouter = Router();
|
||||
pingRouter.get('/', healthcheckRouter);
|
||||
19
email-worker/src/routes/metricsRouter.ts
Normal file
19
email-worker/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 { createLogger } from '../lib/logger';
|
||||
|
||||
const logger = createLogger("server:metrics");
|
||||
|
||||
export const metricsRouter = Router();
|
||||
|
||||
metricsRouter.get('/', async (req:Request, res:Response, next:NextFunction) => {
|
||||
// ne mogu dopustiti da prometheus client sruši server
|
||||
try {
|
||||
logger(`⚡️[server]: GET /metrics`);
|
||||
res.set('Content-Type', register.contentType);
|
||||
res.end(await register.metrics());
|
||||
} catch(ex:any) {
|
||||
next(createError(500, (ex as Error).message));
|
||||
}
|
||||
});
|
||||
16
email-worker/src/routes/pingRouter.ts
Normal file
16
email-worker/src/routes/pingRouter.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { RequestHandler, Router } from "express";
|
||||
|
||||
/**
|
||||
* Router koji se izvršava u slučaju grube greške koja nije obrađena nigdje prije
|
||||
* @param req express request
|
||||
* @param res express response
|
||||
* @param next
|
||||
*/
|
||||
export const pingRequestHandler:RequestHandler = async (req, res, next) => {
|
||||
res.status(200);
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.end('PONG');
|
||||
};
|
||||
|
||||
export const pingRouter = Router();
|
||||
pingRouter.get('/', pingRequestHandler);
|
||||
7
email-worker/src/types/NgitLocals.ts
Normal file
7
email-worker/src/types/NgitLocals.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { LabelValues } from "prom-client";
|
||||
|
||||
/** data assignet to `express.response.locals` */
|
||||
export type NgitLocals = {
|
||||
/** Prometheus client timer */
|
||||
stopPrometheusTimer: (labels?: LabelValues<"path"|"status">) => number,
|
||||
};
|
||||
5
email-worker/src/types/enums/SupportedRoutes.ts
Normal file
5
email-worker/src/types/enums/SupportedRoutes.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum SupportedRoutes {
|
||||
metricsPath='/metrics',
|
||||
ping='/ping',
|
||||
healthcheck='/healthcheck',
|
||||
}
|
||||
60
email-worker/src/types/environment.d.ts
vendored
Normal file
60
email-worker/src/types/environment.d.ts
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
/**
|
||||
* (optional) environment u kojem se proces vrti
|
||||
* @default undefined
|
||||
* */
|
||||
ENV?:"dev"|"jest"
|
||||
/**
|
||||
* (optional) App label to be used in Prometheus (Grafana)
|
||||
* @default "email-worker"
|
||||
* */
|
||||
PROMETHEUS_APP_LABEL?: string
|
||||
/**
|
||||
* (optional) Prometheus histogram bucket sizes (grafana)
|
||||
* @default "0.1, 0.5, 1, 5, 10"
|
||||
* */
|
||||
PROMETHEUS_HISTOGRAM_BUCKETS?: string
|
||||
/**
|
||||
* (required) Pull interval in milliseconds - how often should worker cycle run
|
||||
* @default "10000"
|
||||
* */
|
||||
PULL_INTERVAL:string
|
||||
/**
|
||||
* (required) MongoDB connection URI
|
||||
* */
|
||||
MONGODB_URI: string
|
||||
/**
|
||||
* (required) Mailgun API key for sending emails
|
||||
* */
|
||||
MAILGUN_API_KEY: string
|
||||
/**
|
||||
* (optional) Mailgun domain
|
||||
* @default "rezije.app"
|
||||
* */
|
||||
MAILGUN_DOMAIN?: string
|
||||
/**
|
||||
* (required) Secret key for generating share link checksums
|
||||
* */
|
||||
SHARE_LINK_SECRET: string
|
||||
/**
|
||||
* (optional) Maximum number of emails to send per worker run
|
||||
* @default "10"
|
||||
* */
|
||||
EMAIL_BUDGET?: string
|
||||
/**
|
||||
* (optional) HTTP server port
|
||||
* @default "3000"
|
||||
* */
|
||||
PORT?: string
|
||||
/**
|
||||
* (optional) Debug namespaces for console logging
|
||||
* */
|
||||
DEBUG?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
165
email-worker/src/workRunner.ts
Normal file
165
email-worker/src/workRunner.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { failedRequestCounter, requestDurationHistogram, successfulRequestCounter, totalRequestCounter } from "./lib/metricsCounters";
|
||||
import { coalesce } from "./lib/initTools";
|
||||
import { createLogger } from "./lib/logger";
|
||||
import { serializeError } from "./lib/serializeError";
|
||||
import { doWork } from "./emailWorker";
|
||||
|
||||
/** time between two pull operations */
|
||||
const PULL_INTERVAL = parseInt(coalesce(process.env.PULL_INTERVAL, "10000"));
|
||||
const consoleLog = createLogger("server:server");
|
||||
|
||||
/** Writes entry to log */
|
||||
const logWrite = (logTitle:string, logMessage:string) => {
|
||||
consoleLog(`${logTitle}: ${logMessage}}`);
|
||||
}
|
||||
|
||||
/** Writes error to log */
|
||||
const logError = (ex: any) =>
|
||||
logWrite(serializeError(ex), "error");
|
||||
|
||||
/**
|
||||
* zastavica za zaustavljanje sinhronizacije
|
||||
*/
|
||||
let disposed:boolean = false;
|
||||
/** is worker started - prevents multiple starts */
|
||||
let workerStarted:boolean = false;
|
||||
/** Promise which is resolved once the pending work in progress is completed */
|
||||
let pendingWork:Promise<void>|undefined;
|
||||
/** Worker re-run timeout */
|
||||
let pendingTimeout:NodeJS.Timeout|undefined;
|
||||
|
||||
/** Enumeracija pojedinih statusa obrade jednog work-a */
|
||||
export enum WorkerRunnerStatus {
|
||||
init="init",
|
||||
disposed="disposed",
|
||||
beginWork="beginWork",
|
||||
updatedStats1="updatedStats1",
|
||||
updatedStats2="updatedStats2",
|
||||
stoppedStatTimer="stoppedStatTimer",
|
||||
workDone="workDone",
|
||||
newIntervalScheduled="newIntervalScheduled",
|
||||
currentWorkResolved="currentWorkResolved",
|
||||
}
|
||||
|
||||
/** Info o statusu workera */
|
||||
export type WorkerRunnerInfo = {
|
||||
/** zadnje izvršena readnja */
|
||||
status: WorkerRunnerStatus,
|
||||
/** vrijeme kada je worker zadnji puta pokrenut */
|
||||
lastWorkTime: number,
|
||||
}
|
||||
|
||||
/** Info o statusu workera, koji koristi healthcheck kako bi vidio da li stvar funkcionira */
|
||||
export const workerRunnerInfo:WorkerRunnerInfo = {
|
||||
status: WorkerRunnerStatus.init,
|
||||
lastWorkTime: Date.now()
|
||||
}
|
||||
|
||||
export const workRunner = async () => {
|
||||
|
||||
pendingTimeout = undefined;
|
||||
workerRunnerInfo.lastWorkTime = Date.now();
|
||||
workerRunnerInfo.status = WorkerRunnerStatus.beginWork;
|
||||
|
||||
// AKO je modul zaustavljen
|
||||
// -> nemoj se pokrenuti
|
||||
if(disposed) {
|
||||
workerRunnerInfo.status = WorkerRunnerStatus.disposed;
|
||||
return;
|
||||
}
|
||||
|
||||
// kreiram Promise koji omogućuje da dispose zna
|
||||
// pričekati da worker završi sa poslom (ako je u tom trenutku aktivan)
|
||||
pendingWork = new Promise(async (resolve) => {
|
||||
|
||||
try {
|
||||
totalRequestCounter.inc();
|
||||
|
||||
const stopPrometheusTimer = requestDurationHistogram.startTimer();
|
||||
|
||||
workerRunnerInfo.status = WorkerRunnerStatus.updatedStats1;
|
||||
|
||||
try {
|
||||
// ne dopuštam da stvar sruši worker
|
||||
await doWork();
|
||||
|
||||
workerRunnerInfo.status = WorkerRunnerStatus.workDone;
|
||||
|
||||
// ažuriram statistiku
|
||||
successfulRequestCounter.inc();
|
||||
|
||||
workerRunnerInfo.status = WorkerRunnerStatus.updatedStats2;
|
||||
} catch(ex:any) {
|
||||
|
||||
// ažuriram statistiku
|
||||
failedRequestCounter.inc();
|
||||
logError(ex);
|
||||
}
|
||||
|
||||
stopPrometheusTimer();
|
||||
|
||||
workerRunnerInfo.status = WorkerRunnerStatus.stoppedStatTimer;
|
||||
} catch(ex:any) {
|
||||
logError(ex);
|
||||
}
|
||||
|
||||
// nemoj pokrenuti timer ako je worker u međuvremenu disposed
|
||||
if(!disposed) {
|
||||
// pull again after timeout
|
||||
pendingTimeout = setTimeout(workRunner, PULL_INTERVAL);
|
||||
workerRunnerInfo.status = WorkerRunnerStatus.newIntervalScheduled;
|
||||
} else {
|
||||
logWrite("Info", "... exiting worker loop");
|
||||
}
|
||||
|
||||
resolve();
|
||||
|
||||
workerRunnerInfo.status = WorkerRunnerStatus.currentWorkResolved;
|
||||
|
||||
pendingWork = undefined;
|
||||
});
|
||||
|
||||
// this is an async function which must return a promise
|
||||
// > so return the promise which will be resolved once the work is done
|
||||
return(pendingWork);
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts the worker
|
||||
*/
|
||||
export const startSyncWorker = () => {
|
||||
if(!workerStarted && !disposed) {
|
||||
workerStarted = true;
|
||||
workRunner();
|
||||
logWrite("Info", "Worker Started");
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops and disposes the worker
|
||||
*/
|
||||
export const disposeSyncWorker = async () => {
|
||||
logWrite("Info", "Disposing worker ...");
|
||||
|
||||
disposed = true;
|
||||
|
||||
// preventing timer from trigger another work cycle
|
||||
if(pendingTimeout) {
|
||||
clearTimeout(pendingTimeout);
|
||||
}
|
||||
|
||||
// IF no work is currently in progress
|
||||
// > return a resolved promise
|
||||
if(!pendingWork) {
|
||||
return(Promise.resolve());
|
||||
}
|
||||
|
||||
await pendingWork;
|
||||
|
||||
logWrite("Info", "Worker disposed!");
|
||||
}
|
||||
|
||||
/** Ovo se koristi samo za Unit Testing */
|
||||
export const reset_dispose = () => {
|
||||
disposed = false;
|
||||
}
|
||||
29
email-worker/tests/__mocks__/prom-client.ts
Normal file
29
email-worker/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();
|
||||
33
email-worker/tests/helpers/mockHttpContext.ts
Normal file
33
email-worker/tests/helpers/mockHttpContext.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { NgitLocals } from "../../src/types/NgitLocals";
|
||||
|
||||
interface IMockHttpContext {
|
||||
reqPath?:string
|
||||
headersSent?:boolean
|
||||
writableEnded?:boolean
|
||||
method?:string
|
||||
}
|
||||
|
||||
export const mockHttpContext = ({reqPath="/", headersSent=false, writableEnded=false, method="GET"}: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(),
|
||||
locals: {
|
||||
stopPrometheusTimer: jest.fn(),
|
||||
} as unknown as NgitLocals,
|
||||
headersSent,
|
||||
writableEnded,
|
||||
} as unknown as Response;
|
||||
|
||||
const next:NextFunction = jest.fn();
|
||||
|
||||
return({req,res,next})
|
||||
}
|
||||
118
email-worker/tests/routers/errorRouter.spec.ts
Normal file
118
email-worker/tests/routers/errorRouter.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
|
||||
import { errorRouter } from '../../src/routes/errorRouter';
|
||||
import createError from "http-errors";
|
||||
import { mockHttpContext } from "../helpers/mockHttpContext";
|
||||
|
||||
describe("errorRouter", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockWrite.mockClear();
|
||||
});
|
||||
|
||||
test("u slučaju greške 404 mora vratiti string poruku 'page not found'", 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("u slučaju greške 404 mora logirati request, response i tekst greške", async () => {
|
||||
const err = createError(404)
|
||||
const reqPath = "/neki-path/";
|
||||
const {req,res,next} = mockHttpContext({ reqPath });
|
||||
|
||||
|
||||
await errorRouter(err, req, res, next);
|
||||
|
||||
expect(res.locals.logger.info).toHaveBeenCalledWith("response", "page not found");
|
||||
expect(res.locals.logger.error).toHaveBeenCalledWith(err.name, "page "+req.path+" not found");
|
||||
});
|
||||
|
||||
test("ako su header-i već poslani, ne smiju biti poslani još jednom", 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("ako NIJE već pozvana [end] metoda, treba je pozvati", 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("mora zaustaviti 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("u slučaju greške 500 mora vratiti string poruku 'internal server 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("u slučaju greške 400 mora vratiti string poruku 'bad request' i logirati grešku", 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(res.locals.logger.errorwrite).toHaveBeenCalledWith(err.name, errorMessage);
|
||||
});
|
||||
|
||||
test("u slučaju greške 401 mora vratiti string poruku 'unauthorized' i logirati grešku", 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(res.locals.logger.error).toHaveBeenCalledWith(err.name, errorMessage);
|
||||
});
|
||||
|
||||
test("u slučaju greške 403 mora vratiti string poruku 'forbidden' i logirati grešku", 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(res.locals.logger.error).toHaveBeenCalledWith(err.name, errorMessage);
|
||||
});
|
||||
});
|
||||
36
email-worker/tsconfig.json
Normal file
36
email-worker/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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" ---- se NE SMIJE koristiti
|
||||
// POJAŠNJENJE: ako zadamo "baseUrl" Intellisense će početi kod autocompletion-a (Ctrl+Space)
|
||||
// umjesto relativnih insertirati apsolutni path do modula,
|
||||
// a takav path nije dobar za build niti debugging
|
||||
// "baseUrl": "./", // set a base directory to resolve non-absolute module names - This must be specified if "paths" is used
|
||||
"paths": {
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
// Slijedeće je namijenjeno BUILD projekta
|
||||
// POJAŠNJENJE: build tadi `ttypescript`
|
||||
// koji ne zna interpretirati što je podešeno pod "path"
|
||||
// > to za njega rješava "typescript-transform-paths"
|
||||
"transform": "typescript-transform-paths"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": ["src/**/*"], // location of files which need to be compiled
|
||||
// Slijedeće je namijenjeno DEBUGGING servera u VS Code-u
|
||||
// POJAŠNJENJE: kod debugginga modul se pokreće pomoću `ts-node`,
|
||||
// koji ne zna sam interpretirati što je podešeno pod "paths"
|
||||
// > to za njega rješava "tsconfig-paths/register"
|
||||
"ts-node": {
|
||||
"require": ["tsconfig-paths/register"]
|
||||
},
|
||||
}
|
||||
@@ -17,8 +17,12 @@
|
||||
"path": "mailgun-webhook"
|
||||
},
|
||||
{
|
||||
"name": "⚙️ email-server-worker",
|
||||
"path": "email-server-worker"
|
||||
"name": "⚙️ email-worker",
|
||||
"path": "email-worker"
|
||||
},
|
||||
{
|
||||
"name": "🔗 shared-code",
|
||||
"path": "shared-code"
|
||||
},
|
||||
{
|
||||
"name": "📦 root",
|
||||
|
||||
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": {
|
||||
}
|
||||
}
|
||||
66
mailgun-webhook/Dockerfile
Normal file
66
mailgun-webhook/Dockerfile
Normal file
@@ -0,0 +1,66 @@
|
||||
#--------------------------------------------
|
||||
# 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: installing production node_modules
|
||||
#--------------------------------------------
|
||||
FROM node:20 AS package-stage
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./package*.json ./
|
||||
|
||||
# install ONLY production dependencies
|
||||
RUN npm i --omit=dev && npm cache clean --force
|
||||
|
||||
#--------------------------------------------
|
||||
# Stage: preparing final image
|
||||
#--------------------------------------------
|
||||
FROM gcr.io/distroless/nodejs20-debian12:nonroot 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}
|
||||
|
||||
# (optional) enables logging to stdout
|
||||
ARG DEBUG
|
||||
ENV DEBUG=${DEBUG}
|
||||
|
||||
# copying node_modules
|
||||
COPY --from=package-stage /app/package*.json ./
|
||||
COPY --from=package-stage /app/node_modules ./node_modules
|
||||
|
||||
# copying built files
|
||||
COPY --from=build-stage /app/build ./server
|
||||
|
||||
# running the server under limited "nobody" user
|
||||
USER nobody:nobody
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||
CMD ["/nodejs/bin/node", "./server/healthcheck.js"]
|
||||
|
||||
# starting the 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
|
||||
70
mailgun-webhook/build-image.sh
Executable file
70
mailgun-webhook/build-image.sh
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Parse flags
|
||||
AUTO_VERSION=false
|
||||
AUTO_PUSH=false
|
||||
IMAGE_VERSION=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--auto-version)
|
||||
AUTO_VERSION=true
|
||||
;;
|
||||
--auto-push)
|
||||
AUTO_PUSH=true
|
||||
;;
|
||||
*)
|
||||
if [ "$IMAGE_VERSION" == "" ] && [[ ! "$arg" =~ ^-- ]]; then
|
||||
IMAGE_VERSION=$arg
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Determine version
|
||||
if [ "$AUTO_VERSION" = true ]; then
|
||||
IMAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
printf "\nAuto-version enabled. Using version from package.json: %s\n" "$IMAGE_VERSION"
|
||||
elif [ "$IMAGE_VERSION" == "" ]; then
|
||||
printf "\nYou did not specify the Docker image version to build"
|
||||
printf "\n\nSyntax:\n\n build-image.sh <version> [--auto-push]"
|
||||
printf "\n build-image.sh --auto-version [--auto-push]\n\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REGISTRY_URL="registry.budakova.org"
|
||||
IMAGE_NAME=$(node -p "require('./package.json').name")
|
||||
IMAGE_TAG=$REGISTRY_URL/$IMAGE_NAME:$IMAGE_VERSION
|
||||
|
||||
# Check if image already exists in registry (only when using auto-version)
|
||||
if [ "$AUTO_VERSION" = true ]; then
|
||||
printf "\nChecking if image %s already exists in registry...\n" "$IMAGE_TAG"
|
||||
if docker manifest inspect $IMAGE_TAG > /dev/null 2>&1; then
|
||||
printf "\nERROR: Image %s already exists in registry.\n" "$IMAGE_TAG"
|
||||
printf "Please update the version in package.json before building.\n\n"
|
||||
exit 1
|
||||
fi
|
||||
printf "Image does not exist in registry. Proceeding with build.\n"
|
||||
fi
|
||||
|
||||
# Check for push preference
|
||||
if [ "$AUTO_PUSH" = true ]; then
|
||||
PUSH_IMAGE="y"
|
||||
printf "\nAuto-push enabled. Image will be pushed to registry.\n"
|
||||
else
|
||||
read -p "BUILD: Push new image to registry [y/n]? " -n 1 -r
|
||||
echo # (optional) move to a new line
|
||||
PUSH_IMAGE="$REPLY"
|
||||
fi
|
||||
|
||||
printf "\nBUILD START ...\n\n"
|
||||
|
||||
docker build . -t $IMAGE_TAG
|
||||
|
||||
if [[ "$PUSH_IMAGE" =~ ^[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 (implemented via CloudFlare)
|
||||
- **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.1",
|
||||
"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);
|
||||
201
mailgun-webhook/src/routes/webhookRouter.ts
Normal file
201
mailgun-webhook/src/routes/webhookRouter.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* MailGun Webhook Router
|
||||
*
|
||||
* Handles incoming webhook events from MailGun and logs them to the console.
|
||||
* This router processes POST requests containing email event data such as
|
||||
* delivered, failed, opened, clicked, bounced, complained, and unsubscribed events.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import createError from 'http-errors';
|
||||
import { AppLocals } from '../types/AppLocals';
|
||||
import { isMailgunWebhookPayload, isValidMailgunEvent, MailgunWebhookEvent, MailgunWebhookPayloadSignature } from '../types/MailgunWebhookEvent';
|
||||
import { successfulRequestCounter } from '../lib/metricsCounters';
|
||||
import { logInfo, logError, logWarn } from '../lib/logger';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const WEBHOOK_SIGNING_KEY = process.env.MAILGUN_WEBHOOK_SIGNING_KEY;
|
||||
|
||||
if (!WEBHOOK_SIGNING_KEY) {
|
||||
logError('Configuration Error', 'MAILGUN_WEBHOOK_SIGNING_KEY environment variable is not set');
|
||||
throw new Error('MAILGUN_WEBHOOK_SIGNING_KEY environment variable is required');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the MailGun webhook signature
|
||||
* @param param0 - Object containing signingKey, timestamp, token, and signature
|
||||
* @returns boolean indicating if the signature is valid
|
||||
*/
|
||||
const verifySignature = ({ timestamp, token, signature }: MailgunWebhookPayloadSignature) => {
|
||||
const encodedToken = crypto
|
||||
.createHmac('sha256', WEBHOOK_SIGNING_KEY)
|
||||
.update(timestamp.concat(token))
|
||||
.digest('hex')
|
||||
|
||||
return (encodedToken === signature)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a Unix timestamp into a human-readable date string
|
||||
* @param timestamp - Unix timestamp as string
|
||||
* @returns Formatted date string in ISO format
|
||||
*/
|
||||
const formatTimestamp = (timestamp: string): string => {
|
||||
try {
|
||||
const timestampNum = parseInt(timestamp, 10);
|
||||
if (isNaN(timestampNum)) {
|
||||
return 'Invalid timestamp';
|
||||
}
|
||||
return new Date(timestampNum * 1000).toISOString();
|
||||
} catch (error) {
|
||||
return 'Invalid timestamp';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Logs MailGun webhook event data to console with structured formatting
|
||||
* @param eventData - The MailGun webhook event data
|
||||
*/
|
||||
const logWebhookEvent = (eventData: MailgunWebhookEvent): void => {
|
||||
const separator = '========================================';
|
||||
const minorSeparator = '----------------------------------------';
|
||||
|
||||
console.log('\n' + separator);
|
||||
console.log('MailGun Webhook Event Received');
|
||||
console.log(separator);
|
||||
console.log(`Event Type: ${eventData.event}`);
|
||||
console.log(`Timestamp: ${eventData.timestamp} (${formatTimestamp(eventData.timestamp)})`);
|
||||
console.log(`Recipient: ${eventData.recipient}`);
|
||||
console.log(`Domain: ${eventData.domain}`);
|
||||
|
||||
if (eventData['message-id']) {
|
||||
console.log(`MailGun Message ID: ${eventData['message-id']}`);
|
||||
}
|
||||
if (eventData['Message-Id']) {
|
||||
console.log(`SMTP Message ID: ${eventData['Message-Id']}`);
|
||||
}
|
||||
|
||||
// Log event-specific data
|
||||
console.log(minorSeparator);
|
||||
console.log('Event-Specific Data:');
|
||||
|
||||
switch (eventData.event) {
|
||||
case 'delivered':
|
||||
if (eventData['message-headers']) {
|
||||
console.log(`Message Headers: ${eventData['message-headers'].substring(0, 200)}...`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'failed':
|
||||
if (eventData.severity) console.log(`Severity: ${eventData.severity}`);
|
||||
if (eventData.reason) console.log(`Reason: ${eventData.reason}`);
|
||||
if (eventData.notification) console.log(`Notification: ${eventData.notification}`);
|
||||
break;
|
||||
|
||||
case 'opened':
|
||||
if (eventData.city) console.log(`City: ${eventData.city}`);
|
||||
if (eventData.country) console.log(`Country: ${eventData.country}`);
|
||||
if (eventData['device-type']) console.log(`Device Type: ${eventData['device-type']}`);
|
||||
if (eventData['client-os']) console.log(`Client OS: ${eventData['client-os']}`);
|
||||
if (eventData['client-name']) console.log(`Client Name: ${eventData['client-name']}`);
|
||||
if (eventData.ip) console.log(`IP Address: ${eventData.ip}`);
|
||||
break;
|
||||
|
||||
case 'clicked':
|
||||
console.log(`URL Clicked: ${eventData.url}`);
|
||||
if (eventData.city) console.log(`City: ${eventData.city}`);
|
||||
if (eventData.country) console.log(`Country: ${eventData.country}`);
|
||||
if (eventData['device-type']) console.log(`Device Type: ${eventData['device-type']}`);
|
||||
if (eventData['client-os']) console.log(`Client OS: ${eventData['client-os']}`);
|
||||
if (eventData['client-name']) console.log(`Client Name: ${eventData['client-name']}`);
|
||||
if (eventData.ip) console.log(`IP Address: ${eventData.ip}`);
|
||||
break;
|
||||
|
||||
case 'bounced':
|
||||
if (eventData.code) console.log(`SMTP Code: ${eventData.code}`);
|
||||
if (eventData.error) console.log(`Error: ${eventData.error}`);
|
||||
if (eventData.notification) console.log(`Notification: ${eventData.notification}`);
|
||||
break;
|
||||
|
||||
case 'complained':
|
||||
console.log('User marked email as spam');
|
||||
break;
|
||||
|
||||
case 'unsubscribed':
|
||||
console.log('User unsubscribed from mailing list');
|
||||
break;
|
||||
}
|
||||
|
||||
// Log full event data for debugging
|
||||
console.log(minorSeparator);
|
||||
console.log('Full Event Data (JSON):');
|
||||
console.log(JSON.stringify(eventData, null, 2));
|
||||
console.log(separator + '\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Main webhook request handler
|
||||
* Processes incoming MailGun webhook events and logs them to console
|
||||
*/
|
||||
export const webhookRequestHandler = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
const payload = req.body as any;
|
||||
|
||||
if(!isMailgunWebhookPayload(payload)) {
|
||||
logWarn('Invalid webhook payload structure', JSON.stringify(payload));
|
||||
next(createError(400, 'Invalid request format: payload structure is incorrect'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { signature, "event-data": eventData } = payload;
|
||||
|
||||
// Validate that we have the minimum required fields
|
||||
if (!isValidMailgunEvent(eventData)) {
|
||||
logWarn('Invalid webhook event received', JSON.stringify(eventData));
|
||||
next(createError(400, 'Invalid request format: missing required fields (event, recipient)'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify webhook signature
|
||||
const isValidSignature = verifySignature({
|
||||
timestamp: signature.timestamp,
|
||||
token: signature.token,
|
||||
signature: signature.signature
|
||||
});
|
||||
|
||||
if(!isValidSignature) {
|
||||
logWarn('Invalid webhook signature', JSON.stringify(signature));
|
||||
next(createError(401, 'Unauthorized: invalid webhook signature'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Log the webhook event to console
|
||||
logWebhookEvent(eventData as MailgunWebhookEvent);
|
||||
|
||||
// Log using the structured logger as well
|
||||
logInfo('MailGun webhook event processed', `Event: ${eventData.event}, Recipient: ${eventData.recipient}`);
|
||||
|
||||
// Return success response to MailGun
|
||||
res.status(200).json({
|
||||
status: 'received',
|
||||
message: 'Webhook event logged successfully'
|
||||
});
|
||||
|
||||
// Increment successful requests counter for metrics
|
||||
successfulRequestCounter.inc();
|
||||
} catch (ex: any) {
|
||||
logError('Error processing webhook', ex.message);
|
||||
next(createError(500, 'Internal server error'));
|
||||
}
|
||||
|
||||
// Stop Prometheus timer
|
||||
(res.locals as AppLocals).stopPrometheusTimer({ path: req.path });
|
||||
};
|
||||
|
||||
/**
|
||||
* Express router for MailGun webhook endpoint
|
||||
*/
|
||||
const router = Router();
|
||||
|
||||
export const webhookRouter = router.post('/', webhookRequestHandler);
|
||||
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,
|
||||
};
|
||||
188
mailgun-webhook/src/types/MailgunWebhookEvent.ts
Normal file
188
mailgun-webhook/src/types/MailgunWebhookEvent.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Type definitions for MailGun webhook events
|
||||
*/
|
||||
|
||||
/**
|
||||
* All possible MailGun webhook event types
|
||||
*/
|
||||
export type MailgunEventType =
|
||||
| 'delivered'
|
||||
| 'failed'
|
||||
| 'opened'
|
||||
| 'clicked'
|
||||
| 'bounced'
|
||||
| 'complained'
|
||||
| 'unsubscribed';
|
||||
|
||||
export interface MailgunWebhookPayload {
|
||||
signature: MailgunWebhookPayloadSignature;
|
||||
"event-data": MailgunWebhookEvent;
|
||||
}
|
||||
|
||||
export interface MailgunWebhookPayloadSignature {
|
||||
token: string;
|
||||
timestamp: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if data is a MailgunWebhookPayload
|
||||
*/
|
||||
export function isMailgunWebhookPayload(data: any): data is MailgunWebhookPayload {
|
||||
return (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
typeof data.signature === 'object' &&
|
||||
typeof data['event-data'] === 'object'
|
||||
);
|
||||
}
|
||||
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',
|
||||
}
|
||||
20
mailgun-webhook/src/types/environment.d.ts
vendored
Normal file
20
mailgun-webhook/src/types/environment.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"]
|
||||
},
|
||||
}
|
||||
16888
package-lock.json
generated
Normal file
16888
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "evidencija-rezija-monorepo",
|
||||
"version": "1.0.0",
|
||||
"description": "Property management and utility bills tracking monorepo",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"web-app",
|
||||
"email-worker",
|
||||
"shared-code",
|
||||
"mailgun-webhook"
|
||||
],
|
||||
"scripts": {
|
||||
"install:all": "npm install",
|
||||
"build:web-app": "npm run build --workspace=web-app",
|
||||
"build:email-worker": "npm run build --workspace=email-worker",
|
||||
"dev:web-app": "npm run dev --workspace=web-app",
|
||||
"dev:email-worker": "npm run start --workspace=email-worker"
|
||||
},
|
||||
"keywords": [
|
||||
"property-management",
|
||||
"utility-bills",
|
||||
"monorepo"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
}
|
||||
14
shared-code/package.json
Normal file
14
shared-code/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@evidencija-rezija/shared-code",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared code for web-app and email-worker",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"mongodb": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -41,12 +41,45 @@ export enum EmailStatus {
|
||||
Unverified = "unverified",
|
||||
/** Email is not yet verified - a verification request has been sent */
|
||||
VerificationPending = "verification-pending",
|
||||
/** sending of verification email failed */
|
||||
VerificationFailed = "verification-failed",
|
||||
/** Email is verified and is in good standing: emails are being successfully delivered */
|
||||
Verified = "verified",
|
||||
/** Recepient has unsubscribed from receiving emails via link - no further emails will be sent */
|
||||
Unsubscribed = "unsubscribed"
|
||||
}
|
||||
|
||||
export enum BillsNotificationStrategy {
|
||||
/** Notify tenant when bill is payed */
|
||||
WhenPayed = "when-payed",
|
||||
/** Notify tenant when bill gets an attachment */
|
||||
WhenAttached = "when-attached"
|
||||
}
|
||||
|
||||
export enum BillsNotificationStatus {
|
||||
/** notification is scheduled to be sent */
|
||||
Scheduled = "scheduled",
|
||||
/** notification has been sent */
|
||||
Sent = "sent",
|
||||
/** notification has been successfully delivered (set by webhook) */
|
||||
Delivered = "delivered",
|
||||
/** Sending of notifications failed */
|
||||
Failed = "failed",
|
||||
/** Email to tenant has bounced (set by webhook) */
|
||||
Bounced = "bounced"
|
||||
}
|
||||
|
||||
export enum RentNotificationStatus {
|
||||
/** notification has been sent */
|
||||
Sent = "sent",
|
||||
/** notification has been successfully delivered (set by webhook) */
|
||||
Delivered = "delivered",
|
||||
/** Sending of notification failed */
|
||||
Failed = "failed",
|
||||
/** Email to tenant has bounced (set by webhook) */
|
||||
Bounced = "bounced"
|
||||
}
|
||||
|
||||
/** bill object in the form returned by MongoDB */
|
||||
export interface BillingLocation {
|
||||
_id: string;
|
||||
@@ -75,26 +108,32 @@ export interface BillingLocation {
|
||||
tenantStreet?: string | null;
|
||||
/** (optional) tenant town */
|
||||
tenantTown?: string | null;
|
||||
/** (optional) whether to automatically notify tenant */
|
||||
autoBillFwd?: boolean | null;
|
||||
/** (optional) tenant email */
|
||||
tenantEmail?: string | null;
|
||||
/** (optional) tenant email status */
|
||||
tenantEmailStatus?: EmailStatus | null;
|
||||
/** (optional) language for tenant notification emails */
|
||||
tenantEmailLanguage?: "hr" | "en" | null;
|
||||
/** (optional) whether to automatically notify tenant */
|
||||
billsNotificationEnabled?: boolean | null;
|
||||
/** (optional) bill forwarding strategy */
|
||||
billFwdStrategy?: "when-payed" | "when-attached" | null;
|
||||
billsNotificationStrategy?: BillsNotificationStrategy | null;
|
||||
/** (optional) bill forwarding status */
|
||||
billsNotificationStatus?: BillsNotificationStatus | null;
|
||||
/** (optional) utility bills proof of payment attachment */
|
||||
billsProofOfPayment?: FileAttachment|null;
|
||||
/** (optional) whether to automatically send rent notification */
|
||||
rentDueNotification?: boolean | null;
|
||||
rentNotificationEnabled?: boolean | null;
|
||||
/** (optional) when was the rent due notification sent */
|
||||
rentNotificationStatus?: RentNotificationStatus | null;
|
||||
/** (optional) rent proof of payment attachment */
|
||||
rentProofOfPayment?: FileAttachment|null;
|
||||
/** (optional) day of month when rent is due (1-31) */
|
||||
rentDueDay?: number | null;
|
||||
/** (optional) monthly rent amount in cents */
|
||||
rentAmount?: number | null;
|
||||
/** (optional) whether the location has been seen by tenant */
|
||||
seenByTenantAt?: Date | null;
|
||||
/** (optional) utility bills proof of payment attachment */
|
||||
utilBillsProofOfPayment?: FileAttachment|null;
|
||||
/** (optional) rent proof of payment attachment */
|
||||
rentProofOfPayment?: FileAttachment|null;
|
||||
/** (optional) share link expiry timestamp */
|
||||
shareTTL?: Date;
|
||||
/** (optional) when tenant first visited the share link */
|
||||
5
shared-code/src/index.ts
Normal file
5
shared-code/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Database types
|
||||
export * from './db-types';
|
||||
|
||||
// Share checksum utilities
|
||||
export * from './shareChecksum';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user