feat(platform-service): add smtp email delivery and postal setup

This commit is contained in:
root 2026-03-14 05:52:28 +00:00
parent d57b388904
commit db9ae4a573
9 changed files with 184 additions and 7 deletions

24
pnpm-lock.yaml generated
View File

@ -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

View File

@ -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.

View File

@ -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"

View File

@ -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);

View File

@ -29,6 +29,8 @@ export async function sendEmail(
log: { info: (...a: unknown[]) => void; error: (...a: unknown[]) => void }
): Promise<SendResult> {
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<SendResult> {
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(

View File

@ -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,
};
}

View File

@ -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 {

View File

@ -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<SentMessageInfo>;
}
export function createTransport(options: {
host: string;
port: number;
secure: boolean;
auth?: {
user?: string;
pass?: string;
};
}): Transporter;
}

View File

@ -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);