Files
investor-flow/app/server/src/services/emailAlertService.ts
T
Investor Flow Build 5b9f770aa4
CI / Test & Type-Check (push) Canceled after 0s
Phase 5: alert subscriptions UI + multiple watchlists + queue fixes
UI:
- /alerts page: event history with acknowledge, subscription create/manage with toggle
- /admin/smtp: SMTP config form (host, port, auth, test)
- Watchlist sidebar: dropdown selector for multiple watchlists, create/delete
- Sidebar: alerts count badge, SMTP link under admin
- Mobile tab nav: alerts tab added
- Client trpc.ts: all new API methods + types

Backend:
- watchlists.listByWatchlist procedure + listSymbolsByWatchlist repo fn
- yfinance min-interval 1500->2000ms to reduce Edge 429s
- Fixed e.date.slice error in yfinance-adjustments with typeof guard
- Removed defunct BITF from watchlist+queue
- Cleared 83 failed + 12 backoff queue jobs

Docs:
- FUNCTIONAL_DESIGN.md: alerts + multiple watchlists + SMTP documented
- TECH_DESIGN.md: new modules, tRPC procs, routes updated
2026-07-23 20:51:47 -04:00

132 lines
4.8 KiB
TypeScript

import nodemailer from 'nodemailer';
import type { DatabaseSync } from 'node:sqlite';
import type { Alert } from '../alerts/AlertEngine.ts';
interface SmtpConfigRow {
id: string;
host: string;
port: number;
secure: number;
user: string | null;
pass_enc: string | null;
from_name: string;
from_email: string;
enabled: number;
updated_at: string;
}
export interface SmtpConfig {
host: string;
port: number;
secure: boolean;
user: string | null;
passEnc: string | null;
fromName: string;
fromEmail: string;
enabled: boolean;
}
export function readSmtpConfig(db: DatabaseSync): SmtpConfig | null {
const row = db.prepare('SELECT * FROM smtp_config WHERE id = ?').get('singleton') as SmtpConfigRow | undefined;
if (!row) return null;
return {
host: row.host,
port: row.port,
secure: row.secure === 1,
user: row.user ?? null,
passEnc: row.pass_enc ?? null,
fromName: row.from_name,
fromEmail: row.from_email,
enabled: row.enabled === 1,
};
}
function buildAlertEmailHtml(alert: Alert): string {
const severityColors: Record<string, string> = {
info: '#3b82f6',
warning: '#f59e0b',
critical: '#ef4444',
};
const color = severityColors[alert.severity] ?? '#6b7280';
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;margin:0;padding:0;background:#f9fafb">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;padding:20px">
<tr><td align="center">
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1)">
<tr><td style="padding:24px;border-bottom:3px solid ${color}">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td><h1 style="margin:0;font-size:20px;color:#111827">Investor Flow</h1></td>
<td align="right"><span style="display:inline-block;padding:4px 12px;border-radius:12px;font-size:12px;font-weight:600;color:#fff;background:${color};text-transform:uppercase">${alert.severity}</span></td>
</tr>
</table>
</td></tr>
<tr><td style="padding:24px">
<h2 style="margin:0 0 8px;font-size:18px;color:#111827">${alert.title}</h2>
<p style="margin:0 0 16px;font-size:14px;color:#6b7280;line-height:1.5">${alert.description.replace(/\n/g, '<br>')}</p>
<table width="100%" cellpadding="0" cellspacing="0" style="font-size:13px;color:#6b7280">
${alert.symbol ? `<tr><td style="padding:4px 0">Symbol:</td><td style="padding:4px 0;font-weight:600;color:#111827">${alert.symbol}</td></tr>` : ''}
<tr><td style="padding:4px 0">Time:</td><td style="padding:4px 0;font-weight:600;color:#111827">${new Date(alert.createdAt).toLocaleString()}</td></tr>
<tr><td style="padding:4px 0">Type:</td><td style="padding:4px 0;font-weight:600;color:#111827">${alert.type.replace(/_/g, ' ')}</td></tr>
</table>
</td></tr>
<tr><td style="padding:16px 24px;background:#f9fafb;border-top:1px solid #e5e7eb;font-size:12px;color:#9ca3af;text-align:center">
<p style="margin:0">This alert was sent by Investor Flow. You can manage your alert subscriptions in the app.</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
export function getUserEmail(db: DatabaseSync, userId: string): string | null {
const row = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
return row?.email ?? null;
}
export async function sendAlertEmail(
db: DatabaseSync,
alert: Alert,
): Promise<boolean> {
const config = readSmtpConfig(db);
if (!config || !config.enabled) return false;
const userEmail = getUserEmail(db, alert.userId);
if (!userEmail) return false;
// Check rate limit: 1 per alert_type per symbol per hour.
const hourAgo = new Date(Date.now() - 3600000).toISOString();
const sent = db.prepare(
`SELECT COUNT(*) as count FROM alert_events
WHERE user_id = ? AND type = ? AND symbol = ? AND created_at > ?`,
).get(alert.userId, alert.type, alert.symbol ?? null, hourAgo) as { count: number };
if (sent.count > 1) return false;
try {
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.user && config.passEnc
? { user: config.user, pass: config.passEnc }
: undefined,
});
await transporter.sendMail({
from: `"${config.fromName}" <${config.fromEmail}>`,
to: userEmail,
subject: `[${alert.severity.toUpperCase()}] ${alert.title}`,
html: buildAlertEmailHtml(alert),
});
console.log(`[alert:email] sent ${alert.type} alert to ${userEmail}`);
return true;
} catch (err) {
console.error(`[alert:email] failed to send to ${userEmail}:`, err);
return false;
}
}