monacousa-portal/src/lib/server/email.ts

413 lines
14 KiB
TypeScript
Raw Normal View History

import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
import { supabaseAdmin } from './supabase';
export interface SmtpConfig {
host: string;
port: number;
secure: boolean;
username: string;
password: string;
from_address: string;
from_name: string;
}
export interface SendEmailOptions {
to: string;
subject: string;
html: string;
text?: string;
recipientId?: string;
recipientName?: string;
templateKey?: string;
emailType?: string;
sentBy?: string;
}
/**
* Get SMTP configuration from app_settings table, with fallback to environment variables
* This allows welcome emails to work using the same SMTP as GoTrue when app_settings isn't configured
*/
export async function getSmtpConfig(): Promise<SmtpConfig | null> {
// First try to get from app_settings
const { data: settings } = await supabaseAdmin
.from('app_settings')
.select('setting_key, setting_value')
.eq('category', 'email');
const config: Record<string, string> = {};
if (settings && settings.length > 0) {
for (const s of settings) {
// Parse the value - it might be JSON stringified or plain
let value = s.setting_value;
if (typeof value === 'string') {
// Remove surrounding quotes if present
value = value.replace(/^"|"$/g, '');
}
config[s.setting_key] = value as string;
}
}
// Check if app_settings has valid SMTP config
if (config.smtp_host && config.smtp_username && config.smtp_password) {
return {
host: config.smtp_host,
port: parseInt(config.smtp_port || '587'),
secure: config.smtp_secure === 'true' || parseInt(config.smtp_port || '587') === 465,
username: config.smtp_username,
password: config.smtp_password,
from_address: config.smtp_from_address || 'noreply@monacousa.org',
from_name: config.smtp_from_name || 'Monaco USA'
};
}
// Fall back to environment variables (same as GoTrue SMTP settings)
const envHost = process.env.SMTP_HOST || process.env.GOTRUE_SMTP_HOST;
const envUser = process.env.SMTP_USER || process.env.GOTRUE_SMTP_USER;
const envPass = process.env.SMTP_PASS || process.env.GOTRUE_SMTP_PASS;
if (envHost && envUser && envPass) {
const envPort = process.env.SMTP_PORT || process.env.GOTRUE_SMTP_PORT || '587';
return {
host: envHost,
port: parseInt(envPort),
secure: parseInt(envPort) === 465,
username: envUser,
password: envPass,
from_address: process.env.SMTP_ADMIN_EMAIL || process.env.GOTRUE_SMTP_ADMIN_EMAIL || 'noreply@monacousa.org',
from_name: process.env.SMTP_SENDER_NAME || process.env.GOTRUE_SMTP_SENDER_NAME || 'Monaco USA'
};
}
return null;
}
/**
* Create a nodemailer transporter with the configured SMTP settings
*/
export async function createTransporter(): Promise<Transporter | null> {
const config = await getSmtpConfig();
if (!config) {
console.error('SMTP configuration not found or incomplete');
return null;
}
return nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: {
user: config.username,
pass: config.password
}
});
}
/**
* Send an email using the configured SMTP settings
*/
export async function sendEmail(options: SendEmailOptions): Promise<{ success: boolean; error?: string; messageId?: string }> {
const config = await getSmtpConfig();
if (!config) {
return { success: false, error: 'SMTP not configured. Please configure email settings first.' };
}
const transporter = await createTransporter();
if (!transporter) {
return { success: false, error: 'Failed to create email transporter' };
}
try {
const result = await transporter.sendMail({
from: `"${config.from_name}" <${config.from_address}>`,
to: options.to,
subject: options.subject,
html: options.html,
text: options.text || stripHtml(options.html)
});
// Log to email_logs table
await supabaseAdmin.from('email_logs').insert({
recipient_id: options.recipientId || null,
recipient_email: options.to,
recipient_name: options.recipientName || null,
template_key: options.templateKey || null,
subject: options.subject,
email_type: options.emailType || 'manual',
status: 'sent',
provider: 'smtp',
provider_message_id: result.messageId,
sent_by: options.sentBy || null,
sent_at: new Date().toISOString()
});
return { success: true, messageId: result.messageId };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Email send error:', error);
// Log failed attempt
await supabaseAdmin.from('email_logs').insert({
recipient_id: options.recipientId || null,
recipient_email: options.to,
recipient_name: options.recipientName || null,
template_key: options.templateKey || null,
subject: options.subject,
email_type: options.emailType || 'manual',
status: 'failed',
provider: 'smtp',
error_message: errorMessage,
sent_by: options.sentBy || null
});
return { success: false, error: errorMessage };
}
}
/**
* Send a templated email with variable substitution
* Templates should contain content only (no full HTML wrapper) - will be wrapped automatically
*/
export async function sendTemplatedEmail(
templateKey: string,
to: string,
variables: Record<string, string>,
options?: {
recipientId?: string;
recipientName?: string;
sentBy?: string;
baseUrl?: string;
}
): Promise<{ success: boolean; error?: string; messageId?: string }> {
// Fetch template from database
const { data: template, error: templateError } = await supabaseAdmin
.from('email_templates')
.select('*')
.eq('template_key', templateKey)
.eq('is_active', true)
.single();
if (templateError || !template) {
return { success: false, error: `Email template "${templateKey}" not found or inactive` };
}
// Get site URL for logo
const baseUrl = options?.baseUrl || process.env.SITE_URL || 'https://monacousa.org';
const logoUrl = `${baseUrl}/MONACOUSA-Flags_376x376.png`;
// Add default variables
const allVariables: Record<string, string> = {
logo_url: logoUrl,
site_url: baseUrl,
...variables
};
// Replace variables in subject and body
let subject = template.subject;
let bodyContent = template.body_html;
let text = template.body_text || '';
for (const [key, value] of Object.entries(allVariables)) {
const regex = new RegExp(`{{${key}}}`, 'g');
subject = subject.replace(regex, value);
bodyContent = bodyContent.replace(regex, value);
text = text.replace(regex, value);
}
// Extract title from template or use subject
// Look for title in template metadata or first h2 tag
let emailTitle = template.email_title || subject;
// Try to extract from first h2 in content
const h2Match = bodyContent.match(/<h2[^>]*>([^<]+)<\/h2>/i);
if (h2Match) {
emailTitle = h2Match[1].replace(/{{[^}]+}}/g, '').trim();
}
// Check if template already has full HTML wrapper (legacy templates)
const hasFullWrapper = bodyContent.includes('<!DOCTYPE') || bodyContent.includes('<html');
let html: string;
if (hasFullWrapper) {
// Legacy template with full HTML - use as-is but inject background image
// Replace old gradient-only background with new background image pattern
html = bodyContent.replace(
/<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background: linear-gradient\([^)]+\); background-color: #0f172a;">/g,
`<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-image: url('${EMAIL_BACKGROUND_IMAGE_URL}'); background-size: cover; background-position: center; background-color: #0f172a;">`
);
} else {
// Content-only template - wrap with Monaco template
html = wrapInMonacoTemplate({
title: emailTitle,
content: bodyContent,
logoUrl
});
}
return sendEmail({
to,
subject,
html,
text: text || undefined,
recipientId: options?.recipientId,
recipientName: options?.recipientName,
templateKey,
emailType: template.category,
sentBy: options?.sentBy
});
}
/**
* Test SMTP connection and optionally send a test email
*/
export async function testSmtpConnection(
sendTo?: string,
sentBy?: string
): Promise<{ success: boolean; error?: string }> {
const config = await getSmtpConfig();
if (!config) {
return { success: false, error: 'SMTP not configured. Please configure and save email settings first.' };
}
const transporter = await createTransporter();
if (!transporter) {
return { success: false, error: 'Failed to create email transporter' };
}
try {
// Verify connection
await transporter.verify();
// If a recipient is provided, send a test email
if (sendTo) {
const testContent = `
<p style="margin: 0 0 16px 0; color: #334155; line-height: 1.6;">This is a test email from your Monaco USA Portal.</p>
<div style="background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 12px; padding: 20px; margin: 0 0 20px 0;">
<p style="margin: 0 0 8px 0; color: #166534; font-size: 14px; font-weight: 600;"> Configuration Verified</p>
<p style="margin: 0; color: #334155; font-size: 14px;">Your SMTP settings are working correctly!</p>
</div>
<p style="margin: 0; color: #64748b; font-size: 12px;">Sent at ${new Date().toLocaleString()}</p>`;
const result = await sendEmail({
to: sendTo,
subject: 'Monaco USA Portal - SMTP Test Email',
html: wrapInMonacoTemplate({
title: 'SMTP Test Successful!',
content: testContent
}),
emailType: 'test',
sentBy
});
if (!result.success) {
return { success: false, error: result.error };
}
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('SMTP test error:', error);
return { success: false, error: `SMTP connection failed: ${errorMessage}` };
}
}
// S3-hosted background image URL matching login screen
const EMAIL_BACKGROUND_IMAGE_URL = 'https://s3.monacousa.org/public/monaco_high_res.jpg';
/**
* Wrap email content in Monaco-branded template
* This creates a consistent look matching the login page styling with background image
*/
export function wrapInMonacoTemplate(options: {
title: string;
content: string;
logoUrl?: string;
backgroundImageUrl?: string;
}): string {
const baseUrl = process.env.SITE_URL || 'http://localhost:7453';
const logoUrl = options.logoUrl || `${baseUrl}/MONACOUSA-Flags_376x376.png`;
const bgImageUrl = options.backgroundImageUrl || EMAIL_BACKGROUND_IMAGE_URL;
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!--[if mso]>
<style type="text/css">
body, table, td { font-family: Arial, sans-serif !important; }
</style>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #0f172a;">
<!--[if gte mso 9]>
<v:background xmlns:v="urn:schemas-microsoft-com:vml" fill="t">
<v:fill type="tile" src="${bgImageUrl}" color="#0f172a"/>
</v:background>
<![endif]-->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-image: url('${bgImageUrl}'); background-size: cover; background-position: center; background-color: #0f172a;">
<tr>
<td>
<!-- Gradient overlay matching login screen: from-slate-900/80 via-slate-900/60 to-monaco-900/70 -->
<div style="background: linear-gradient(135deg, rgba(15, 23, 42, 0.8) 0%, rgba(15, 23, 42, 0.6) 50%, rgba(127, 29, 29, 0.7) 100%);">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding: 40px 20px;">
<!-- Logo Section -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px;">
<tr>
<td align="center" style="padding-bottom: 30px;">
<div style="background: rgba(255, 255, 255, 0.95); border-radius: 16px; padding: 12px; display: inline-block; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);">
<img src="${logoUrl}" alt="Monaco USA" width="80" height="80" style="display: block; border-radius: 8px;">
</div>
<h1 style="margin: 16px 0 4px 0; font-size: 24px; font-weight: bold; color: #ffffff; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);">Monaco <span style="color: #fca5a5;">USA</span></h1>
<p style="margin: 0; font-size: 14px; color: rgba(255, 255, 255, 0.8);">Americans in Monaco</p>
</td>
</tr>
</table>
<!-- Main Content Card -->
<table role="presentation" cellspacing="0" cellpadding="0" style="max-width: 480px; width: 100%; background: #ffffff; border-radius: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);">
<tr>
<td style="padding: 40px;">
<h2 style="margin: 0 0 20px 0; color: #CE1126; font-size: 24px; text-align: center;">${options.title}</h2>
<div style="text-align: left;">${options.content}</div>
</td>
</tr>
</table>
<!-- Footer -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px;">
<tr>
<td align="center" style="padding-top: 24px;">
<p style="margin: 0; font-size: 12px; color: rgba(255, 255, 255, 0.5);">&copy; 2026 Monaco USA. All rights reserved.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</body>
</html>`;
}
/**
* Strip HTML tags from a string to create plain text version
*/
function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim();
}