From db9ae4a57333079bb6c3cf892acb1cefbd9fa3bb Mon Sep 17 00:00:00 2001 From: root Date: Sat, 14 Mar 2026 05:52:28 +0000 Subject: [PATCH] feat(platform-service): add smtp email delivery and postal setup --- pnpm-lock.yaml | 24 +++++-- .../platform-service/POSTAL_SMTP_SETUP.md | 67 +++++++++++++++++++ services/platform-service/package.json | 1 + services/platform-service/src/lib/config.ts | 12 ++++ .../src/modules/delivery/channels/email.ts | 46 +++++++++++++ .../src/modules/delivery/dispatcher.ts | 5 ++ .../src/modules/delivery/types.ts | 7 +- services/platform-service/src/nodemailer.d.ts | 27 ++++++++ services/platform-service/src/server.ts | 2 + 9 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 services/platform-service/POSTAL_SMTP_SETUP.md create mode 100644 services/platform-service/src/nodemailer.d.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 598ffa1d..d83d1199 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -790,6 +790,9 @@ importers: jose: specifier: ^6.0.8 version: 6.1.3 + nodemailer: + specifier: ^6.10.1 + version: 6.10.1 stripe: specifier: ^17.5.0 version: 17.7.0 @@ -10841,6 +10844,13 @@ packages: integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==, } + nodemailer@6.10.1: + resolution: + { + integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==, + } + engines: { node: '>=6.0.0' } + noop-logger@0.1.1: resolution: { @@ -17662,14 +17672,14 @@ snapshots: msw: 2.12.10(@types/node@20.19.33)(typescript@5.9.3) vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.10(@types/node@22.19.11)(typescript@5.9.3) - vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/mocker@4.0.18(msw@2.12.10(@types/node@20.19.33)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: @@ -18894,7 +18904,7 @@ snapshots: eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -18927,7 +18937,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -18997,7 +19007,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -21477,6 +21487,8 @@ snapshots: node-releases@2.0.27: {} + nodemailer@6.10.1: {} + noop-logger@0.1.1: {} normalize-path@3.0.0: {} @@ -23455,7 +23467,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 diff --git a/services/platform-service/POSTAL_SMTP_SETUP.md b/services/platform-service/POSTAL_SMTP_SETUP.md new file mode 100644 index 00000000..5205cb70 --- /dev/null +++ b/services/platform-service/POSTAL_SMTP_SETUP.md @@ -0,0 +1,67 @@ +# Postal SMTP Setup + +Use Postal as the long-term outbound email backend for `platform-service`. + +## Architecture + +`platform-service` owns: + +- email templates +- auth-triggered email events +- delivery logs +- SMTP submission + +Postal owns: + +- SMTP/API delivery +- outbound queueing +- bounce and reputation handling +- domain signing and mail-server operations + +`platform-service` should talk to Postal over SMTP. No separate ByteLyst email relay is needed. + +## Environment + +Set these variables for `platform-service`: + +```env +EMAIL_PROVIDER=smtp +EMAIL_FROM_ADDRESS=noreply@your-domain.com +EMAIL_FROM_NAME=ByteLyst +SMTP_HOST=postal.your-domain.internal +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=your-postal-smtp-username +SMTP_PASSWORD=your-postal-smtp-password +``` + +Use `SMTP_SECURE=true` only if your Postal endpoint expects implicit TLS, typically on port `465`. + +## Postal Mapping + +Create one SMTP credential in Postal for the sender/server you want `platform-service` to use. + +Map the values like this: + +- `SMTP_HOST`: Postal SMTP host +- `SMTP_PORT`: Postal submission port +- `SMTP_USER`: Postal SMTP username +- `SMTP_PASSWORD`: Postal SMTP password +- `EMAIL_FROM_ADDRESS`: verified sender address or domain-backed sender + +## Operational Notes + +- Keep `EMAIL_PROVIDER=console` for local development if you do not want real delivery. +- Use a local SMTP catcher such as Mailpit only for development inbox inspection. +- For production, Postal still requires DNS and mail operations outside this repo: SPF, DKIM, DMARC, reverse DNS, and clean outbound IP reputation. + +## Current Repo Wiring + +The SMTP delivery path is implemented in: + +- `src/modules/delivery/channels/email.ts` +- `src/modules/delivery/dispatcher.ts` +- `src/modules/delivery/subscribers.ts` +- `src/server.ts` + +Auth-driven flows such as password reset and email verification now depend on the delivery subscribers being registered at service startup. diff --git a/services/platform-service/package.json b/services/platform-service/package.json index f70977ce..59cf1cef 100644 --- a/services/platform-service/package.json +++ b/services/platform-service/package.json @@ -33,6 +33,7 @@ "fastify-metrics": "^10.3.0", "fastify-zod-openapi": "^5.5.0", "jose": "^6.0.8", + "nodemailer": "^6.10.1", "stripe": "^17.5.0", "zod": "^3.24.2", "zod-openapi": "^5.4.6" diff --git a/services/platform-service/src/lib/config.ts b/services/platform-service/src/lib/config.ts index 66e94d11..4d2e3279 100644 --- a/services/platform-service/src/lib/config.ts +++ b/services/platform-service/src/lib/config.ts @@ -45,6 +45,18 @@ const envSchema = z.object({ WEBAUTHN_ORIGINS: z.string().optional(), // ── SmartAuth CORS ── CORS_ALLOWED_ORIGINS: z.string().optional(), + EMAIL_PROVIDER: z.enum(['smtp', 'sendgrid', 'postmark', 'bytelyst', 'console']).optional(), + EMAIL_API_KEY: z.string().optional(), + EMAIL_FROM_ADDRESS: z.string().optional(), + EMAIL_FROM_NAME: z.string().optional(), + SMTP_HOST: z.string().optional(), + SMTP_PORT: z.coerce.number().optional(), + SMTP_SECURE: z + .enum(['true', 'false']) + .transform(value => value === 'true') + .optional(), + SMTP_USER: z.string().optional(), + SMTP_PASSWORD: z.string().optional(), }); export const config = envSchema.parse(process.env); diff --git a/services/platform-service/src/modules/delivery/channels/email.ts b/services/platform-service/src/modules/delivery/channels/email.ts index 01318921..616d9f7e 100644 --- a/services/platform-service/src/modules/delivery/channels/email.ts +++ b/services/platform-service/src/modules/delivery/channels/email.ts @@ -29,6 +29,8 @@ export async function sendEmail( log: { info: (...a: unknown[]) => void; error: (...a: unknown[]) => void } ): Promise { switch (config.provider) { + case 'smtp': + return sendViaSmtp(message, config); case 'console': return sendViaConsole(message, log); case 'bytelyst': @@ -42,6 +44,50 @@ export async function sendEmail( } } +// ── SMTP Provider (self-hosted) ────────────────────────────── + +async function sendViaSmtp(message: EmailMessage, config: EmailChannelConfig): Promise { + if (!config.smtpHost) { + return { success: false, error: 'SMTP host not configured' }; + } + + const { createTransport } = await import('nodemailer'); + const port = config.smtpPort ?? 587; + const secure = config.smtpSecure ?? port === 465; + const auth = + config.smtpUser || config.smtpPassword + ? { + user: config.smtpUser, + pass: config.smtpPassword, + } + : undefined; + + try { + const transport = createTransport({ + host: config.smtpHost, + port, + secure, + auth, + }); + + const info = await transport.sendMail({ + from: `${message.fromName} <${message.from}>`, + to: message.to, + subject: message.subject, + text: message.bodyText, + html: message.bodyHtml, + }); + + return { + success: true, + messageId: + typeof info.messageId === 'string' ? info.messageId : `smtp_${crypto.randomUUID()}`, + }; + } catch (err: unknown) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } +} + // ── Console Provider (dev/testing) ─────────────────────────── async function sendViaConsole( diff --git a/services/platform-service/src/modules/delivery/dispatcher.ts b/services/platform-service/src/modules/delivery/dispatcher.ts index 8cdf6b02..438c28d6 100644 --- a/services/platform-service/src/modules/delivery/dispatcher.ts +++ b/services/platform-service/src/modules/delivery/dispatcher.ts @@ -17,6 +17,11 @@ export function resolveEmailConfig(): EmailChannelConfig { apiKey: process.env.EMAIL_API_KEY, fromEmail: process.env.EMAIL_FROM_ADDRESS || 'noreply@bytelyst.com', fromName: process.env.EMAIL_FROM_NAME || 'ByteLyst', + smtpHost: process.env.SMTP_HOST, + smtpPort: process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : undefined, + smtpSecure: process.env.SMTP_SECURE ? process.env.SMTP_SECURE === 'true' : undefined, + smtpUser: process.env.SMTP_USER, + smtpPassword: process.env.SMTP_PASSWORD, }; } diff --git a/services/platform-service/src/modules/delivery/types.ts b/services/platform-service/src/modules/delivery/types.ts index e8c1fe04..fa7151d7 100644 --- a/services/platform-service/src/modules/delivery/types.ts +++ b/services/platform-service/src/modules/delivery/types.ts @@ -64,10 +64,15 @@ export interface DeliveryLogDoc { // ── Channel Config ─────────────────────────────────────────── export interface EmailChannelConfig { - provider: 'sendgrid' | 'postmark' | 'bytelyst' | 'console'; + provider: 'smtp' | 'sendgrid' | 'postmark' | 'bytelyst' | 'console'; apiKey?: string; fromEmail: string; fromName: string; + smtpHost?: string; + smtpPort?: number; + smtpSecure?: boolean; + smtpUser?: string; + smtpPassword?: string; } export interface PushChannelConfig { diff --git a/services/platform-service/src/nodemailer.d.ts b/services/platform-service/src/nodemailer.d.ts new file mode 100644 index 00000000..a4b35134 --- /dev/null +++ b/services/platform-service/src/nodemailer.d.ts @@ -0,0 +1,27 @@ +declare module 'nodemailer' { + export interface SendMailOptions { + from: string; + to: string; + subject: string; + text: string; + html: string; + } + + export interface SentMessageInfo { + messageId?: string; + } + + export interface Transporter { + sendMail(message: SendMailOptions): Promise; + } + + export function createTransport(options: { + host: string; + port: number; + secure: boolean; + auth?: { + user?: string; + pass?: string; + }; + }): Transporter; +} diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 14d085df..e4c6a2d4 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -86,6 +86,7 @@ import type { JwtPayload } from './lib/request-context.js'; import { seedDefaultFlags } from './modules/flags/seed.js'; import { runPendingMigrations } from './migrations/runner.js'; import { registerDiagnosticsSubscribers } from './modules/diagnostics/subscribers.js'; +import { registerDeliverySubscribers } from './modules/delivery/subscribers.js'; import { verifyToken } from './modules/auth/jwt.js'; await initCosmosIfNeeded(); @@ -206,6 +207,7 @@ await app.register(surveyRoutes, { prefix: '/api' }); // Register event bus subscribers registerDiagnosticsSubscribers(app.log); +registerDeliverySubscribers(app.log); // Start diagnostic trigger evaluation job (Phase 4) startTriggerEvaluationJob(app.log);