Files
MOPC-Portal/src/lib/email.ts

851 lines
25 KiB
TypeScript
Raw Normal View History

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'] },
},
select: { key: true, value: true },
})
const db: Record<string, string> = {}
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 || ''
const from = db.email_from || process.env.EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.com>'
// 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 <noreply@monaco-opc.com>'
// =============================================================================
// 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 `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>MOPC</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: ${BRAND.darkBlue}; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-image: url('https://s3.monaco-opc.com/public/ocean.png'); background-size: cover; background-position: center top; background-repeat: no-repeat; background-color: ${BRAND.darkBlue};">
<tr>
<td align="center" style="padding: 40px 20px;">
<!-- Main Container -->
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width: 600px; width: 100%;">
<!-- Content Box -->
<tr>
<td style="background-color: ${BRAND.white}; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<!-- Small Logo Header -->
<tr>
<td align="center" style="padding: 32px 40px 24px 40px;">
<img src="${getSmallLogoUrl()}" alt="MOPC" width="100" height="auto" style="display: block; border: 0; max-width: 100px;">
</td>
</tr>
<!-- Email Content -->
<tr>
<td style="padding: 0 40px 32px 40px;">
${content}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 0 40px 32px 40px; border-top: 1px solid #e5e7eb;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" style="padding-top: 24px;">
<img src="${getBigLogoUrl()}" alt="Monaco Ocean Protection Challenge" width="180" height="auto" style="display: block; border: 0; max-width: 180px; margin-bottom: 12px;">
</td>
</tr>
<tr>
<td align="center">
<p style="color: ${BRAND.textMuted}; font-size: 13px; margin: 0; line-height: 1.5;">
Together for a healthier ocean
</p>
<p style="color: ${BRAND.textMuted}; font-size: 12px; margin: 8px 0 0 0;">
&copy; ${new Date().getFullYear()} Monaco Ocean Protection Challenge
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`
}
/**
* Generate a styled CTA button
*/
function ctaButton(url: string, text: string): string {
return `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 24px 0;">
<tr>
<td align="center">
<a href="${url}" target="_blank" style="display: inline-block; background-color: ${BRAND.red}; color: #ffffff; text-decoration: none; padding: 16px 40px; border-radius: 8px; font-weight: 600; font-size: 16px; mso-padding-alt: 0;">
<!--[if mso]>
<i style="letter-spacing: 40px; mso-font-width: -100%; mso-text-raise: 30pt;">&nbsp;</i>
<![endif]-->
<span style="mso-text-raise: 15pt;">${text}</span>
<!--[if mso]>
<i style="letter-spacing: 40px; mso-font-width: -100%;">&nbsp;</i>
<![endif]-->
</a>
</td>
</tr>
</table>
`
}
/**
* Generate styled section title
*/
function sectionTitle(text: string): string {
return `<h2 style="color: ${BRAND.darkBlue}; margin: 0 0 16px 0; font-size: 22px; font-weight: 600; line-height: 1.3;">${text}</h2>`
}
/**
* Generate styled paragraph
*/
function paragraph(text: string): string {
return `<p style="color: ${BRAND.textDark}; margin: 0 0 16px 0; font-size: 15px; line-height: 1.6;">${text}</p>`
}
/**
* 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 `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: ${c.bg}; border-left: 4px solid ${c.border}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="color: ${c.text}; margin: 0; font-size: 14px; font-weight: 500;">${content}</p>
</td>
</tr>
</table>
`
}
/**
* Generate styled stat card
*/
function statCard(label: string, value: string | number): string {
return `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px; text-align: center;">
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">${label}</p>
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 42px; font-weight: 700; line-height: 1;">${value}</p>
</td>
</tr>
</table>
`
}
// =============================================================================
// 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(`<strong>This link expires in ${expiryMinutes} minutes</strong>`, 'warning')}
${ctaButton(url, 'Sign In to MOPC')}
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
If you didn't request this email, you can safely ignore it.
</p>
`
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 <strong>${roleLabel}</strong>.`)}
${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 = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${paragraph(`This is a friendly reminder about your pending evaluations for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
${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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
// Title card with success styling
const titleCard = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: #ecfdf5; border-left: 4px solid #059669; border-radius: 0 12px 12px 0; padding: 20px 24px;">
<h3 style="color: #065f46; margin: 0; font-size: 18px; font-weight: 700;">${title}</h3>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${titleCard}
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">
${formattedMessage}
</div>
${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 <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
${paragraph('As a jury member, you\'ll evaluate innovative ocean protection projects and help select the most promising initiatives.')}
${ctaButton(url, 'Accept Invitation')}
<p style="color: ${BRAND.textMuted}; margin: 20px 0 0 0; font-size: 13px; text-align: center;">
This link will allow you to access the platform and view your assigned projects.
</p>
`
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
? `<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0; padding: 20px; background-color: ${BRAND.lightGray}; border-radius: 8px;">${customMessage.replace(/\n/g, '<br>')}</div>`
: ''
const content = `
${sectionTitle(greeting)}
${paragraph(`Thank you for submitting your application to <strong style="color: ${BRAND.darkBlue};">${programName}</strong>!`)}
${infoBox(`Your project "<strong>${projectName}</strong>" 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.')}
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
You will receive email updates about your application status.
</p>
`
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(`<strong>${teamLeadName}</strong> has invited you to join their team for the project "<strong style="color: ${BRAND.darkBlue};">${projectName}</strong>" 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')}
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
If you weren't expecting this invitation, you can safely ignore this email.
</p>
`
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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')}
<p style="color: ${BRAND.textMuted}; margin: 20px 0 0 0; font-size: 13px; text-align: center;">
Sent at ${new Date().toISOString()}
</p>
`
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<boolean> {
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<void> {
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<void> {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
const content = `
${sectionTitle(greeting)}
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">
${formattedBody}
</div>
${linkUrl ? ctaButton(linkUrl, 'View Details') : ''}
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
You received this email because of your notification preferences on the MOPC Portal.
</p>
`
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<void> {
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,
})
}