import nodemailer from 'nodemailer' import type { Transporter } from 'nodemailer' import { prisma } from '@/lib/prisma' // Cached transporter and config hash to detect changes let cachedTransporter: Transporter | null = null let cachedConfigHash = '' let cachedFrom = '' /** * Get SMTP transporter using database settings with env var fallback. * Caches the transporter and rebuilds it when settings change. */ async function getTransporter(): Promise<{ transporter: Transporter; from: string }> { // Read DB settings const dbSettings = await prisma.systemSettings.findMany({ where: { key: { in: ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'email_from_name', 'email_from'] }, }, select: { key: true, value: true }, }) const db: Record = {} for (const s of dbSettings) { db[s.key] = s.value } // DB settings take priority, env vars as fallback const host = db.smtp_host || process.env.SMTP_HOST || 'localhost' const port = db.smtp_port || process.env.SMTP_PORT || '587' const user = db.smtp_user || process.env.SMTP_USER || '' const pass = db.smtp_password || process.env.SMTP_PASS || '' // Combine sender name and email into "Name " format const fromName = db.email_from_name || 'MOPC Portal' const fromEmail = db.email_from || process.env.EMAIL_FROM || 'noreply@monaco-opc.com' const from = `${fromName} <${fromEmail}>` // Check if config changed since last call const configHash = `${host}:${port}:${user}:${pass}:${from}` if (cachedTransporter && configHash === cachedConfigHash) { return { transporter: cachedTransporter, from: cachedFrom } } // Create new transporter cachedTransporter = nodemailer.createTransport({ host, port: parseInt(port), secure: port === '465', auth: { user, pass }, }) cachedConfigHash = configHash cachedFrom = from return { transporter: cachedTransporter, from: cachedFrom } } // Legacy references for backward compat — default sender from env const defaultFrom = process.env.EMAIL_FROM || 'MOPC Portal ' // ============================================================================= // Brand Colors & Logo URLs // ============================================================================= const BRAND = { red: '#de0f1e', redHover: '#b91c1c', darkBlue: '#053d57', teal: '#557f8c', white: '#fefefe', lightGray: '#f5f5f5', textDark: '#1f2937', textMuted: '#6b7280', } const getSmallLogoUrl = () => 'https://s3.monaco-opc.com/public/MOPC-blue-small.png' const getBigLogoUrl = () => 'https://s3.monaco-opc.com/public/MOPC-blue-long.png' // ============================================================================= // Email Template Wrapper & Helpers // ============================================================================= /** * Wrap email content with consistent branding: * - Small logo at top of white content box * - Big logo in dark blue footer */ function getEmailWrapper(content: string): string { return ` MOPC
MOPC
${content}
Monaco Ocean Protection Challenge

Together for a healthier ocean

© ${new Date().getFullYear()} Monaco Ocean Protection Challenge

` } /** * Generate a styled CTA button */ function ctaButton(url: string, text: string): string { return `
${text}
` } /** * Generate styled section title */ function sectionTitle(text: string): string { return `

${text}

` } /** * Generate styled paragraph */ function paragraph(text: string): string { return `

${text}

` } /** * Generate styled info box */ function infoBox(content: string, variant: 'warning' | 'info' | 'success' = 'info'): string { const colors = { warning: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' }, info: { bg: '#e0f2fe', border: '#0ea5e9', text: '#0369a1' }, success: { bg: '#dcfce7', border: '#22c55e', text: '#166534' }, } const c = colors[variant] return `

${content}

` } /** * Generate styled stat card */ function statCard(label: string, value: string | number): string { return `

${label}

${value}

` } // ============================================================================= // Email Templates // ============================================================================= interface EmailTemplate { subject: string html: string text: string } /** * Generate magic link email template */ function getMagicLinkTemplate(url: string, expiryMinutes: number = 15): EmailTemplate { const content = ` ${sectionTitle('Sign in to your account')} ${paragraph('Click the button below to securely sign in to the MOPC Portal.')} ${infoBox(`This link expires in ${expiryMinutes} minutes`, 'warning')} ${ctaButton(url, 'Sign In to MOPC')}

If you didn't request this email, you can safely ignore it.

` return { subject: 'Sign in to MOPC Portal', html: getEmailWrapper(content), text: ` Sign in to MOPC Portal ========================= Click the link below to sign in to your account: ${url} This link will expire in ${expiryMinutes} minutes. If you didn't request this email, you can safely ignore it. --- Monaco Ocean Protection Challenge Together for a healthier ocean. `, } } /** * Generate generic invitation email template (not round-specific) */ function getGenericInvitationTemplate( name: string, url: string, role: string ): EmailTemplate { const roleLabel = role === 'JURY_MEMBER' ? 'jury member' : role.toLowerCase().replace('_', ' ') const greeting = name ? `Hello ${name},` : 'Hello,' const content = ` ${sectionTitle(greeting)} ${paragraph(`You've been invited to join the Monaco Ocean Protection Challenge platform as a ${roleLabel}.`)} ${paragraph('Click the button below to set up your account and get started.')} ${ctaButton(url, 'Accept Invitation')} ${infoBox('This link will expire in 7 days.', 'info')} ` return { subject: "You're invited to join the MOPC Portal", html: getEmailWrapper(content), text: ` ${greeting} You've been invited to join the Monaco Ocean Protection Challenge platform as a ${roleLabel}. Click the link below to set up your account and get started: ${url} This link will expire in 7 days. --- Monaco Ocean Protection Challenge Together for a healthier ocean. `, } } /** * Generate evaluation reminder email template */ function getEvaluationReminderTemplate( name: string, pendingCount: number, roundName: string, deadline: string, assignmentsUrl: string ): EmailTemplate { const greeting = name ? `Hello ${name},` : 'Hello,' // Deadline alert box (styled differently from info box) const deadlineBox = `

Deadline

${deadline}

` const content = ` ${sectionTitle(greeting)} ${paragraph(`This is a friendly reminder about your pending evaluations for ${roundName}.`)} ${statCard('Pending Evaluations', pendingCount)} ${deadlineBox} ${paragraph('Your expert evaluation helps identify the most promising ocean conservation projects. Please complete your reviews before the deadline.')} ${ctaButton(assignmentsUrl, 'View My Assignments')} ` return { subject: `Reminder: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} awaiting your review`, html: getEmailWrapper(content), text: ` ${greeting} This is a friendly reminder that you have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''} for ${roundName}. Deadline: ${deadline} Please complete your evaluations before the deadline to ensure your feedback is included in the selection process. View your assignments: ${assignmentsUrl} --- Monaco Ocean Protection Challenge Together for a healthier ocean. `, } } /** * Generate announcement email template */ function getAnnouncementTemplate( name: string, title: string, message: string, ctaText?: string, ctaUrl?: string ): EmailTemplate { const greeting = name ? `Hello ${name},` : 'Hello,' const ctaTextPlain = ctaText && ctaUrl ? `\n${ctaText}: ${ctaUrl}\n` : '' // Escape HTML in message but preserve line breaks const formattedMessage = message .replace(/&/g, '&') .replace(//g, '>') .replace(/\n/g, '
') // Title card with success styling const titleCard = `

${title}

` const content = ` ${sectionTitle(greeting)} ${titleCard}
${formattedMessage}
${ctaText && ctaUrl ? ctaButton(ctaUrl, ctaText) : ''} ` return { subject: title, html: getEmailWrapper(content), text: ` ${greeting} ${title} ${message} ${ctaTextPlain} --- Monaco Ocean Protection Challenge Together for a healthier ocean. `, } } /** * Generate jury invitation email template */ function getJuryInvitationTemplate( name: string, url: string, roundName: string ): EmailTemplate { const greeting = name ? `Hello ${name},` : 'Hello,' const content = ` ${sectionTitle(greeting)} ${paragraph(`You've been invited to serve as a jury member for ${roundName}.`)} ${paragraph('As a jury member, you\'ll evaluate innovative ocean protection projects and help select the most promising initiatives.')} ${ctaButton(url, 'Accept Invitation')}

This link will allow you to access the platform and view your assigned projects.

` return { subject: `You're invited to evaluate projects for ${roundName}`, html: getEmailWrapper(content), text: ` ${greeting} You've been invited to serve as a jury member for ${roundName}. As a jury member, you'll evaluate innovative ocean protection projects and help select the most promising initiatives. Click the link below to accept your invitation: ${url} --- Monaco Ocean Protection Challenge Together for a healthier ocean. `, } } /** * Generate application confirmation email template */ function getApplicationConfirmationTemplate( name: string, projectName: string, programName: string, customMessage?: string ): EmailTemplate { const greeting = name ? `Hello ${name},` : 'Hello,' const customMessageHtml = customMessage ? `
${customMessage.replace(/\n/g, '
')}
` : '' const content = ` ${sectionTitle(greeting)} ${paragraph(`Thank you for submitting your application to ${programName}!`)} ${infoBox(`Your project "${projectName}" has been successfully received.`, 'success')} ${customMessageHtml} ${paragraph('Our team will review your submission and get back to you soon. In the meantime, if you have any questions, please don\'t hesitate to reach out.')}

You will receive email updates about your application status.

` return { subject: `Application Received - ${projectName}`, html: getEmailWrapper(content), text: ` ${greeting} Thank you for submitting your application to ${programName}! Your project "${projectName}" has been successfully received. ${customMessage || ''} Our team will review your submission and get back to you soon. In the meantime, if you have any questions, please don't hesitate to reach out. --- Monaco Ocean Protection Challenge Together for a healthier ocean. `, } } /** * Generate team member invite email template */ function getTeamMemberInviteTemplate( name: string, projectName: string, teamLeadName: string, inviteUrl: string ): EmailTemplate { const greeting = name ? `Hello ${name},` : 'Hello,' const content = ` ${sectionTitle(greeting)} ${paragraph(`${teamLeadName} has invited you to join their team for the project "${projectName}" on the Monaco Ocean Protection Challenge platform.`)} ${paragraph('Click the button below to accept the invitation and set up your account.')} ${ctaButton(inviteUrl, 'Accept Invitation')} ${infoBox('This invitation link will expire in 30 days.', 'info')}

If you weren't expecting this invitation, you can safely ignore this email.

` return { subject: `You've been invited to join "${projectName}"`, html: getEmailWrapper(content), text: ` ${greeting} ${teamLeadName} has invited you to join their team for the project "${projectName}" on the Monaco Ocean Protection Challenge platform. Click the link below to accept the invitation and set up your account: ${inviteUrl} This invitation link will expire in 30 days. If you weren't expecting this invitation, you can safely ignore this email. --- Monaco Ocean Protection Challenge Together for a healthier ocean. `, } } // ============================================================================= // Email Sending Functions // ============================================================================= /** * Send magic link email for authentication */ export async function sendMagicLinkEmail( email: string, url: string ): Promise { const expiryMinutes = parseInt(process.env.MAGIC_LINK_EXPIRY || '900') / 60 const template = getMagicLinkTemplate(url, expiryMinutes) const { transporter, from } = await getTransporter() await transporter.sendMail({ from, to: email, subject: template.subject, text: template.text, html: template.html, }) } /** * Send generic invitation email (not round-specific) */ export async function sendInvitationEmail( email: string, name: string | null, url: string, role: string ): Promise { const template = getGenericInvitationTemplate(name || '', url, role) const { transporter, from } = await getTransporter() await transporter.sendMail({ from, to: email, subject: template.subject, text: template.text, html: template.html, }) } /** * Send jury invitation email (round-specific) */ export async function sendJuryInvitationEmail( email: string, name: string | null, url: string, roundName: string ): Promise { const template = getJuryInvitationTemplate(name || '', url, roundName) const { transporter, from } = await getTransporter() await transporter.sendMail({ from, to: email, subject: template.subject, text: template.text, html: template.html, }) } /** * Send evaluation reminder email */ export async function sendEvaluationReminderEmail( email: string, name: string | null, pendingCount: number, roundName: string, deadline: string, assignmentsUrl: string ): Promise { const template = getEvaluationReminderTemplate( name || '', pendingCount, roundName, deadline, assignmentsUrl ) const { transporter, from } = await getTransporter() await transporter.sendMail({ from, to: email, subject: template.subject, text: template.text, html: template.html, }) } /** * Send announcement email */ export async function sendAnnouncementEmail( email: string, name: string | null, title: string, message: string, ctaText?: string, ctaUrl?: string ): Promise { const template = getAnnouncementTemplate( name || '', title, message, ctaText, ctaUrl ) const { transporter, from } = await getTransporter() await transporter.sendMail({ from, to: email, subject: template.subject, text: template.text, html: template.html, }) } /** * Send a test email to verify SMTP configuration */ export async function sendTestEmail(toEmail: string): Promise { try { const content = ` ${sectionTitle('Test Email')} ${paragraph('This is a test email from the MOPC Portal.')} ${infoBox('If you received this, your email configuration is working correctly!', 'success')}

Sent at ${new Date().toISOString()}

` const { transporter, from } = await getTransporter() await transporter.sendMail({ from, to: toEmail, subject: 'MOPC Portal - Test Email', text: 'This is a test email from the MOPC Portal. If you received this, your email configuration is working correctly.', html: getEmailWrapper(content), }) return true } catch { return false } } /** * Verify SMTP connection */ export async function verifyEmailConnection(): Promise { try { const { transporter } = await getTransporter() await transporter.verify() return true } catch { return false } } /** * Send application confirmation email to applicant */ export async function sendApplicationConfirmationEmail( email: string, applicantName: string, projectName: string, programName: string, customMessage?: string ): Promise { const template = getApplicationConfirmationTemplate( applicantName, projectName, programName, customMessage ) const { transporter, from } = await getTransporter() await transporter.sendMail({ from, to: email, subject: template.subject, text: template.text, html: template.html, }) } /** * Send team member invite email */ export async function sendTeamMemberInviteEmail( email: string, memberName: string, projectName: string, teamLeadName: string, inviteUrl: string ): Promise { const template = getTeamMemberInviteTemplate( memberName, projectName, teamLeadName, inviteUrl ) const { transporter, from } = await getTransporter() await transporter.sendMail({ from, to: email, subject: template.subject, text: template.text, html: template.html, }) } /** * Generate notification email template */ function getNotificationEmailTemplate( name: string, subject: string, body: string, linkUrl?: string ): EmailTemplate { const greeting = name ? `Hello ${name},` : 'Hello,' // Format body text preserving line breaks const formattedBody = body .replace(/&/g, '&') .replace(//g, '>') .replace(/\n/g, '
') const content = ` ${sectionTitle(greeting)}
${formattedBody}
${linkUrl ? ctaButton(linkUrl, 'View Details') : ''}

You received this email because of your notification preferences on the MOPC Portal.

` return { subject, html: getEmailWrapper(content), text: ` ${greeting} ${body} ${linkUrl ? `View details: ${linkUrl}` : ''} --- Monaco Ocean Protection Challenge Together for a healthier ocean. `, } } /** * Send notification email (triggered by in-app notification system) */ export async function sendNotificationEmail( email: string, name: string, subject: string, body: string, linkUrl?: string ): Promise { const template = getNotificationEmailTemplate(name, subject, body, linkUrl) const { transporter, from } = await getTransporter() await transporter.sendMail({ from, to: email, subject: template.subject, text: template.text, html: template.html, }) }