REST API for managing email aliases, forwarding rules, and API credentials. Built with NestJS 11, backed by MariaDB, with optional Redis for distributed rate limiting.
Important
This repository may be out of date or may not accurately reflect the current API logic. The most up-to-date documentation will always be available at: https://mail.thc.org/docs/
- Email alias creation, forwarding, and deactivation with email confirmation
- Handle claiming: reserve a local-part across all managed domains with permanent ownership
- API key authentication for programmatic alias and handle management
- Admin panel API with full CRUD for aliases, domains, handles, bans, API tokens, DNS requests, and users
- Admin ban creation can optionally disable matching active aliases for
email,domain, andnamebans - JWT-based admin authentication (EdDSA/Ed25519) with session families and refresh token rotation
- CSRF protection on admin mutation endpoints
- Password hashing with Argon2id
- Password reset flow with email confirmation
- Multi-layered rate limiting (per-IP, per-email, per-key) with delay and hard-limit strategies
- IP ban middleware
- DNS verification relay to an external check-dns service
- Configurable CORS with origin validation
- Structured JSON logging with automatic sensitive field redaction
- Multi-tenant origin policy support
The application follows a modular NestJS architecture with clear separation between controllers, services, and repositories. There is no ORM; all database access uses parameterized SQL queries through a thin DatabaseService abstraction over the MariaDB connection pool.
Request
-> Express middleware (request context, IP ban, rate limiting)
-> NestJS guards (API key auth, admin session auth)
-> NestJS interceptors (audit logging, sensitive header removal)
-> Controller
-> Service (business logic, validation, ban checks)
-> Repository (raw SQL via DatabaseService)
-> MariaDB
Cross-cutting concerns (logging, exception handling, CORS, tenant policy) are provided globally via InfrastructureModule. Redis is optional and used only for distributed rate-limit counters; when unavailable, the system falls back to in-memory counters.
app/
├── src/
│ ├── main.ts # Bootstrap, global prefix (/api), pipes, filters
│ ├── app.module.ts # Root module, middleware registration
│ ├── types/ # Express augmentation, ambient types
│ ├── modules/
│ │ ├── admin/ # Admin panel API
│ │ │ ├── admin.module.ts
│ │ │ ├── admin.controller.ts # /admin/me, /admin/protected
│ │ │ ├── aliases/ # CRUD for email aliases
│ │ │ ├── api-tokens/ # CRUD for API tokens
│ │ │ ├── bans/ # CRUD for bans (IP, domain, email, name)
│ │ │ ├── dns-requests/ # CRUD for DNS verification requests
│ │ │ ├── domains/ # CRUD for mail domains
│ │ │ ├── handles/ # CRUD for reserved handles
│ │ │ ├── users/ # CRUD for admin users, notifications
│ │ │ ├── session/ # Admin session resolution
│ │ │ ├── middlewares/ # Admin route auth, CSRF for mutations
│ │ │ ├── pipes/ # ParseIdPipe (param validation)
│ │ │ ├── dto/ # Shared admin DTOs
│ │ │ └── utils/ # Admin helpers, database utils
│ │ ├── api/ # Public API (key-authenticated)
│ │ │ ├── controllers/ # Alias operations, credential creation
│ │ │ ├── services/ # Alias logic, credentials, email
│ │ │ ├── repositories/ # Alias, API tokens, logs, activity
│ │ │ ├── guards/ # API key guard
│ │ │ ├── interceptors/ # API audit log interceptor
│ │ │ ├── dto/ # Request DTOs
│ │ │ └── templates/ # HTML confirmation page templates
│ │ ├── auth/ # Admin authentication
│ │ │ ├── services/ # Sign-in, session, password, reset email
│ │ │ ├── repositories/ # Users, password reset requests
│ │ │ └── dto/ # Sign-in, forgot/reset password DTOs
│ │ ├── forwarding/ # Email forwarding subscription
│ │ │ ├── services/ # Subscribe/unsubscribe, email confirmation
│ │ │ ├── repositories/ # Confirmation state tracking
│ │ │ └── dto/ # Confirm body DTO
│ │ ├── handle/ # Handle claiming and management
│ │ │ ├── services/ # Public (confirmation) and API-key flows
│ │ │ └── repositories/ # Handle and disabled-domain persistence
│ │ ├── domains/ # Active domain listing (cached)
│ │ ├── bans/ # Ban policy evaluation
│ │ ├── check-dns/ # DNS verification relay
│ │ └── stats/ # Alias metrics and forward counter
│ └── shared/
│ ├── infrastructure.module.ts # Global providers
│ ├── config/ # Typed config factories, env validation
│ ├── database/ # DatabaseService (MariaDB pool, transactions)
│ ├── redis/ # RedisService (optional, lazy init)
│ ├── logging/ # AppLogger, request context middleware
│ ├── errors/ # HttpExceptionFilter, PublicHttpException
│ ├── http/ # NoCacheInterceptor, SensitiveHeadersInterceptor, pagination
│ ├── security/ # CORS factory, IP ban middleware, rate limiting
│ ├── tenancy/ # Tenant origin policy
│ ├── validation/ # Mailbox parsing, domain validation, content-type guard
│ └── utils/ # JWT, cookies, CSRF, crypto, email templates
├── test/ # Unit and integration tests (Jest)
├── package.json
├── tsconfig.json
├── tsconfig.build.json
├── nest-cli.json
├── jest.config.ts
├── eslint.config.mjs
├── deploy.sh # PM2 deployment script
└── .env.example # Environment variable reference
Prerequisites: Node.js >= 20, MariaDB instance, an external check-dns service.
cd app
npm install
cp .env.example .env
# Edit .env with your database credentials, JWT keys, and other required valuesThe auth system uses EdDSA (Ed25519) for access tokens. Generate a key pair:
node -e "
const { generateKeyPairSync } = require('crypto');
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
console.log('Private (PEM):');
console.log(privateKey.export({ type: 'pkcs8', format: 'pem' }));
console.log('Public (PEM):');
console.log(publicKey.export({ type: 'spki', format: 'pem' }));
"Set JWT_ACCESS_PRIVATE_KEY to the private key PEM and JWT_ACCESS_VERIFY_KEYS to a JSON object mapping key IDs to public key PEMs.
# Development (hot reload via tsx)
npm run start:dev
# Production build
npm run build
npm start
# Type check
npm run typecheck
# Lint
npm run lint
# Run tests
npm testThe server listens on APP_HOST:APP_PORT (default 127.0.0.1:8080). All routes are prefixed with /api.
The included deploy.sh pulls the latest code and restarts via PM2:
./deploy.shThese must be set for the application to start:
| Variable | Description |
|---|---|
MARIADB_HOST |
MariaDB server host |
MARIADB_USER |
Database user |
MARIADB_DATABASE |
Database name |
CHECKDNS_BASE_URL |
Base URL of the external DNS check service |
CHECKDNS_TOKEN |
Auth token for the DNS check service |
AUTH_CSRF_SECRET |
Secret for CSRF token generation (HMAC-SHA256) |
COUNTER_SECRET_KEY |
Secret key for the forward counter increment endpoint (generate with openssl rand -hex 32) |
JWT_ACCESS_PRIVATE_KEY |
Ed25519 private key (PEM) for signing access tokens |
JWT_ACCESS_KID |
Key ID for the active signing key |
JWT_ACCESS_VERIFY_KEYS |
JSON object of key IDs to public keys for verification |
See .env.example for the full list of optional variables including rate-limit thresholds, SMTP settings, Redis URL, CORS origins, and Argon2 tuning parameters.
| Module | Prefix | Purpose |
|---|---|---|
| Forwarding | /api/forward |
Subscribe/unsubscribe email aliases with confirmation flow |
| Handle | /api/handle |
Claim, unsubscribe, and manage domain rules for handles with confirmation flow |
| Credentials | /api/credentials |
Create and confirm API keys via email verification |
| Check DNS | /api/request, /api/checkdns |
Relay DNS verification requests to external service |
| Domains | /api/domains |
List active mail domains (10s cache) |
| Stats | /api/stats |
Alias and forwarding metrics (120s cache) |
| Counter | /api/counter/increment |
Increment forwarded email counter (secret key required) |
| Prefix | Purpose |
|---|---|
/api/alias |
List, create, and deactivate aliases owned by the key holder |
/api/handle |
Create, delete handles, and disable/enable domains for handles owned by the key holder |
/api/activity |
View activity log for the key holder |
Authenticated via X-API-Key header with an API key (64-char hex string, stored as SHA256 hash).
All under /api/admin. Require JWT session authentication. CSRF token required for POST/PATCH/DELETE.
| Resource | Prefix | Operations |
|---|---|---|
| Session | /api/admin/me |
View current admin session |
| Aliases | /api/admin/aliases |
Full CRUD |
| API Tokens | /api/admin/api-tokens |
Full CRUD |
| Bans | /api/admin/bans |
Full CRUD (IP, domain, email, name); POST accepts optional disable_matching_aliases to deactivate matching active aliases for email, domain, and name bans |
| DNS Requests | /api/admin/dns-requests |
Full CRUD |
| Domains | /api/admin/domains |
Full CRUD |
| Handles | /api/admin/handles |
Full CRUD |
| Users | /api/admin/users |
Full CRUD + password change |
When POST /api/admin/bans is called with disable_matching_aliases: true, the response also includes a disabled_aliases count and a summary message.
| Endpoint | Method | Purpose |
|---|---|---|
/api/auth/sign-in |
POST | Authenticate admin user |
/api/auth/session |
GET | Get current session info |
/api/auth/csrf |
GET | Get CSRF token |
/api/auth/refresh |
POST | Refresh access token |
/api/auth/sign-out |
POST | End current session |
/api/auth/sign-out-all |
POST | Revoke all sessions |
/api/auth/forgot-password |
POST | Request password reset email |
/api/auth/reset-password |
POST | Reset password with token |
Rate limiting is implemented as a NestJS middleware (RouteRateLimitMiddleware) that evaluates a set of rules per request based on method and path. Rules fall into two categories:
- Delay rules (slow-down): artificially delay the response after a threshold is reached, with incrementally increasing latency.
- Limit rules (hard cap): reject the request with
429 Too Many Requestswhen the counter exceeds the configured threshold.
Counters are stored in Redis when available, falling back to in-memory storage. Each rule has a unique name that determines the counter key, a time window, and a key function that determines the bucketing dimension (IP, token, email, etc.).
The forwarding endpoints (/api/forward/subscribe and /api/forward/confirm) share a single rate-limit counter (fwd_cycle_ip) bucketed by IP address. This counter increments on both subscribe and confirm requests, preventing automated loops that create aliases in rapid succession by cycling between the two endpoints with unique parameters on each call. Handle subscribe and confirm endpoints share the same counter, so the budget is consumed across both alias and handle operations.
Each alias or handle creation cycle (one subscribe + one confirm) consumes 2 hits from this shared bucket. The limit is configured via RL_FORWARDING_CYCLE_PER_HOUR_PER_IP (default: 10, allowing up to 5 operations per hour per IP).
The /api/forward/confirm endpoint applies progressive delay after a configurable number of requests per IP per minute. This mirrors the delay rules already present on subscribe and unsubscribe endpoints. Configured via SD_CONFIRM_DELAY_AFTER (threshold) and SD_CONFIRM_DELAY_STEP_MS (delay increment per subsequent request).
All rate-limit thresholds are configurable via environment variables. See .env.example for the complete list with descriptions. Key variables for forwarding abuse prevention:
| Variable | Default | Description |
|---|---|---|
RL_FORWARDING_CYCLE_PER_HOUR_PER_IP |
10 |
Combined subscribe + confirm hard limit per IP per hour |
SD_CONFIRM_DELAY_AFTER |
3 |
Confirm requests per minute before delay kicks in |
SD_CONFIRM_DELAY_STEP_MS |
500 |
Delay increment (ms) per confirm request above threshold |
RL_SUBSCRIBE_PER_10MIN_PER_IP |
60 |
Subscribe hard limit per IP per 10 minutes |
RL_CONFIRM_PER_10MIN_PER_IP |
120 |
Confirm hard limit per IP per 10 minutes |
RL_HANDLE_SUBSCRIBE_PER_10MIN_PER_IP |
60 |
Handle subscribe hard limit per IP per 10 minutes |
RL_HANDLE_CONFIRM_PER_10MIN_PER_IP |
120 |
Handle confirm hard limit per IP per 10 minutes |
Handles allow a user to reserve a local-part (e.g. jose) that routes jose@any-managed-domain to a single real destination address. Key behaviors:
- Permanent reservation. A handle name can never be reused, even after unsubscribe. The row is kept with
active = 0andunsubscribed_atset. - Collision prevention. A handle cannot be claimed if any existing alias already uses the same local-part, and vice-versa. Both cases return the generic
alias_takenerror to avoid information leakage. - Domain disable/enable. The handle owner can block specific domains so that
handle@blocked-domainis rejected at the SMTP level, while other domains continue routing normally. - Public flow. Uses the same email confirmation pattern as alias forwarding: the user requests via
GET /api/handle/subscribe, receives a 6-digit token by email, and confirms viaGET /api/handle/confirm?token=.... Unsubscribe and domain disable/enable follow the same confirmation flow. - Authenticated flow. API key holders can create, delete, and manage domain rules immediately via
POST /api/handle/create,POST /api/handle/delete,POST /api/handle/domain/disable, andPOST /api/handle/domain/enable.
Handle confirmation emails use separate subject templates from alias emails, configurable via EMAIL_CONFIRMATION_SUBJECT_HANDLE_SUBSCRIBE (default: handle:create) and EMAIL_CONFIRMATION_SUBJECT_HANDLE_UNSUBSCRIBE (default: handle:delete). Domain disable/enable emails use the subscribe template.
Handle rate limiting mirrors the alias forwarding limits (progressive delay, per-IP, per-handle, and per-destination caps) and shares the cross-endpoint cycle counter with alias operations.
- No ORM. All SQL is hand-written and parameterized. Row types are defined as TypeScript interfaces in each repository file.
- ESM-only. The project uses
"type": "module"with NodeNext module resolution. All internal imports use.jsextensions. - Redis is optional. If
REDIS_URLis not set, rate-limit counters use in-memory storage (not shared across instances). - Tests run with
--experimental-vm-modulesfor ESM support in Jest. Usenpm test(notnpx jestdirectly). - Strict TypeScript. The project enables
strict,noUncheckedIndexedAccess,exactOptionalPropertyTypes, andnoImplicitOverride. - Rate limiting is configurable per-endpoint via environment variables. Each endpoint can have delay rules (slow-down) and hard limits (429 rejection). Forwarding endpoints (subscribe and confirm) share a cross-endpoint cycle counter per IP to prevent automated alias creation loops. See
.env.examplefor all threshold variables. - Admin mutations require a CSRF token in the request header, derived from the session via HMAC-SHA256.
- Password hashing uses Argon2id with configurable time cost, memory cost, parallelism, hash length, and salt length.