feat(platform-service): add smtp email delivery and postal setup
This commit is contained in:
parent
d57b388904
commit
db9ae4a573
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
67
services/platform-service/POSTAL_SMTP_SETUP.md
Normal file
67
services/platform-service/POSTAL_SMTP_SETUP.md
Normal 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.
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
27
services/platform-service/src/nodemailer.d.ts
vendored
Normal file
27
services/platform-service/src/nodemailer.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user