Connecting web applications directly to SMTP servers inside request handlers introduces unpredictable latency, connection overhead, and fragmented credential management across microservices.
Hermes is an open-source, multi-tenant transactional email gateway designed to solve this problem by decoupling request intake from actual delivery using a BullMQ/Redis queue. The system secures credentials at rest using AES-256-GCM and validates high-frequency programmatic API keys using Argon2id with indexed prefix lookups to avoid full-table scans.
The codebase is organized into modular repositories:
- API Gateway & Workers: github.com/RuanLopes1350/hermes-api
- Frontend Management Dashboard: github.com/RuanLopes1350/hermes-front
- TypeScript Client SDK: github.com/RuanLopes1350/hermes-client
-
NPM Package:
@ruanlopes1350/hermes-client
1. The Core Architecture
In distributed architectures, transactional emails (receipts, password resets, system alerts) should not block the user-facing request cycle. SMTP handshakes, TLS negotiation, and remote server response times frequently take between 500ms and 3,000ms. If an SMTP server stalls, threads hang and upstream requests timeout.
Hermes separates the ingestion interface from the transport layer:
[ Client App / SDK ]
│
│ POST /api/emails (X-API-Key: hm_prefix.secret)
▼
┌─────────────────────────────────────────────────────────────┐
│ HERMES API GATEWAY │
│ - Validates API Key (Prefix index lookup + Argon2id verify)│
│ - Records email in Postgres as 'pending' │
│ - Pushes job to BullMQ queue (< 25ms response time) │
└──────────────┬──────────────────────────────┬───────────────┘
│ │
▼ ▼
┌────────────────────┐ ┌───────────────────┐
│ PostgreSQL DB │ │ Redis (BullMQ) │
│ (Drizzle ORM) │ │ (Queue/PubSub) │
└────────────────────┘ └─────────┬─────────┘
│
▼
┌───────────────────────────┐
│ EMAIL WORKER │
│ - Decrypts AES-256 keys │
│ - Renders MJML template │
│ - Dispatches via SMTP │
│ - Updates DB & fires SSE │
└─────────────┬─────────────┘
│
▼
┌───────────────────────────┐
│ SMTP SERVER / GMAIL │
└───────────────────────────┘
2. Decoupling the Ingestion API from the Worker
Ingestion Gateway (server.ts)
The API accepts incoming requests, performs schema validation, checks permissions, stores the email record in PostgreSQL as pending, and pushes a job to Redis. It returns an immediate 201 Created with the email record ID.
// Fast response path: No SMTP handshakes inside HTTP execution
const emailRecord = await emailRepository.create({
serviceId: req.serviceId,
recipientTo: body.recipient_to,
subject: body.subject,
status: 'pending',
});
await emailQueue.add('send-email', {
emailId: emailRecord.id,
serviceId: req.serviceId,
credentialId: req.credentialId,
payload: body,
});
return res.status(201).json(CommonResponse.created('E-mail enfileirado com sucesso!', emailRecord));
Background Execution Worker (worker.ts)
The worker pulls jobs off the BullMQ queue asynchronously:
- Resolves the tenant's SMTP credentials (either username/password or Google OAuth2 refresh tokens).
- Decrypts sensitive fields in memory.
- Compiles the MJML template with dynamic Handlebars variables.
- Executes delivery via Nodemailer.
- Updates the database state to
sentorfailed(capturing error stacks and execution timestamps). - Publishes an event to Redis Pub/Sub, streaming real-time status updates to the dashboard via Server-Sent Events (SSE).
If a network glitch or rate-limit occurs, BullMQ handles exponential backoff retries without blocking new HTTP requests.
3. Cryptographic Implementation
A shared gateway must safely store external credentials and validate incoming API requests at scale.
3.1 API Key Verification: Prefix-Indexed Argon2id
Hashing API keys with standard SHA-256 leaves them vulnerable to high-speed dictionary attacks if a database dump leaks. Conversely, hashing the entire incoming key with bcrypt or argon2id without indexing forces an $O(N)$ linear scan over all database records.
Hermes solves this by splitting API keys into two components:
hm_b5c92a10.e4d3c2b1a0f9e8d7c6b5a4938271605f45819027814a09823...
└───┬──────┘ └─────────────────────────┬─────────────────────────┘
│ └─ 64-char Hex Secret (Argon2id Hash)
└─ 8-char Hex Prefix (Indexed plaintext)
-
Index Lookup ($O(1)$): The database indexes the public
prefix(hm_b5c92a10). When an API call arrives, the query retrieves only the matching record:
SELECT id, service_id, key_hash, expires_at
FROM credential
WHERE prefix = 'hm_b5c92a10' AND is_active = true AND deleted_at IS NULL;
-
Argon2id Verification: Once the candidate row is fetched,
argon2.verify(candidate.key_hash, secret)validates the secret. This gives memory-hard cryptographic protection against GPU brute-forcing while avoiding table scans.
3.2 Credentials at Rest: AES-256-GCM Envelope Encryption
Passwords and Google OAuth2 tokens are stored in the database formatted as:
<iv_hex>:<auth_tag_hex>:<ciphertext_hex>
- Algorithm: AES-256-GCM.
- IV: Unique 16-byte initialization vector generated randomly for each encryption call.
- Auth Tag: 16-byte authentication tag ensuring ciphertext integrity (detecting data corruption or tampering).
-
Master Key: Derived from the
MASTER_KEYenvironment variable, ensuring that database leaks alone do not expose usable SMTP credentials.
4. Zero-Downtime Key Rotation (Webhook-First Pattern)
Rotating API keys in production usually requires manual orchestration to avoid downtime. Hermes automates rotation using an asynchronous cron job and signed webhooks:
-
Monitoring: A daily cron job (
0 0 * * *) scans active credentials whereauto_rotate = trueandexpiresAtis within the configured threshold (default: 3 days). -
Webhook-First Dispatch: The system generates a new key pair and attempts to deliver it to the service's configured webhook URL via HTTPS
POST. -
HMAC SHA-256 Verification: The payload is signed with an
X-Hermes-Signatureheader calculated using the service's privatewebhook_secret:
const signature = crypto
.createHmac('sha256', service.webhookSecret)
.update(JSON.stringify(payload))
.digest('hex');
-
Failure Safety: If the client application fails to acknowledge the webhook with a
200 OK(e.g., service unavailable or invalid signature), the rotation is rolled back. The database record is not updated, and the existing key remains valid. BullMQ schedules a retry with exponential backoff. - Database Commit: The new key hash and prefix are written to PostgreSQL only after the client acknowledges receipt.
5. Integrating with the SDK
The @ruanlopes1350/hermes-client package provides a fluent builder pattern, automatic retries with jitter, and built-in webhook handlers for key rotation:
import { HermesClient, MemoryAdapter } from '@ruanlopes1350/hermes-client';
const hermes = new HermesClient({
baseUrl: 'https://hermes.internal',
storageAdapter: new MemoryAdapter(process.env.HERMES_API_KEY!),
});
// Fluent email dispatch
await hermes.email()
.to('user@example.com')
.subject('Password Reset Request')
.useTemplate('cltmpl_password_reset', {
name: 'Dev User',
reset_url: 'https://app.internal/reset?token=xyz'
})
.send();
To handle key rotations automatically in Express:
import express from 'express';
import { expressWebhookHandler } from '@ruanlopes1350/hermes-client/express';
const app = express();
// Requires raw body for HMAC verification
app.post(
'/api/webhooks/hermes',
express.raw({ type: 'application/json' }),
expressWebhookHandler(hermes, process.env.HERMES_WEBHOOK_SECRET!)
);
6. Tradeoffs and Limitations
A transparent look at the architectural constraints of this setup:
- Infrastructure Footprint: Unlike fully managed SaaS providers (Resend, SendGrid, Postmark), Hermes requires maintaining a Node.js runtime, PostgreSQL, Redis, and worker instances. For small applications sending a few dozen emails per week, the operational overhead may not be justified.
- Argon2id CPU Consumption: Under high-concurrency spikes (>500 req/s), Argon2id verification puts heavy load on the CPU. While the prefix index eliminates full-table scans, verifying memory-hard hashes repeatedly remains compute-heavy. Deployments with extreme throughput should place a caching proxy or rate-limiter in front of the API.
-
Single-Host Worker Scaling: The included auto-scaler (
scaler.ts) manages worker replica counts by communicating with the local Docker Compose daemon. It is designed for single-host VPS infrastructure, not distributed clusters (e.g., Kubernetes HPA). - Deliverability Responsibility: Hermes handles queuing, template rendering, and delivery handoffs. It does not manage upstream IP reputation, feedback loops, or DNS records (SPF, DKIM, DMARC), which remain the responsibility of your underlying SMTP provider.
7. Project Repositories
The complete source code is open for review and contributions:
Top comments (0)