395 lines
13 KiB
TypeScript
395 lines
13 KiB
TypeScript
|
|
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
|
||
|
|
*/
|
||
|
|
export async function getSmtpConfig(): Promise<SmtpConfig | null> {
|
||
|
|
const { data: settings } = await supabaseAdmin
|
||
|
|
.from('app_settings')
|
||
|
|
.select('setting_key, setting_value')
|
||
|
|
.eq('category', 'email');
|
||
|
|
|
||
|
|
if (!settings || settings.length === 0) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const config: Record<string, string> = {};
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate required fields
|
||
|
|
if (!config.smtp_host || !config.smtp_username || !config.smtp_password) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
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'
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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);">© 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(/ /g, ' ')
|
||
|
|
.replace(/&/g, '&')
|
||
|
|
.replace(/</g, '<')
|
||
|
|
.replace(/>/g, '>')
|
||
|
|
.replace(/"/g, '"')
|
||
|
|
.replace(/'/g, "'")
|
||
|
|
.replace(/\s+/g, ' ')
|
||
|
|
.trim();
|
||
|
|
}
|