From 91885f0d4ff4d5f2ce7d3d2acabd2770c23f87b8 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 14 Mar 2026 06:16:28 +0000 Subject: [PATCH] Add Mailpit-backed prototype email sandbox --- .env.example | 2 +- README.md | 2 +- docker-compose.yml | 16 ++ docs/PROTOTYPE_DEPLOYMENT.md | 4 + .../src/modules/status/health-checker.ts | 6 + .../src/modules/status/self-test.ts | 168 +++++++++++++++++- 6 files changed, 188 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index db9bc8fa..9074dc55 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 69b826a2..9f1b16e1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index b4eae8c5..9bcb4c88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/PROTOTYPE_DEPLOYMENT.md b/docs/PROTOTYPE_DEPLOYMENT.md index 23721d22..36965ffe 100644 --- a/docs/PROTOTYPE_DEPLOYMENT.md +++ b/docs/PROTOTYPE_DEPLOYMENT.md @@ -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`. diff --git a/services/platform-service/src/modules/status/health-checker.ts b/services/platform-service/src/modules/status/health-checker.ts index 5f3d2d3f..41e9e2c6 100644 --- a/services/platform-service/src/modules/status/health-checker.ts +++ b/services/platform-service/src/modules/status/health-checker.ts @@ -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', diff --git a/services/platform-service/src/modules/status/self-test.ts b/services/platform-service/src/modules/status/self-test.ts index 5ce0e0ba..356de188 100644 --- a/services/platform-service/src/modules/status/self-test.ts +++ b/services/platform-service/src/modules/status/self-test.ts @@ -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 { + await new Promise((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 { } } +export async function checkSmtpRelayReadiness(): Promise { + 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 { + 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: '

SMTP self-test

', + 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 { - 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 { } export async function getSelfTestReport(): Promise { - 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),