Add Mailpit-backed prototype email sandbox
This commit is contained in:
parent
a27a822fc2
commit
91885f0d4f
@ -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=
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user