- Email settings: Add separate sender display name field - Rounds page: Drag-and-drop reordering with visible order numbers - Round creation: Auto-assign projects to filtering rounds, auto-activate if voting started - Round detail: Fix incorrect "voting period ended" message for draft rounds - Projects page: Add delete option with confirmation dialog - AI filtering: Add configurable batch size and parallel request settings - Filtering results: Fix duplicate criteria display - Add seed scripts for notification settings and MOPC onboarding form Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
855 lines
25 KiB
TypeScript
855 lines
25 KiB
TypeScript
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<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 || ''
|
|
|
|
// Combine sender name and email into "Name <email>" 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 <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;">
|
|
© ${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;"> </i>
|
|
<![endif]-->
|
|
<span style="mso-text-raise: 15pt;">${text}</span>
|
|
<!--[if mso]>
|
|
<i style="letter-spacing: 40px; mso-font-width: -100%;"> </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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.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,
|
|
})
|
|
}
|