Add Mailpit-backed prototype email sandbox

This commit is contained in:
root 2026-03-14 06:16:28 +00:00
parent a27a822fc2
commit 91885f0d4f
6 changed files with 188 additions and 10 deletions

View File

@ -33,7 +33,7 @@ STRIPE_PRICE_ENTERPRISE=price_...
EMAIL_PROVIDER=smtp
EMAIL_FROM_ADDRESS=noreply@bytelyst.local
EMAIL_FROM_NAME=ByteLyst
SMTP_HOST=localhost
SMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_SECURE=false
SMTP_USER=

View File

@ -43,7 +43,7 @@ cp .env.example .env
See [docs/PROTOTYPE_DEPLOYMENT.md](docs/PROTOTYPE_DEPLOYMENT.md) for the required environment variables and day-to-day commands.
The prototype stack now includes a local Cosmos DB Emulator container, so the default `.env.example` values are wired for single-VM Docker use.
Blob uploads are backed by local Azurite, and the platform exposes prototype diagnostics at `/api/health/dependencies`, `/api/self-test`, and `/api/self-test.json`.
Blob uploads are backed by local Azurite, prototype email delivery is backed by Mailpit, and the platform exposes prototype diagnostics at `/api/health/dependencies`, `/api/self-test`, and `/api/self-test.json`.
## Current Capability Surface

View File

@ -1,4 +1,17 @@
services:
# ── Mailpit SMTP Sandbox (prototype only) ────────────────────
mailpit:
image: axllent/mailpit:v1.27.5
ports:
- '1025:1025'
- '8025:8025'
healthcheck:
test: ['CMD', 'wget', '-q', '--spider', 'http://127.0.0.1:8025']
interval: 10s
timeout: 5s
retries: 6
restart: unless-stopped
# ── Azurite Blob Storage (prototype only) ─────────────────────
azurite:
image: mcr.microsoft.com/azure-storage/azurite:3.35.0
@ -120,7 +133,10 @@ services:
- PLATFORM_SERVICE_URL=http://platform-service:4003
- EXTRACTION_SERVICE_URL=http://extraction-service:4005
- MCP_SERVER_URL=http://mcp-server:4007
- MAILPIT_UI_URL=http://mailpit:8025
depends_on:
mailpit:
condition: service_healthy
azurite:
condition: service_healthy
cosmos-emulator:

View File

@ -9,6 +9,7 @@ This repo is currently set up to run as a single-host prototype with Docker Comp
- `mcp-server`
- `cosmos-emulator`
- `azurite`
- `mailpit`
- `gateway` (Traefik)
- `loki`
- `grafana`
@ -20,6 +21,7 @@ This repo is currently set up to run as a single-host prototype with Docker Comp
For this VM prototype, Cosmos is self-hosted through the Linux Cosmos DB Emulator container. Everything else should still stay in `.env` and move to a real secret manager later.
Blob/file uploads are self-hosted through Azurite.
Prototype email delivery is self-hosted through Mailpit.
## First-Time Setup
@ -59,12 +61,14 @@ docker compose logs -f extraction-service
docker compose logs -f mcp-server
docker compose logs -f cosmos-emulator
docker compose logs -f azurite
docker compose logs -f mailpit
./scripts/prototype-self-test.sh
docker compose down
```
The Cosmos Data Explorer is exposed on `http://localhost:1234`.
Azurite Blob Storage is exposed on `http://localhost:10000`.
Mailpit SMTP listens on `localhost:1025` and the Mailpit inbox UI is exposed on `http://localhost:8025`.
The platform dependency health JSON is exposed on `http://localhost:4003/api/health/dependencies`.
The platform self-test page is exposed on `http://localhost:4003/api/self-test`.
The platform self-test JSON is exposed on `http://localhost:4003/api/self-test.json`.

View File

@ -29,6 +29,12 @@ const SERVICE_DEFS: ServiceDef[] = [
defaultUrl: 'http://localhost:4007',
healthPath: '/health',
},
{
name: 'Mailpit',
envVar: 'MAILPIT_UI_URL',
defaultUrl: 'http://mailpit:8025',
healthPath: '/',
},
{
name: 'Backend API',
envVar: 'BACKEND_URL',

View File

@ -1,8 +1,11 @@
import net from 'node:net';
import { randomUUID } from 'node:crypto';
import { URL } from 'node:url';
import { getStorage } from '@bytelyst/storage';
import { BLOB_CONTAINERS, getBucket, isBlobStorageConfigured } from '../../lib/blob.js';
import { checkAllServices } from './health-checker.js';
import { resolveEmailConfig } from '../delivery/dispatcher.js';
import { sendEmail } from '../delivery/channels/email.js';
import type { PlatformStatus } from './types.js';
export type DiagnosticStatus = 'pass' | 'fail';
@ -23,6 +26,26 @@ export interface DiagnosticsReport {
serviceStatus: PlatformStatus;
}
async function tcpConnect(host: string, port: number, timeoutMs: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
const socket = net.createConnection({ host, port });
const onError = (error: Error) => {
socket.destroy();
reject(error);
};
socket.setTimeout(timeoutMs, () => {
socket.destroy();
reject(new Error(`Timed out connecting to ${host}:${port}`));
});
socket.once('error', onError);
socket.once('connect', () => {
socket.end();
resolve();
});
});
}
function checkStatus(ok: boolean): DiagnosticStatus {
return ok ? 'pass' : 'fail';
}
@ -154,13 +177,139 @@ export async function runBlobStorageSelfTest(): Promise<DiagnosticCheck> {
}
}
export async function checkSmtpRelayReadiness(): Promise<DiagnosticCheck> {
const startedAt = Date.now();
const config = resolveEmailConfig();
if (config.provider !== 'smtp') {
return {
name: 'smtp_relay',
status: 'pass',
durationMs: Date.now() - startedAt,
summary: `SMTP relay check skipped because EMAIL_PROVIDER=${config.provider}`,
details: {
provider: config.provider,
},
};
}
if (!config.smtpHost) {
return {
name: 'smtp_relay',
status: 'fail',
durationMs: Date.now() - startedAt,
summary: 'SMTP relay is not configured',
details: {
provider: config.provider,
},
};
}
const smtpPort = config.smtpPort ?? 587;
const mailpitUiUrl = process.env.MAILPIT_UI_URL?.trim();
try {
await tcpConnect(config.smtpHost, smtpPort, 5_000);
let uiReachable: boolean | undefined;
if (mailpitUiUrl) {
const response = await fetch(mailpitUiUrl, { method: 'GET' });
uiReachable = response.ok;
}
return {
name: 'smtp_relay',
status: checkStatus(uiReachable ?? true),
durationMs: Date.now() - startedAt,
summary:
uiReachable === false
? 'SMTP relay port is reachable but Mailpit UI check failed'
: 'SMTP relay is reachable',
details: {
provider: config.provider,
host: config.smtpHost,
port: smtpPort,
...(mailpitUiUrl ? { mailpitUiUrl, uiReachable } : {}),
},
};
} catch (error) {
return {
name: 'smtp_relay',
status: 'fail',
durationMs: Date.now() - startedAt,
summary: 'SMTP relay readiness check failed',
error: error instanceof Error ? error.message : String(error),
details: {
provider: config.provider,
host: config.smtpHost,
port: smtpPort,
...(mailpitUiUrl ? { mailpitUiUrl } : {}),
},
};
}
}
export async function runSmtpDeliverySelfTest(): Promise<DiagnosticCheck> {
const startedAt = Date.now();
const config = resolveEmailConfig();
if (config.provider !== 'smtp') {
return {
name: 'smtp_delivery_self_test',
status: 'pass',
durationMs: Date.now() - startedAt,
summary: `SMTP delivery self-test skipped because EMAIL_PROVIDER=${config.provider}`,
details: {
provider: config.provider,
},
};
}
const recipient = `self-test-${randomUUID()}@bytelyst.local`;
const subject = `platform-service smtp self-test ${new Date().toISOString()}`;
const result = await sendEmail(
{
to: recipient,
from: config.fromEmail,
fromName: config.fromName,
subject,
bodyHtml: '<p>SMTP self-test</p>',
bodyText: 'SMTP self-test',
},
config,
{
info: () => undefined,
error: () => undefined,
}
);
return {
name: 'smtp_delivery_self_test',
status: checkStatus(result.success),
durationMs: Date.now() - startedAt,
summary: result.success
? 'SMTP delivery self-test email accepted by relay'
: 'SMTP delivery self-test failed',
error: result.error,
details: {
provider: config.provider,
recipient,
messageId: result.messageId,
host: config.smtpHost,
port: config.smtpPort ?? 587,
},
};
}
export async function getDependencyHealthReport(): Promise<DiagnosticsReport> {
const [blobStorage, serviceStatus] = await Promise.all([
const [blobStorage, smtpRelay, serviceStatus] = await Promise.all([
checkBlobStorageReadiness(),
checkSmtpRelayReadiness(),
checkAllServices(),
]);
const dependencyChecks = [blobStorage];
const dependencyChecks = [blobStorage, smtpRelay];
return {
status: reportStatus(dependencyChecks, serviceStatus),
@ -171,13 +320,16 @@ export async function getDependencyHealthReport(): Promise<DiagnosticsReport> {
}
export async function getSelfTestReport(): Promise<DiagnosticsReport> {
const [blobStorage, blobStorageSelfTest, serviceStatus] = await Promise.all([
checkBlobStorageReadiness(),
runBlobStorageSelfTest(),
checkAllServices(),
]);
const [blobStorage, smtpRelay, blobStorageSelfTest, smtpDeliverySelfTest, serviceStatus] =
await Promise.all([
checkBlobStorageReadiness(),
checkSmtpRelayReadiness(),
runBlobStorageSelfTest(),
runSmtpDeliverySelfTest(),
checkAllServices(),
]);
const dependencyChecks = [blobStorage, blobStorageSelfTest];
const dependencyChecks = [blobStorage, smtpRelay, blobStorageSelfTest, smtpDeliverySelfTest];
return {
status: reportStatus(dependencyChecks, serviceStatus),