// 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 { 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 { 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 { 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 { 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 { 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: `

Email Configuration Test

This is a test email from your Spanglish platform.

Provider: ${provider}

Timestamp: ${new Date().toISOString()}

If you received this email, your email configuration is working correctly!

`, 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 { 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 { const settings = await dbGet( (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 { 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 { 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; 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( (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( (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( (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( (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( (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( (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((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> = { 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> { // Get global options const globalOptions = await dbGet( (db as any) .select() .from(paymentOptions) ); // Get event overrides const overrides = await dbGet( (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( (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( (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( (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( (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 = { 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( (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( (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( (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( (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( (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( (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( (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; 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( (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(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; 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( (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(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;