Split oversized frontend API client, email service, and admin/booking pages into focused modules while preserving import surfaces, and add Redis-backed queues, stale booking cleanup, stronger auth, and scale deployment configs. Co-authored-by: Cursor <cursoragent@cursor.com>
309 lines
8.4 KiB
TypeScript
309 lines
8.4 KiB
TypeScript
// Email transport layer: provider configuration, SMTP setup, and the low-level
|
|
// sendEmail router. No template rendering or DB logging happens here.
|
|
|
|
import nodemailer from 'nodemailer';
|
|
import type { Transporter } from 'nodemailer';
|
|
|
|
// ==================== Types ====================
|
|
|
|
export interface SendEmailOptions {
|
|
to: string | string[];
|
|
subject: string;
|
|
html: string;
|
|
text?: string;
|
|
replyTo?: string;
|
|
}
|
|
|
|
export interface SendEmailResult {
|
|
success: boolean;
|
|
messageId?: string;
|
|
error?: string;
|
|
}
|
|
|
|
export type EmailProvider = 'resend' | 'smtp' | 'console';
|
|
|
|
// ==================== Provider Configuration ====================
|
|
|
|
function getEmailProvider(): EmailProvider {
|
|
const provider = (process.env.EMAIL_PROVIDER || 'console').toLowerCase();
|
|
if (provider === 'resend' || provider === 'smtp' || provider === 'console') {
|
|
return provider;
|
|
}
|
|
console.warn(`[Email] Unknown provider "${provider}", falling back to console`);
|
|
return 'console';
|
|
}
|
|
|
|
function getFromEmail(): string {
|
|
return process.env.EMAIL_FROM || 'noreply@spanglish.com';
|
|
}
|
|
|
|
function getFromName(): string {
|
|
return process.env.EMAIL_FROM_NAME || 'Spanglish';
|
|
}
|
|
|
|
/** Provider info for diagnostics endpoints. */
|
|
export function getProviderInfo(): { provider: EmailProvider; configured: boolean } {
|
|
const provider = getEmailProvider();
|
|
let configured = false;
|
|
|
|
switch (provider) {
|
|
case 'resend':
|
|
configured = !!(process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY);
|
|
break;
|
|
case 'smtp':
|
|
configured = !!process.env.SMTP_HOST;
|
|
break;
|
|
case 'console':
|
|
configured = true;
|
|
break;
|
|
}
|
|
|
|
return { provider, configured };
|
|
}
|
|
|
|
// ==================== SMTP Configuration ====================
|
|
|
|
interface SMTPConfig {
|
|
host: string;
|
|
port: number;
|
|
secure: boolean;
|
|
auth?: {
|
|
user: string;
|
|
pass: string;
|
|
};
|
|
}
|
|
|
|
function getSMTPConfig(): SMTPConfig | null {
|
|
const host = process.env.SMTP_HOST;
|
|
const port = parseInt(process.env.SMTP_PORT || '587');
|
|
const user = process.env.SMTP_USER;
|
|
const pass = process.env.SMTP_PASS;
|
|
const secure = process.env.SMTP_SECURE === 'true' || port === 465;
|
|
|
|
if (!host) {
|
|
return null;
|
|
}
|
|
|
|
const config: SMTPConfig = {
|
|
host,
|
|
port,
|
|
secure,
|
|
};
|
|
|
|
if (user && pass) {
|
|
config.auth = { user, pass };
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
// Cached SMTP transporter
|
|
let smtpTransporter: Transporter | null = null;
|
|
|
|
function getSMTPTransporter(): Transporter | null {
|
|
if (smtpTransporter) {
|
|
return smtpTransporter;
|
|
}
|
|
|
|
const config = getSMTPConfig();
|
|
if (!config) {
|
|
console.error('[Email] SMTP configuration missing');
|
|
return null;
|
|
}
|
|
|
|
smtpTransporter = nodemailer.createTransport({
|
|
host: config.host,
|
|
port: config.port,
|
|
secure: config.secure,
|
|
auth: config.auth,
|
|
// Additional options for better deliverability
|
|
pool: true,
|
|
maxConnections: 5,
|
|
maxMessages: 100,
|
|
// TLS options
|
|
tls: {
|
|
rejectUnauthorized: process.env.SMTP_TLS_REJECT_UNAUTHORIZED !== 'false',
|
|
},
|
|
});
|
|
|
|
// Verify connection configuration
|
|
smtpTransporter.verify((error, success) => {
|
|
if (error) {
|
|
console.error('[Email] SMTP connection verification failed:', error.message);
|
|
} else {
|
|
console.log('[Email] SMTP server is ready to send emails');
|
|
}
|
|
});
|
|
|
|
return smtpTransporter;
|
|
}
|
|
|
|
// ==================== Email Providers ====================
|
|
|
|
/**
|
|
* Send email using Resend API
|
|
*/
|
|
async function sendWithResend(options: SendEmailOptions): Promise<SendEmailResult> {
|
|
const apiKey = process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY;
|
|
const fromEmail = getFromEmail();
|
|
const fromName = getFromName();
|
|
|
|
if (!apiKey) {
|
|
console.error('[Email] Resend API key not configured');
|
|
return { success: false, error: 'Resend API key not configured' };
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('https://api.resend.com/emails', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
from: `${fromName} <${fromEmail}>`,
|
|
to: Array.isArray(options.to) ? options.to : [options.to],
|
|
subject: options.subject,
|
|
html: options.html,
|
|
text: options.text,
|
|
reply_to: options.replyTo,
|
|
}),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
console.error('[Email] Resend API error:', data);
|
|
return {
|
|
success: false,
|
|
error: data.message || data.error || 'Failed to send email'
|
|
};
|
|
}
|
|
|
|
console.log('[Email] Email sent via Resend:', data.id);
|
|
return {
|
|
success: true,
|
|
messageId: data.id
|
|
};
|
|
} catch (error: any) {
|
|
console.error('[Email] Resend error:', error);
|
|
return {
|
|
success: false,
|
|
error: error.message || 'Failed to send email via Resend'
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send email using SMTP (Nodemailer)
|
|
*/
|
|
async function sendWithSMTP(options: SendEmailOptions): Promise<SendEmailResult> {
|
|
const transporter = getSMTPTransporter();
|
|
|
|
if (!transporter) {
|
|
return { success: false, error: 'SMTP not configured' };
|
|
}
|
|
|
|
const fromEmail = getFromEmail();
|
|
const fromName = getFromName();
|
|
|
|
try {
|
|
const info = await transporter.sendMail({
|
|
from: `"${fromName}" <${fromEmail}>`,
|
|
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
|
|
replyTo: options.replyTo,
|
|
subject: options.subject,
|
|
html: options.html,
|
|
text: options.text,
|
|
});
|
|
|
|
console.log('[Email] Email sent via SMTP:', info.messageId);
|
|
return {
|
|
success: true,
|
|
messageId: info.messageId
|
|
};
|
|
} catch (error: any) {
|
|
console.error('[Email] SMTP error:', error);
|
|
return {
|
|
success: false,
|
|
error: error.message || 'Failed to send email via SMTP'
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Console logger for development/testing (no actual email sent)
|
|
*/
|
|
async function sendWithConsole(options: SendEmailOptions): Promise<SendEmailResult> {
|
|
const to = Array.isArray(options.to) ? options.to.join(', ') : options.to;
|
|
|
|
console.log('\n========================================');
|
|
console.log('[Email] Console Mode - Email Preview');
|
|
console.log('========================================');
|
|
console.log(`To: ${to}`);
|
|
console.log(`Subject: ${options.subject}`);
|
|
console.log(`Reply-To: ${options.replyTo || 'N/A'}`);
|
|
console.log('----------------------------------------');
|
|
console.log('HTML Body (truncated):');
|
|
console.log(options.html?.substring(0, 500) + '...');
|
|
console.log('========================================\n');
|
|
|
|
return {
|
|
success: true,
|
|
messageId: `console-${Date.now()}`
|
|
};
|
|
}
|
|
|
|
// Mask an email address for logs: keep first char + domain (e.g. j***@example.com).
|
|
function maskEmail(email: string): string {
|
|
const [local, domain] = String(email).split('@');
|
|
if (!domain) return '***';
|
|
const head = local.slice(0, 1);
|
|
return `${head}***@${domain}`;
|
|
}
|
|
|
|
/**
|
|
* Main send function that routes to the appropriate provider
|
|
*/
|
|
export async function sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
|
|
const provider = getEmailProvider();
|
|
|
|
const recipientCount = Array.isArray(options.to) ? options.to.length : 1;
|
|
const sample = Array.isArray(options.to) ? options.to[0] : options.to;
|
|
console.log(`[Email] Sending email via ${provider} to ${maskEmail(sample)}${recipientCount > 1 ? ` (+${recipientCount - 1} more)` : ''}`);
|
|
|
|
switch (provider) {
|
|
case 'resend':
|
|
return sendWithResend(options);
|
|
case 'smtp':
|
|
return sendWithSMTP(options);
|
|
case 'console':
|
|
default:
|
|
return sendWithConsole(options);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test email configuration by sending a test email
|
|
*/
|
|
export async function testConnection(to: string): Promise<SendEmailResult> {
|
|
const { provider, configured } = getProviderInfo();
|
|
|
|
if (!configured) {
|
|
return { success: false, error: `Email provider "${provider}" is not configured` };
|
|
}
|
|
|
|
return sendEmail({
|
|
to,
|
|
subject: 'Spanglish - Email Test',
|
|
html: `
|
|
<h2>Email Configuration Test</h2>
|
|
<p>This is a test email from your Spanglish platform.</p>
|
|
<p><strong>Provider:</strong> ${provider}</p>
|
|
<p><strong>Timestamp:</strong> ${new Date().toISOString()}</p>
|
|
<p>If you received this email, your email configuration is working correctly!</p>
|
|
`,
|
|
text: `Email Configuration Test\n\nProvider: ${provider}\nTimestamp: ${new Date().toISOString()}\n\nIf you received this email, your email configuration is working correctly!`,
|
|
});
|
|
}
|