first commit
This commit is contained in:
784
backend/src/lib/email.ts
Normal file
784
backend/src/lib/email.ts
Normal file
@@ -0,0 +1,784 @@
|
||||
// Email service for Spanglish platform
|
||||
// Supports multiple email providers: Resend, SMTP (Nodemailer)
|
||||
|
||||
import { db, emailTemplates, emailLogs, events, tickets, payments, users } from '../db/index.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { getNow } from './utils.js';
|
||||
import {
|
||||
replaceTemplateVariables,
|
||||
wrapInBaseTemplate,
|
||||
defaultTemplates,
|
||||
type DefaultTemplate
|
||||
} from './emailTemplates.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',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Format date for emails
|
||||
*/
|
||||
formatDate(dateStr: string, locale: string = 'en'): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Format time for emails
|
||||
*/
|
||||
formatTime(dateStr: string, locale: string = 'en'): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 (db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq((emailTemplates as any).slug, slug))
|
||||
.get();
|
||||
|
||||
return template || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Seed default templates if they don't exist
|
||||
*/
|
||||
async seedDefaultTemplates(): Promise<void> {
|
||||
console.log('[Email] Checking for default templates...');
|
||||
|
||||
for (const template of defaultTemplates) {
|
||||
const existing = await this.getTemplate(template.slug);
|
||||
|
||||
if (!existing) {
|
||||
console.log(`[Email] Creating template: ${template.name}`);
|
||||
const now = getNow();
|
||||
|
||||
await (db as any).insert(emailTemplates).values({
|
||||
id: nanoid(),
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 = nanoid();
|
||||
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
|
||||
*/
|
||||
async sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get ticket with event info
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
.get();
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
|
||||
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();
|
||||
return this.sendTemplateEmail({
|
||||
templateSlug: 'booking-confirmation',
|
||||
to: ticket.attendeeEmail,
|
||||
toName: attendeeFullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
variables: {
|
||||
attendeeName: attendeeFullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
qrCode: ticket.qrCode || '',
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale),
|
||||
eventTime: this.formatTime(event.startDatetime, locale),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
eventPrice: this.formatCurrency(event.price, event.currency),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Send payment receipt email
|
||||
*/
|
||||
async sendPaymentReceipt(paymentId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get payment with ticket and event info
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, paymentId))
|
||||
.get();
|
||||
|
||||
if (!payment) {
|
||||
return { success: false, error: 'Payment not found' };
|
||||
}
|
||||
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
|
||||
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 paymentMethodNames: Record<string, Record<string, string>> = {
|
||||
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash' },
|
||||
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo' },
|
||||
};
|
||||
|
||||
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
return this.sendTemplateEmail({
|
||||
templateSlug: 'payment-receipt',
|
||||
to: ticket.attendeeEmail,
|
||||
toName: receiptFullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
variables: {
|
||||
attendeeName: receiptFullName,
|
||||
ticketId: ticket.id,
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale),
|
||||
paymentAmount: this.formatCurrency(payment.amount, payment.currency),
|
||||
paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider,
|
||||
paymentReference: payment.reference || payment.id,
|
||||
paymentDate: this.formatDate(payment.paidAt || payment.createdAt, locale),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, eventId))
|
||||
.get();
|
||||
|
||||
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 ticketQuery.all();
|
||||
|
||||
if (eventTickets.length === 0) {
|
||||
return { success: true, sentCount: 0, failedCount: 0, errors: ['No recipients found'] };
|
||||
}
|
||||
|
||||
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),
|
||||
eventTime: this.formatTime(event.startDatetime, locale),
|
||||
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,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a custom email (not from template)
|
||||
*/
|
||||
async sendCustomEmail(params: {
|
||||
to: string;
|
||||
toName?: string;
|
||||
subject: string;
|
||||
bodyHtml: string;
|
||||
bodyText?: string;
|
||||
eventId?: string;
|
||||
sentBy: string;
|
||||
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
||||
const { to, toName, subject, bodyHtml, bodyText, eventId, sentBy } = params;
|
||||
|
||||
const allVariables = {
|
||||
...this.getCommonVariables(),
|
||||
subject,
|
||||
};
|
||||
|
||||
const finalBodyHtml = wrapInBaseTemplate(bodyHtml, allVariables);
|
||||
|
||||
// Create log entry
|
||||
const logId = nanoid();
|
||||
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,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
// Send email
|
||||
const result = await sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html: finalBodyHtml,
|
||||
text: bodyText,
|
||||
});
|
||||
|
||||
// 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;
|
||||
Reference in New Issue
Block a user