- Add in-memory email queue with rate limiting (MAX_EMAILS_PER_HOUR) - Bulk send to event attendees now queues and returns immediately - Frontend shows 'Emails are being sent in the background' - Legal pages, settings, and placeholders updates Co-authored-by: Cursor <cursoragent@cursor.com>
1351 lines
40 KiB
TypeScript
1351 lines
40 KiB
TypeScript
// Email service for Spanglish platform
|
|
// Supports multiple email providers: Resend, SMTP (Nodemailer)
|
|
|
|
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets, payments, users, paymentOptions, eventPaymentOverrides, siteSettings } from '../db/index.js';
|
|
import { eq, and } from 'drizzle-orm';
|
|
import { getNow, generateId } from './utils.js';
|
|
import {
|
|
replaceTemplateVariables,
|
|
wrapInBaseTemplate,
|
|
defaultTemplates,
|
|
type DefaultTemplate
|
|
} from './emailTemplates.js';
|
|
import { enqueueBulkEmails, type TemplateEmailJobParams } from './emailQueue.js';
|
|
import nodemailer from 'nodemailer';
|
|
import type { Transporter } from 'nodemailer';
|
|
|
|
// ==================== Types ====================
|
|
|
|
interface SendEmailOptions {
|
|
to: string | string[];
|
|
subject: string;
|
|
html: string;
|
|
text?: string;
|
|
replyTo?: string;
|
|
}
|
|
|
|
interface SendEmailResult {
|
|
success: boolean;
|
|
messageId?: string;
|
|
error?: string;
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
// ==================== 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()}`
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Main send function that routes to the appropriate provider
|
|
*/
|
|
async function sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
|
|
const provider = getEmailProvider();
|
|
|
|
console.log(`[Email] Sending email via ${provider} to ${Array.isArray(options.to) ? options.to.join(', ') : options.to}`);
|
|
|
|
switch (provider) {
|
|
case 'resend':
|
|
return sendWithResend(options);
|
|
case 'smtp':
|
|
return sendWithSMTP(options);
|
|
case 'console':
|
|
default:
|
|
return sendWithConsole(options);
|
|
}
|
|
}
|
|
|
|
// ==================== Email Service ====================
|
|
|
|
export const emailService = {
|
|
/**
|
|
* Get current email provider info
|
|
*/
|
|
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 };
|
|
},
|
|
|
|
/**
|
|
* Test email configuration by sending a test email
|
|
*/
|
|
async testConnection(to: string): Promise<SendEmailResult> {
|
|
const { provider, configured } = this.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!`,
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Get common variables for all emails
|
|
*/
|
|
getCommonVariables(): Record<string, string> {
|
|
return {
|
|
siteName: 'Spanglish',
|
|
siteUrl: process.env.FRONTEND_URL || 'https://spanglish.com',
|
|
currentYear: new Date().getFullYear().toString(),
|
|
supportEmail: process.env.EMAIL_FROM || 'hello@spanglish.com',
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Get the site timezone from settings (cached for performance)
|
|
*/
|
|
async getSiteTimezone(): Promise<string> {
|
|
const settings = await dbGet<any>(
|
|
(db as any).select().from(siteSettings).limit(1)
|
|
);
|
|
return settings?.timezone || 'America/Asuncion';
|
|
},
|
|
|
|
/**
|
|
* Format date for emails using site timezone
|
|
*/
|
|
formatDate(dateStr: string, locale: string = 'en', timezone: string = 'America/Asuncion'): string {
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
timeZone: timezone,
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Format time for emails using site timezone
|
|
*/
|
|
formatTime(dateStr: string, locale: string = 'en', timezone: string = 'America/Asuncion'): string {
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
timeZone: timezone,
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Format currency
|
|
*/
|
|
formatCurrency(amount: number, currency: string = 'PYG'): string {
|
|
if (currency === 'PYG') {
|
|
return `${amount.toLocaleString('es-PY')} PYG`;
|
|
}
|
|
return `$${amount.toFixed(2)} ${currency}`;
|
|
},
|
|
|
|
/**
|
|
* Get a template by slug
|
|
*/
|
|
async getTemplate(slug: string): Promise<any | null> {
|
|
const template = await dbGet(
|
|
(db as any)
|
|
.select()
|
|
.from(emailTemplates)
|
|
.where(eq((emailTemplates as any).slug, slug))
|
|
);
|
|
|
|
return template || null;
|
|
},
|
|
|
|
/**
|
|
* Seed default templates if they don't exist, and update system templates with latest content
|
|
*/
|
|
async seedDefaultTemplates(): Promise<void> {
|
|
console.log('[Email] Checking for default templates...');
|
|
|
|
for (const template of defaultTemplates) {
|
|
const existing = await this.getTemplate(template.slug);
|
|
const now = getNow();
|
|
|
|
if (!existing) {
|
|
console.log(`[Email] Creating template: ${template.name}`);
|
|
|
|
await (db as any).insert(emailTemplates).values({
|
|
id: generateId(),
|
|
name: template.name,
|
|
slug: template.slug,
|
|
subject: template.subject,
|
|
subjectEs: template.subjectEs,
|
|
bodyHtml: template.bodyHtml,
|
|
bodyHtmlEs: template.bodyHtmlEs,
|
|
bodyText: template.bodyText,
|
|
bodyTextEs: template.bodyTextEs,
|
|
description: template.description,
|
|
variables: JSON.stringify(template.variables),
|
|
isSystem: template.isSystem ? 1 : 0,
|
|
isActive: 1,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
} else if (existing.isSystem) {
|
|
// Update system templates with latest content from defaults
|
|
console.log(`[Email] Updating system template: ${template.name}`);
|
|
|
|
await (db as any)
|
|
.update(emailTemplates)
|
|
.set({
|
|
subject: template.subject,
|
|
subjectEs: template.subjectEs,
|
|
bodyHtml: template.bodyHtml,
|
|
bodyHtmlEs: template.bodyHtmlEs,
|
|
bodyText: template.bodyText,
|
|
bodyTextEs: template.bodyTextEs,
|
|
description: template.description,
|
|
variables: JSON.stringify(template.variables),
|
|
updatedAt: now,
|
|
})
|
|
.where(eq((emailTemplates as any).slug, template.slug));
|
|
}
|
|
}
|
|
|
|
console.log('[Email] Default templates check complete');
|
|
},
|
|
|
|
/**
|
|
* Send an email using a template
|
|
*/
|
|
async sendTemplateEmail(params: {
|
|
templateSlug: string;
|
|
to: string;
|
|
toName?: string;
|
|
variables: Record<string, any>;
|
|
locale?: string;
|
|
eventId?: string;
|
|
sentBy?: string;
|
|
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
|
const { templateSlug, to, toName, variables, locale = 'en', eventId, sentBy } = params;
|
|
|
|
// Get template
|
|
const template = await this.getTemplate(templateSlug);
|
|
if (!template) {
|
|
return { success: false, error: `Template "${templateSlug}" not found` };
|
|
}
|
|
|
|
// Build variables
|
|
const allVariables = {
|
|
...this.getCommonVariables(),
|
|
lang: locale,
|
|
...variables,
|
|
};
|
|
|
|
// Get localized content
|
|
const subject = locale === 'es' && template.subjectEs
|
|
? template.subjectEs
|
|
: template.subject;
|
|
const bodyHtml = locale === 'es' && template.bodyHtmlEs
|
|
? template.bodyHtmlEs
|
|
: template.bodyHtml;
|
|
const bodyText = locale === 'es' && template.bodyTextEs
|
|
? template.bodyTextEs
|
|
: template.bodyText;
|
|
|
|
// Replace variables
|
|
const finalSubject = replaceTemplateVariables(subject, allVariables);
|
|
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables);
|
|
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
|
|
const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined;
|
|
|
|
// Create log entry
|
|
const logId = generateId();
|
|
const now = getNow();
|
|
|
|
await (db as any).insert(emailLogs).values({
|
|
id: logId,
|
|
templateId: template.id,
|
|
eventId: eventId || null,
|
|
recipientEmail: to,
|
|
recipientName: toName || null,
|
|
subject: finalSubject,
|
|
bodyHtml: finalBodyHtml,
|
|
status: 'pending',
|
|
sentBy: sentBy || null,
|
|
createdAt: now,
|
|
});
|
|
|
|
// Send email
|
|
const result = await sendEmail({
|
|
to,
|
|
subject: finalSubject,
|
|
html: finalBodyHtml,
|
|
text: finalBodyText,
|
|
});
|
|
|
|
// Update log with result
|
|
if (result.success) {
|
|
await (db as any)
|
|
.update(emailLogs)
|
|
.set({
|
|
status: 'sent',
|
|
sentAt: getNow(),
|
|
})
|
|
.where(eq((emailLogs as any).id, logId));
|
|
} else {
|
|
await (db as any)
|
|
.update(emailLogs)
|
|
.set({
|
|
status: 'failed',
|
|
errorMessage: result.error,
|
|
})
|
|
.where(eq((emailLogs as any).id, logId));
|
|
}
|
|
|
|
return {
|
|
success: result.success,
|
|
logId,
|
|
error: result.error
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Send booking confirmation email
|
|
* Supports multi-ticket bookings - includes all tickets in the booking
|
|
*/
|
|
async sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> {
|
|
// Get ticket with event info
|
|
const ticket = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).id, ticketId))
|
|
);
|
|
|
|
if (!ticket) {
|
|
return { success: false, error: 'Ticket not found' };
|
|
}
|
|
|
|
const event = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(events)
|
|
.where(eq((events as any).id, ticket.eventId))
|
|
);
|
|
|
|
if (!event) {
|
|
return { success: false, error: 'Event not found' };
|
|
}
|
|
|
|
// Get all tickets in this booking (if multi-ticket)
|
|
let allTickets: any[] = [ticket];
|
|
if (ticket.bookingId) {
|
|
allTickets = await dbAll(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
|
);
|
|
}
|
|
|
|
const ticketCount = allTickets.length;
|
|
const locale = ticket.preferredLanguage || 'en';
|
|
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
|
|
|
// Generate ticket PDF URL (primary ticket, or use combined endpoint for multi)
|
|
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
|
const ticketPdfUrl = ticketCount > 1 && ticket.bookingId
|
|
? `${apiUrl}/api/tickets/booking/${ticket.bookingId}/pdf`
|
|
: `${apiUrl}/api/tickets/${ticket.id}/pdf`;
|
|
|
|
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
|
|
|
// Build attendee list for multi-ticket emails
|
|
const attendeeNames = allTickets.map(t =>
|
|
`${t.attendeeFirstName} ${t.attendeeLastName || ''}`.trim()
|
|
).join(', ');
|
|
|
|
// Calculate total price for multi-ticket bookings
|
|
const totalPrice = event.price * ticketCount;
|
|
|
|
// Get site timezone for proper date/time formatting
|
|
const timezone = await this.getSiteTimezone();
|
|
|
|
return this.sendTemplateEmail({
|
|
templateSlug: 'booking-confirmation',
|
|
to: ticket.attendeeEmail,
|
|
toName: attendeeFullName,
|
|
locale,
|
|
eventId: event.id,
|
|
variables: {
|
|
attendeeName: attendeeFullName,
|
|
attendeeEmail: ticket.attendeeEmail,
|
|
ticketId: ticket.id,
|
|
bookingId: ticket.bookingId || ticket.id,
|
|
qrCode: ticket.qrCode || '',
|
|
ticketPdfUrl,
|
|
eventTitle,
|
|
eventDate: this.formatDate(event.startDatetime, locale, timezone),
|
|
eventTime: this.formatTime(event.startDatetime, locale, timezone),
|
|
eventLocation: event.location,
|
|
eventLocationUrl: event.locationUrl || '',
|
|
eventPrice: this.formatCurrency(event.price, event.currency),
|
|
// Multi-ticket specific variables
|
|
ticketCount: ticketCount.toString(),
|
|
totalPrice: this.formatCurrency(totalPrice, event.currency),
|
|
attendeeNames,
|
|
isMultiTicket: ticketCount > 1 ? 'true' : 'false',
|
|
},
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Send payment receipt email
|
|
*/
|
|
async sendPaymentReceipt(paymentId: string): Promise<{ success: boolean; error?: string }> {
|
|
// Get payment with ticket and event info
|
|
const payment = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).id, paymentId))
|
|
);
|
|
|
|
if (!payment) {
|
|
return { success: false, error: 'Payment not found' };
|
|
}
|
|
|
|
const ticket = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).id, payment.ticketId))
|
|
);
|
|
|
|
if (!ticket) {
|
|
return { success: false, error: 'Ticket not found' };
|
|
}
|
|
|
|
const event = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(events)
|
|
.where(eq((events as any).id, ticket.eventId))
|
|
);
|
|
|
|
if (!event) {
|
|
return { success: false, error: 'Event not found' };
|
|
}
|
|
|
|
// Calculate total amount for multi-ticket bookings
|
|
let totalAmount = payment.amount;
|
|
let ticketCount = 1;
|
|
|
|
if (ticket.bookingId) {
|
|
// Get all payments for this booking
|
|
const bookingTickets = await dbAll<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
|
);
|
|
|
|
ticketCount = bookingTickets.length;
|
|
|
|
// Sum up all payment amounts for the booking
|
|
const bookingPayments = await Promise.all(
|
|
bookingTickets.map((t: any) =>
|
|
dbGet<any>((db as any).select().from(payments).where(eq((payments as any).ticketId, t.id)))
|
|
)
|
|
);
|
|
|
|
totalAmount = bookingPayments
|
|
.filter((p: any) => p)
|
|
.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
|
|
}
|
|
|
|
const locale = ticket.preferredLanguage || 'en';
|
|
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
|
|
|
const paymentMethodNames: Record<string, Record<string, string>> = {
|
|
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago' },
|
|
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago' },
|
|
};
|
|
|
|
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
|
|
|
// Format amount with ticket count info for multi-ticket bookings
|
|
const amountDisplay = ticketCount > 1
|
|
? `${this.formatCurrency(totalAmount, payment.currency)} (${ticketCount} tickets)`
|
|
: this.formatCurrency(totalAmount, payment.currency);
|
|
|
|
// Get site timezone for proper date/time formatting
|
|
const timezone = await this.getSiteTimezone();
|
|
|
|
return this.sendTemplateEmail({
|
|
templateSlug: 'payment-receipt',
|
|
to: ticket.attendeeEmail,
|
|
toName: receiptFullName,
|
|
locale,
|
|
eventId: event.id,
|
|
variables: {
|
|
attendeeName: receiptFullName,
|
|
ticketId: ticket.bookingId || ticket.id,
|
|
eventTitle,
|
|
eventDate: this.formatDate(event.startDatetime, locale, timezone),
|
|
paymentAmount: amountDisplay,
|
|
paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider,
|
|
paymentReference: payment.reference || payment.id,
|
|
paymentDate: this.formatDate(payment.paidAt || payment.createdAt, locale, timezone),
|
|
},
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Get merged payment configuration for an event (global + overrides)
|
|
*/
|
|
async getPaymentConfig(eventId: string): Promise<Record<string, any>> {
|
|
// Get global options
|
|
const globalOptions = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(paymentOptions)
|
|
);
|
|
|
|
// Get event overrides
|
|
const overrides = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(eventPaymentOverrides)
|
|
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
|
);
|
|
|
|
// Defaults
|
|
const defaults = {
|
|
tpagoEnabled: false,
|
|
tpagoLink: null,
|
|
tpagoInstructions: null,
|
|
tpagoInstructionsEs: null,
|
|
bankTransferEnabled: false,
|
|
bankName: null,
|
|
bankAccountHolder: null,
|
|
bankAccountNumber: null,
|
|
bankAlias: null,
|
|
bankPhone: null,
|
|
bankNotes: null,
|
|
bankNotesEs: null,
|
|
};
|
|
|
|
const global = globalOptions || defaults;
|
|
|
|
// Merge: override values take precedence if they're not null/undefined
|
|
return {
|
|
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
|
|
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
|
|
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
|
|
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
|
|
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
|
|
bankName: overrides?.bankName ?? global.bankName,
|
|
bankAccountHolder: overrides?.bankAccountHolder ?? global.bankAccountHolder,
|
|
bankAccountNumber: overrides?.bankAccountNumber ?? global.bankAccountNumber,
|
|
bankAlias: overrides?.bankAlias ?? global.bankAlias,
|
|
bankPhone: overrides?.bankPhone ?? global.bankPhone,
|
|
bankNotes: overrides?.bankNotes ?? global.bankNotes,
|
|
bankNotesEs: overrides?.bankNotesEs ?? global.bankNotesEs,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Send payment instructions email (for TPago or Bank Transfer)
|
|
* This email is sent immediately after user clicks "Continue to Payment"
|
|
*/
|
|
async sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> {
|
|
// Get ticket
|
|
const ticket = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).id, ticketId))
|
|
);
|
|
|
|
if (!ticket) {
|
|
return { success: false, error: 'Ticket not found' };
|
|
}
|
|
|
|
// Get event
|
|
const event = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(events)
|
|
.where(eq((events as any).id, ticket.eventId))
|
|
);
|
|
|
|
if (!event) {
|
|
return { success: false, error: 'Event not found' };
|
|
}
|
|
|
|
// Get payment
|
|
const payment = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).ticketId, ticketId))
|
|
);
|
|
|
|
if (!payment) {
|
|
return { success: false, error: 'Payment not found' };
|
|
}
|
|
|
|
// Only send for manual payment methods
|
|
if (!['bank_transfer', 'tpago'].includes(payment.provider)) {
|
|
return { success: false, error: 'Payment instructions email only for bank_transfer or tpago' };
|
|
}
|
|
|
|
// Get merged payment config for this event
|
|
const paymentConfig = await this.getPaymentConfig(event.id);
|
|
|
|
const locale = ticket.preferredLanguage || 'en';
|
|
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
|
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
|
|
|
// Calculate total price for multi-ticket bookings
|
|
let totalPrice = event.price;
|
|
let ticketCount = 1;
|
|
|
|
if (ticket.bookingId) {
|
|
// Count all tickets in this booking
|
|
const bookingTickets = await dbAll<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
|
);
|
|
ticketCount = bookingTickets.length;
|
|
totalPrice = event.price * ticketCount;
|
|
}
|
|
|
|
// Generate a payment reference using booking ID or ticket ID
|
|
const paymentReference = `SPG-${(ticket.bookingId || ticket.id).substring(0, 8).toUpperCase()}`;
|
|
|
|
// Generate the booking URL for returning to payment page
|
|
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
|
const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`;
|
|
|
|
// Determine which template to use
|
|
const templateSlug = payment.provider === 'tpago'
|
|
? 'payment-instructions-tpago'
|
|
: 'payment-instructions-bank-transfer';
|
|
|
|
// Format amount with ticket count info for multi-ticket bookings
|
|
const amountDisplay = ticketCount > 1
|
|
? `${this.formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
|
|
: this.formatCurrency(totalPrice, event.currency);
|
|
|
|
// Get site timezone for proper date/time formatting
|
|
const timezone = await this.getSiteTimezone();
|
|
|
|
// Build variables based on payment method
|
|
const variables: Record<string, any> = {
|
|
attendeeName: attendeeFullName,
|
|
attendeeEmail: ticket.attendeeEmail,
|
|
ticketId: ticket.bookingId || ticket.id,
|
|
eventTitle,
|
|
eventDate: this.formatDate(event.startDatetime, locale, timezone),
|
|
eventTime: this.formatTime(event.startDatetime, locale, timezone),
|
|
eventLocation: event.location,
|
|
eventLocationUrl: event.locationUrl || '',
|
|
paymentAmount: amountDisplay,
|
|
paymentReference,
|
|
bookingUrl,
|
|
};
|
|
|
|
// Add payment-method specific variables
|
|
if (payment.provider === 'tpago') {
|
|
variables.tpagoLink = paymentConfig.tpagoLink || '';
|
|
} else {
|
|
// Bank transfer
|
|
variables.bankName = paymentConfig.bankName || '';
|
|
variables.bankAccountHolder = paymentConfig.bankAccountHolder || '';
|
|
variables.bankAccountNumber = paymentConfig.bankAccountNumber || '';
|
|
variables.bankAlias = paymentConfig.bankAlias || '';
|
|
variables.bankPhone = paymentConfig.bankPhone || '';
|
|
}
|
|
|
|
console.log(`[Email] Sending payment instructions email (${payment.provider}) to ${ticket.attendeeEmail}`);
|
|
|
|
return this.sendTemplateEmail({
|
|
templateSlug,
|
|
to: ticket.attendeeEmail,
|
|
toName: attendeeFullName,
|
|
locale,
|
|
eventId: event.id,
|
|
variables,
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Send payment rejection email
|
|
* This email is sent when admin rejects a TPago or Bank Transfer payment
|
|
*/
|
|
async sendPaymentRejectionEmail(paymentId: string): Promise<{ success: boolean; error?: string }> {
|
|
// Get payment
|
|
const payment = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).id, paymentId))
|
|
);
|
|
|
|
if (!payment) {
|
|
return { success: false, error: 'Payment not found' };
|
|
}
|
|
|
|
// Get ticket
|
|
const ticket = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).id, payment.ticketId))
|
|
);
|
|
|
|
if (!ticket) {
|
|
return { success: false, error: 'Ticket not found' };
|
|
}
|
|
|
|
// Get event
|
|
const event = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(events)
|
|
.where(eq((events as any).id, ticket.eventId))
|
|
);
|
|
|
|
if (!event) {
|
|
return { success: false, error: 'Event not found' };
|
|
}
|
|
|
|
const locale = ticket.preferredLanguage || 'en';
|
|
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
|
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
|
|
|
// Generate a new booking URL for the event
|
|
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
|
const newBookingUrl = `${frontendUrl}/book/${event.id}`;
|
|
|
|
// Get site timezone for proper date/time formatting
|
|
const timezone = await this.getSiteTimezone();
|
|
|
|
console.log(`[Email] Sending payment rejection email to ${ticket.attendeeEmail}`);
|
|
|
|
return this.sendTemplateEmail({
|
|
templateSlug: 'payment-rejected',
|
|
to: ticket.attendeeEmail,
|
|
toName: attendeeFullName,
|
|
locale,
|
|
eventId: event.id,
|
|
variables: {
|
|
attendeeName: attendeeFullName,
|
|
attendeeEmail: ticket.attendeeEmail,
|
|
ticketId: ticket.id,
|
|
eventTitle,
|
|
eventDate: this.formatDate(event.startDatetime, locale, timezone),
|
|
eventTime: this.formatTime(event.startDatetime, locale, timezone),
|
|
eventLocation: event.location,
|
|
eventLocationUrl: event.locationUrl || '',
|
|
newBookingUrl,
|
|
},
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Send payment reminder email
|
|
* This email is sent when admin wants to remind attendee about pending payment
|
|
*/
|
|
async sendPaymentReminder(paymentId: string): Promise<{ success: boolean; error?: string }> {
|
|
// Get payment
|
|
const payment = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).id, paymentId))
|
|
);
|
|
|
|
if (!payment) {
|
|
return { success: false, error: 'Payment not found' };
|
|
}
|
|
|
|
// Only send for pending/pending_approval payments
|
|
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
|
return { success: false, error: 'Payment reminder can only be sent for pending payments' };
|
|
}
|
|
|
|
// Get ticket
|
|
const ticket = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).id, payment.ticketId))
|
|
);
|
|
|
|
if (!ticket) {
|
|
return { success: false, error: 'Ticket not found' };
|
|
}
|
|
|
|
// Get event
|
|
const event = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(events)
|
|
.where(eq((events as any).id, ticket.eventId))
|
|
);
|
|
|
|
if (!event) {
|
|
return { success: false, error: 'Event not found' };
|
|
}
|
|
|
|
const locale = ticket.preferredLanguage || 'en';
|
|
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
|
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
|
|
|
// Calculate total price for multi-ticket bookings
|
|
let totalPrice = event.price;
|
|
let ticketCount = 1;
|
|
|
|
if (ticket.bookingId) {
|
|
const bookingTickets = await dbAll<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
|
);
|
|
ticketCount = bookingTickets.length;
|
|
totalPrice = event.price * ticketCount;
|
|
}
|
|
|
|
// Generate the booking URL for returning to payment page
|
|
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
|
const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`;
|
|
|
|
// Format amount with ticket count info for multi-ticket bookings
|
|
const amountDisplay = ticketCount > 1
|
|
? `${this.formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
|
|
: this.formatCurrency(totalPrice, event.currency);
|
|
|
|
// Get site timezone for proper date/time formatting
|
|
const timezone = await this.getSiteTimezone();
|
|
|
|
console.log(`[Email] Sending payment reminder email to ${ticket.attendeeEmail}`);
|
|
|
|
return this.sendTemplateEmail({
|
|
templateSlug: 'payment-reminder',
|
|
to: ticket.attendeeEmail,
|
|
toName: attendeeFullName,
|
|
locale,
|
|
eventId: event.id,
|
|
variables: {
|
|
attendeeName: attendeeFullName,
|
|
attendeeEmail: ticket.attendeeEmail,
|
|
ticketId: ticket.bookingId || ticket.id,
|
|
eventTitle,
|
|
eventDate: this.formatDate(event.startDatetime, locale, timezone),
|
|
eventTime: this.formatTime(event.startDatetime, locale, timezone),
|
|
eventLocation: event.location,
|
|
eventLocationUrl: event.locationUrl || '',
|
|
paymentAmount: amountDisplay,
|
|
bookingUrl,
|
|
},
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Send custom email to event attendees
|
|
*/
|
|
async sendToEventAttendees(params: {
|
|
eventId: string;
|
|
templateSlug: string;
|
|
customVariables?: Record<string, any>;
|
|
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
|
sentBy: string;
|
|
}): Promise<{ success: boolean; sentCount: number; failedCount: number; errors: string[] }> {
|
|
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
|
|
|
|
// Get event
|
|
const event = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(events)
|
|
.where(eq((events as any).id, eventId))
|
|
);
|
|
|
|
if (!event) {
|
|
return { success: false, sentCount: 0, failedCount: 0, errors: ['Event not found'] };
|
|
}
|
|
|
|
// Get tickets based on filter
|
|
let ticketQuery = (db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).eventId, eventId));
|
|
|
|
if (recipientFilter !== 'all') {
|
|
ticketQuery = ticketQuery.where(
|
|
and(
|
|
eq((tickets as any).eventId, eventId),
|
|
eq((tickets as any).status, recipientFilter)
|
|
)
|
|
);
|
|
}
|
|
|
|
const eventTickets = await dbAll<any>(ticketQuery);
|
|
|
|
if (eventTickets.length === 0) {
|
|
return { success: true, sentCount: 0, failedCount: 0, errors: ['No recipients found'] };
|
|
}
|
|
|
|
// Get site timezone for proper date/time formatting
|
|
const timezone = await this.getSiteTimezone();
|
|
|
|
let sentCount = 0;
|
|
let failedCount = 0;
|
|
const errors: string[] = [];
|
|
|
|
// Send to each attendee
|
|
for (const ticket of eventTickets) {
|
|
const locale = ticket.preferredLanguage || 'en';
|
|
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
|
|
|
const bulkFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
|
const result = await this.sendTemplateEmail({
|
|
templateSlug,
|
|
to: ticket.attendeeEmail,
|
|
toName: bulkFullName,
|
|
locale,
|
|
eventId: event.id,
|
|
sentBy,
|
|
variables: {
|
|
attendeeName: bulkFullName,
|
|
attendeeEmail: ticket.attendeeEmail,
|
|
ticketId: ticket.id,
|
|
eventTitle,
|
|
eventDate: this.formatDate(event.startDatetime, locale, timezone),
|
|
eventTime: this.formatTime(event.startDatetime, locale, timezone),
|
|
eventLocation: event.location,
|
|
eventLocationUrl: event.locationUrl || '',
|
|
...customVariables,
|
|
},
|
|
});
|
|
|
|
if (result.success) {
|
|
sentCount++;
|
|
} else {
|
|
failedCount++;
|
|
errors.push(`Failed to send to ${ticket.attendeeEmail}: ${result.error}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: failedCount === 0,
|
|
sentCount,
|
|
failedCount,
|
|
errors,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Queue emails for event attendees (non-blocking).
|
|
* Adds all matching recipients to the background email queue and returns immediately.
|
|
* Rate limiting and actual sending is handled by the email queue.
|
|
*/
|
|
async queueEventEmails(params: {
|
|
eventId: string;
|
|
templateSlug: string;
|
|
customVariables?: Record<string, any>;
|
|
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
|
sentBy: string;
|
|
}): Promise<{ success: boolean; queuedCount: number; error?: string }> {
|
|
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
|
|
|
|
// Validate event exists
|
|
const event = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(events)
|
|
.where(eq((events as any).id, eventId))
|
|
);
|
|
|
|
if (!event) {
|
|
return { success: false, queuedCount: 0, error: 'Event not found' };
|
|
}
|
|
|
|
// Validate template exists
|
|
const template = await this.getTemplate(templateSlug);
|
|
if (!template) {
|
|
return { success: false, queuedCount: 0, error: `Template "${templateSlug}" not found` };
|
|
}
|
|
|
|
// Get tickets based on filter
|
|
let ticketQuery = (db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).eventId, eventId));
|
|
|
|
if (recipientFilter !== 'all') {
|
|
ticketQuery = ticketQuery.where(
|
|
and(
|
|
eq((tickets as any).eventId, eventId),
|
|
eq((tickets as any).status, recipientFilter)
|
|
)
|
|
);
|
|
}
|
|
|
|
const eventTickets = await dbAll<any>(ticketQuery);
|
|
|
|
if (eventTickets.length === 0) {
|
|
return { success: true, queuedCount: 0, error: 'No recipients found' };
|
|
}
|
|
|
|
// Get site timezone for proper date/time formatting
|
|
const timezone = await this.getSiteTimezone();
|
|
|
|
// Build individual email jobs for the queue
|
|
const jobs: TemplateEmailJobParams[] = eventTickets.map((ticket: any) => {
|
|
const locale = ticket.preferredLanguage || 'en';
|
|
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
|
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
|
|
|
return {
|
|
templateSlug,
|
|
to: ticket.attendeeEmail,
|
|
toName: fullName,
|
|
locale,
|
|
eventId: event.id,
|
|
sentBy,
|
|
variables: {
|
|
attendeeName: fullName,
|
|
attendeeEmail: ticket.attendeeEmail,
|
|
ticketId: ticket.id,
|
|
eventTitle,
|
|
eventDate: this.formatDate(event.startDatetime, locale, timezone),
|
|
eventTime: this.formatTime(event.startDatetime, locale, timezone),
|
|
eventLocation: event.location,
|
|
eventLocationUrl: event.locationUrl || '',
|
|
...customVariables,
|
|
},
|
|
};
|
|
});
|
|
|
|
// Enqueue all emails for background processing
|
|
enqueueBulkEmails(jobs);
|
|
|
|
console.log(`[Email] Queued ${jobs.length} emails for event "${event.title}" (filter: ${recipientFilter})`);
|
|
|
|
return {
|
|
success: true,
|
|
queuedCount: jobs.length,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Send a custom email (not from template)
|
|
*/
|
|
async sendCustomEmail(params: {
|
|
to: string;
|
|
toName?: string;
|
|
subject: string;
|
|
bodyHtml: string;
|
|
bodyText?: string;
|
|
replyTo?: string;
|
|
eventId?: string;
|
|
sentBy?: string | null;
|
|
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
|
const { to, toName, subject, bodyHtml, bodyText, replyTo, eventId, sentBy = null } = params;
|
|
|
|
const allVariables = {
|
|
...this.getCommonVariables(),
|
|
subject,
|
|
};
|
|
|
|
const finalBodyHtml = wrapInBaseTemplate(bodyHtml, allVariables);
|
|
|
|
// Create log entry
|
|
const logId = generateId();
|
|
const now = getNow();
|
|
|
|
await (db as any).insert(emailLogs).values({
|
|
id: logId,
|
|
templateId: null,
|
|
eventId: eventId || null,
|
|
recipientEmail: to,
|
|
recipientName: toName || null,
|
|
subject,
|
|
bodyHtml: finalBodyHtml,
|
|
status: 'pending',
|
|
sentBy: sentBy || null,
|
|
createdAt: now,
|
|
});
|
|
|
|
// Send email
|
|
const result = await sendEmail({
|
|
to,
|
|
subject,
|
|
html: finalBodyHtml,
|
|
text: bodyText,
|
|
replyTo,
|
|
});
|
|
|
|
// Update log
|
|
if (result.success) {
|
|
await (db as any)
|
|
.update(emailLogs)
|
|
.set({
|
|
status: 'sent',
|
|
sentAt: getNow(),
|
|
})
|
|
.where(eq((emailLogs as any).id, logId));
|
|
} else {
|
|
await (db as any)
|
|
.update(emailLogs)
|
|
.set({
|
|
status: 'failed',
|
|
errorMessage: result.error,
|
|
})
|
|
.where(eq((emailLogs as any).id, logId));
|
|
}
|
|
|
|
return {
|
|
success: result.success,
|
|
logId,
|
|
error: result.error
|
|
};
|
|
},
|
|
};
|
|
|
|
// Export the main sendEmail function for direct use
|
|
export { sendEmail };
|
|
|
|
export default emailService;
|