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_PROVIDER=smtp
|
||||||
EMAIL_FROM_ADDRESS=noreply@bytelyst.local
|
EMAIL_FROM_ADDRESS=noreply@bytelyst.local
|
||||||
EMAIL_FROM_NAME=ByteLyst
|
EMAIL_FROM_NAME=ByteLyst
|
||||||
SMTP_HOST=localhost
|
SMTP_HOST=mailpit
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=1025
|
||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
SMTP_USER=
|
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.
|
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.
|
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
|
## Current Capability Surface
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,17 @@
|
|||||||
services:
|
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 Blob Storage (prototype only) ─────────────────────
|
||||||
azurite:
|
azurite:
|
||||||
image: mcr.microsoft.com/azure-storage/azurite:3.35.0
|
image: mcr.microsoft.com/azure-storage/azurite:3.35.0
|
||||||
@ -120,7 +133,10 @@ services:
|
|||||||
- PLATFORM_SERVICE_URL=http://platform-service:4003
|
- PLATFORM_SERVICE_URL=http://platform-service:4003
|
||||||
- EXTRACTION_SERVICE_URL=http://extraction-service:4005
|
- EXTRACTION_SERVICE_URL=http://extraction-service:4005
|
||||||
- MCP_SERVER_URL=http://mcp-server:4007
|
- MCP_SERVER_URL=http://mcp-server:4007
|
||||||
|
- MAILPIT_UI_URL=http://mailpit:8025
|
||||||
depends_on:
|
depends_on:
|
||||||
|
mailpit:
|
||||||
|
condition: service_healthy
|
||||||
azurite:
|
azurite:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
cosmos-emulator:
|
cosmos-emulator:
|
||||||
|
|||||||
@ -9,6 +9,7 @@ This repo is currently set up to run as a single-host prototype with Docker Comp
|
|||||||
- `mcp-server`
|
- `mcp-server`
|
||||||
- `cosmos-emulator`
|
- `cosmos-emulator`
|
||||||
- `azurite`
|
- `azurite`
|
||||||
|
- `mailpit`
|
||||||
- `gateway` (Traefik)
|
- `gateway` (Traefik)
|
||||||
- `loki`
|
- `loki`
|
||||||
- `grafana`
|
- `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.
|
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.
|
Blob/file uploads are self-hosted through Azurite.
|
||||||
|
Prototype email delivery is self-hosted through Mailpit.
|
||||||
|
|
||||||
## First-Time Setup
|
## First-Time Setup
|
||||||
|
|
||||||
@ -59,12 +61,14 @@ docker compose logs -f extraction-service
|
|||||||
docker compose logs -f mcp-server
|
docker compose logs -f mcp-server
|
||||||
docker compose logs -f cosmos-emulator
|
docker compose logs -f cosmos-emulator
|
||||||
docker compose logs -f azurite
|
docker compose logs -f azurite
|
||||||
|
docker compose logs -f mailpit
|
||||||
./scripts/prototype-self-test.sh
|
./scripts/prototype-self-test.sh
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
The Cosmos Data Explorer is exposed on `http://localhost:1234`.
|
The Cosmos Data Explorer is exposed on `http://localhost:1234`.
|
||||||
Azurite Blob Storage is exposed on `http://localhost:10000`.
|
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 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 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`.
|
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',
|
defaultUrl: 'http://localhost:4007',
|
||||||
healthPath: '/health',
|
healthPath: '/health',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Mailpit',
|
||||||
|
envVar: 'MAILPIT_UI_URL',
|
||||||
|
defaultUrl: 'http://mailpit:8025',
|
||||||
|
healthPath: '/',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Backend API',
|
name: 'Backend API',
|
||||||
envVar: 'BACKEND_URL',
|
envVar: 'BACKEND_URL',
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
|
import net from 'node:net';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { URL } from 'node:url';
|
import { URL } from 'node:url';
|
||||||
import { getStorage } from '@bytelyst/storage';
|
import { getStorage } from '@bytelyst/storage';
|
||||||
import { BLOB_CONTAINERS, getBucket, isBlobStorageConfigured } from '../../lib/blob.js';
|
import { BLOB_CONTAINERS, getBucket, isBlobStorageConfigured } from '../../lib/blob.js';
|
||||||
import { checkAllServices } from './health-checker.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';
|
import type { PlatformStatus } from './types.js';
|
||||||
|
|
||||||
export type DiagnosticStatus = 'pass' | 'fail';
|
export type DiagnosticStatus = 'pass' | 'fail';
|
||||||
@ -23,6 +26,26 @@ export interface DiagnosticsReport {
|
|||||||
serviceStatus: PlatformStatus;
|
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 {
|
function checkStatus(ok: boolean): DiagnosticStatus {
|
||||||
return ok ? 'pass' : 'fail';
|
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> {
|
export async function getDependencyHealthReport(): Promise<DiagnosticsReport> {
|
||||||
const [blobStorage, serviceStatus] = await Promise.all([
|
const [blobStorage, smtpRelay, serviceStatus] = await Promise.all([
|
||||||
checkBlobStorageReadiness(),
|
checkBlobStorageReadiness(),
|
||||||
|
checkSmtpRelayReadiness(),
|
||||||
checkAllServices(),
|
checkAllServices(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const dependencyChecks = [blobStorage];
|
const dependencyChecks = [blobStorage, smtpRelay];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: reportStatus(dependencyChecks, serviceStatus),
|
status: reportStatus(dependencyChecks, serviceStatus),
|
||||||
@ -171,13 +320,16 @@ export async function getDependencyHealthReport(): Promise<DiagnosticsReport> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getSelfTestReport(): Promise<DiagnosticsReport> {
|
export async function getSelfTestReport(): Promise<DiagnosticsReport> {
|
||||||
const [blobStorage, blobStorageSelfTest, serviceStatus] = await Promise.all([
|
const [blobStorage, smtpRelay, blobStorageSelfTest, smtpDeliverySelfTest, serviceStatus] =
|
||||||
checkBlobStorageReadiness(),
|
await Promise.all([
|
||||||
runBlobStorageSelfTest(),
|
checkBlobStorageReadiness(),
|
||||||
checkAllServices(),
|
checkSmtpRelayReadiness(),
|
||||||
]);
|
runBlobStorageSelfTest(),
|
||||||
|
runSmtpDeliverySelfTest(),
|
||||||
|
checkAllServices(),
|
||||||
|
]);
|
||||||
|
|
||||||
const dependencyChecks = [blobStorage, blobStorageSelfTest];
|
const dependencyChecks = [blobStorage, smtpRelay, blobStorageSelfTest, smtpDeliverySelfTest];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: reportStatus(dependencyChecks, serviceStatus),
|
status: reportStatus(dependencyChecks, serviceStatus),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user