- mentor.requestChange: applicants/admins open a PENDING MentorChangeRequest with a reason; one open request per (user, project) enforced - mentor.listChangeRequests: admin-only inbox listing - mentor.resolveChangeRequest: admin marks RESOLVED or DISMISSED with optional resolution note - sendMentorChangeRequestEmail: notifies all SUPER_ADMIN/PROGRAM_ADMIN users when a request is opened (try/catch — never throws) - Mentors are NOT notified of change requests, even after resolution (per design decision in PR8 plan) - Audit log entries for create + resolve; raw reason redacted from audit Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3140 lines
117 KiB
TypeScript
3140 lines
117 KiB
TypeScript
import nodemailer from 'nodemailer'
|
||
import type { Transporter } from 'nodemailer'
|
||
import { prisma } from '@/lib/prisma'
|
||
|
||
/**
|
||
* Dev email override: when DEV_EMAIL_OVERRIDE is set, ALL outgoing emails
|
||
* are redirected to that address. The original recipient is noted in the subject.
|
||
*/
|
||
const DEV_EMAIL_OVERRIDE = process.env.DEV_EMAIL_OVERRIDE || ''
|
||
|
||
async function sendEmail(opts: { to: string; subject: string; text: string; html: string }): Promise<void> {
|
||
const { transporter, from } = await getTransporter()
|
||
const to = DEV_EMAIL_OVERRIDE || opts.to
|
||
const subject = DEV_EMAIL_OVERRIDE ? `[DEV → ${opts.to}] ${opts.subject}` : opts.subject
|
||
await transporter.sendMail({ from, to, subject, text: opts.text, html: opts.html })
|
||
}
|
||
|
||
// Cached transporter and config hash to detect changes
|
||
let cachedTransporter: Transporter | null = null
|
||
let cachedConfigHash = ''
|
||
let cachedFrom = ''
|
||
let cachedAt = 0
|
||
const CACHE_TTL = 60_000 // 1 minute
|
||
|
||
/**
|
||
* Get SMTP transporter using database settings with env var fallback.
|
||
* Caches the transporter and rebuilds it when settings change.
|
||
* Uses connection pooling for reliable bulk sends.
|
||
*/
|
||
async function getTransporter(): Promise<{ transporter: Transporter; from: string }> {
|
||
// Fast path: return cached transporter if still fresh
|
||
if (cachedTransporter && Date.now() - cachedAt < CACHE_TTL) {
|
||
return { transporter: cachedTransporter, from: cachedFrom }
|
||
}
|
||
|
||
// 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 rawFromEmail = db.email_from || process.env.EMAIL_FROM || 'noreply@monaco-opc.com'
|
||
// Strip "Name <...>" wrapper if present — extract just the email address
|
||
const fromEmail = rawFromEmail.includes('<')
|
||
? rawFromEmail.match(/<([^>]+)>/)?.[1] || rawFromEmail
|
||
: rawFromEmail
|
||
const from = `${fromName} <${fromEmail}>`
|
||
|
||
// Check if config changed since last call
|
||
const configHash = `${host}:${port}:${user}:${pass}:${from}`
|
||
if (cachedTransporter && configHash === cachedConfigHash) {
|
||
cachedAt = Date.now()
|
||
return { transporter: cachedTransporter, from: cachedFrom }
|
||
}
|
||
|
||
// Close old transporter if it exists (clean up pooled connections)
|
||
if (cachedTransporter) {
|
||
try { cachedTransporter.close() } catch { /* ignore */ }
|
||
}
|
||
|
||
// Create new transporter with connection pooling for reliable bulk sends
|
||
cachedTransporter = nodemailer.createTransport({
|
||
host,
|
||
port: parseInt(port),
|
||
secure: port === '465',
|
||
auth: { user, pass },
|
||
pool: true,
|
||
maxConnections: 5,
|
||
maxMessages: 10,
|
||
socketTimeout: 30_000,
|
||
connectionTimeout: 15_000,
|
||
} as nodemailer.TransportOptions)
|
||
cachedConfigHash = configHash
|
||
cachedFrom = from
|
||
cachedAt = Date.now()
|
||
|
||
return { transporter: cachedTransporter, from: cachedFrom }
|
||
}
|
||
|
||
/**
|
||
* Delay helper for throttling bulk email sends.
|
||
* Prevents overwhelming the SMTP server (Poste.io).
|
||
*/
|
||
export function emailDelay(ms = 150): Promise<void> {
|
||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||
}
|
||
|
||
// Legacy references for backward compat — default sender from env
|
||
const defaultFrom = process.env.EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.com>'
|
||
|
||
// =============================================================================
|
||
// Helpers
|
||
// =============================================================================
|
||
|
||
/**
|
||
* Escape user-supplied strings for safe injection into HTML email templates.
|
||
* Prevents XSS if email content is rendered in a webmail client.
|
||
*/
|
||
function escapeHtml(str: string): string {
|
||
return str
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
}
|
||
|
||
/**
|
||
* Get the base URL for links in emails.
|
||
* Uses NEXTAUTH_URL with a safe production fallback.
|
||
*/
|
||
export function getBaseUrl(): string {
|
||
return process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
||
}
|
||
|
||
/**
|
||
* Ensure a URL is absolute (has protocol + host).
|
||
* Converts relative paths like "/jury/competitions" to full URLs.
|
||
*/
|
||
export function ensureAbsoluteUrl(url: string | undefined): string | undefined {
|
||
if (!url) return undefined
|
||
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
||
if (url.startsWith('/')) return `${getBaseUrl()}${url}`
|
||
return `${getBaseUrl()}/${url}`
|
||
}
|
||
|
||
// =============================================================================
|
||
// 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 {
|
||
// Ensure URL is always absolute for email clients
|
||
const safeUrl = ensureAbsoluteUrl(url) || url
|
||
return `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 24px 0;">
|
||
<tr>
|
||
<td align="center">
|
||
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
||
<tr>
|
||
<td align="center" bgcolor="${BRAND.red}" style="background-color: ${BRAND.red}; border-radius: 8px; mso-border-alt: none;">
|
||
<a href="${safeUrl}" target="_blank" style="display: inline-block; padding: 16px 40px; color: #ffffff; text-decoration: none; font-weight: 600; font-size: 16px; font-family: Helvetica, Arial, sans-serif; mso-line-height-rule: exactly; line-height: 20px;">
|
||
${text}
|
||
</a>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td align="center" style="padding-top: 8px;">
|
||
<p style="margin: 0; font-size: 12px; color: ${BRAND.textMuted};">
|
||
If the button doesn't work: <a href="${safeUrl}" target="_blank" style="color: ${BRAND.teal}; word-break: break-all;">${safeUrl}</a>
|
||
</p>
|
||
</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;">${escapeHtml(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;">${escapeHtml(label)}</p>
|
||
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 42px; font-weight: 700; line-height: 1;">${escapeHtml(String(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 password reset email template
|
||
*/
|
||
function getPasswordResetTemplate(url: string, expiryMinutes: number = 30): EmailTemplate {
|
||
const content = `
|
||
${sectionTitle('Reset your password')}
|
||
${paragraph('We received a request to reset your password for the MOPC Portal. Click the button below to choose a new password.')}
|
||
${infoBox(`<strong>This link expires in ${expiryMinutes} minutes</strong>`, 'warning')}
|
||
${ctaButton(url, 'Reset Password')}
|
||
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
|
||
If you didn't request a password reset, you can safely ignore this email. Your password will not change.
|
||
</p>
|
||
`
|
||
|
||
return {
|
||
subject: 'Reset your password — MOPC Portal',
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
Reset your password
|
||
=========================
|
||
|
||
Click the link below to reset your password:
|
||
|
||
${url}
|
||
|
||
This link will expire in ${expiryMinutes} minutes.
|
||
|
||
If you didn't request this, you can safely ignore this email.
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate generic invitation email template (not round-specific)
|
||
*/
|
||
function formatExpiryLabel(hours: number): string {
|
||
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''}`
|
||
const days = Math.round(hours / 24)
|
||
return `${days} day${days !== 1 ? 's' : ''}`
|
||
}
|
||
|
||
function getGenericInvitationTemplate(
|
||
name: string,
|
||
url: string,
|
||
role: string,
|
||
expiryHours: number = 72
|
||
): EmailTemplate {
|
||
const roleLabel = role === 'JURY_MEMBER' ? 'jury member' : role.toLowerCase().replace('_', ' ')
|
||
const article = /^[aeiou]/i.test(roleLabel) ? 'an' : 'a'
|
||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||
const expiryLabel = formatExpiryLabel(expiryHours)
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${paragraph(`You've been invited to join the Monaco Ocean Protection Challenge platform as ${article} <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 ${expiryLabel}.`, '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 ${article} ${roleLabel}.
|
||
|
||
Click the link below to set up your account and get started:
|
||
|
||
${url}
|
||
|
||
This link will expire in ${expiryLabel}.
|
||
|
||
---
|
||
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;">${escapeHtml(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};">${escapeHtml(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 = escapeHtml(message).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;">${escapeHtml(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 award juror notification template — used when an admin assigns a
|
||
* juror to a special award and when sending follow-up reminders. Tells the
|
||
* juror what the award is, how many projects are eligible, and links them
|
||
* straight to the voting page.
|
||
*/
|
||
function getAwardJurorNotificationTemplate(
|
||
name: string,
|
||
awardName: string,
|
||
url: string,
|
||
options?: {
|
||
eligibleCount?: number
|
||
votingEndAt?: Date | null
|
||
customMessage?: string
|
||
isReminder?: boolean
|
||
},
|
||
): EmailTemplate {
|
||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||
const eligibleCount = options?.eligibleCount
|
||
const votingEndAt = options?.votingEndAt
|
||
const customMessage = options?.customMessage?.trim()
|
||
const isReminder = options?.isReminder ?? false
|
||
|
||
const lead = isReminder
|
||
? `This is a reminder that you've been assigned as a juror for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(awardName)}</strong>.`
|
||
: `You've been assigned as a juror for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(awardName)}</strong>.`
|
||
|
||
const projectsLine = typeof eligibleCount === 'number' && eligibleCount > 0
|
||
? paragraph(`There ${eligibleCount === 1 ? 'is' : 'are'} <strong>${eligibleCount}</strong> eligible project${eligibleCount === 1 ? '' : 's'} for you to review.`)
|
||
: ''
|
||
|
||
const deadlineLine = votingEndAt
|
||
? paragraph(`<strong>Voting closes:</strong> ${escapeHtml(votingEndAt.toLocaleString('en-GB', { dateStyle: 'long', timeStyle: 'short' }))}`)
|
||
: ''
|
||
|
||
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;">${escapeHtml(customMessage).replace(/\n/g, '<br>')}</div>`
|
||
: ''
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${paragraph(lead)}
|
||
${projectsLine}
|
||
${deadlineLine}
|
||
${customMessageHtml}
|
||
${ctaButton(url, 'Review & Vote')}
|
||
<p style="color: ${BRAND.textMuted}; margin: 20px 0 0 0; font-size: 13px; text-align: center;">
|
||
Sign in with your existing MOPC credentials to access the voting page.
|
||
</p>
|
||
`
|
||
|
||
return {
|
||
subject: isReminder
|
||
? `Reminder: vote for the ${awardName}`
|
||
: `You've been assigned as a juror for ${awardName}`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
${isReminder ? 'This is a reminder that you' : 'You'}'ve been assigned as a juror for ${awardName}.
|
||
${typeof eligibleCount === 'number' && eligibleCount > 0 ? `\nThere ${eligibleCount === 1 ? 'is' : 'are'} ${eligibleCount} eligible project${eligibleCount === 1 ? '' : 's'} for you to review.` : ''}${votingEndAt ? `\nVoting closes: ${votingEndAt.toLocaleString('en-GB', { dateStyle: 'long', timeStyle: 'short' })}` : ''}
|
||
${customMessage ? `\n${customMessage}\n` : ''}
|
||
Review & vote: ${url}
|
||
|
||
Sign in with your existing MOPC credentials to access the voting page.
|
||
|
||
---
|
||
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};">${escapeHtml(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;">${escapeHtml(customMessage).replace(/\n/g, '<br>')}</div>`
|
||
: ''
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${paragraph(`Thank you for submitting your application to <strong style="color: ${BRAND.darkBlue};">${escapeHtml(programName)}</strong>!`)}
|
||
${infoBox(`Your project "<strong>${escapeHtml(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>${escapeHtml(teamLeadName)}</strong> has invited you to join their team for the project "<strong style="color: ${BRAND.darkBlue};">${escapeHtml(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.
|
||
`,
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// Notification Email Templates
|
||
// =============================================================================
|
||
|
||
/**
|
||
* Context passed to notification email templates
|
||
*/
|
||
export interface NotificationEmailContext {
|
||
name?: string
|
||
title: string
|
||
message: string
|
||
linkUrl?: string
|
||
linkLabel?: string
|
||
metadata?: Record<string, unknown>
|
||
}
|
||
|
||
/**
|
||
* Generate "Advanced to Semi-Finals" email template
|
||
*/
|
||
function getAdvancedSemifinalTemplate(
|
||
name: string,
|
||
projectName: string,
|
||
programName: string,
|
||
nextSteps?: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
||
|
||
const celebrationBanner = `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||
<tr>
|
||
<td style="background: linear-gradient(135deg, #059669 0%, #0d9488 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
||
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Exciting News</p>
|
||
<h2 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">You're a Semi-Finalist!</h2>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${celebrationBanner}
|
||
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has been selected to advance to the semi-finals of ${escapeHtml(programName)}.`)}
|
||
${infoBox('Your innovative approach to ocean protection stood out among hundreds of submissions.', 'success')}
|
||
${nextSteps ? paragraph(`<strong>Next Steps:</strong> ${escapeHtml(nextSteps)}`) : paragraph('Our team will be in touch shortly with details about the next phase of the competition.')}
|
||
`
|
||
|
||
return {
|
||
subject: `Congratulations! "${projectName}" advances to Semi-Finals`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
Your project "${projectName}" has been selected to advance to the semi-finals of ${programName}.
|
||
|
||
Your innovative approach to ocean protection stood out among hundreds of submissions.
|
||
|
||
${nextSteps || 'Our team will be in touch shortly with details about the next phase of the competition.'}
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "Selected as Finalist" email template
|
||
*/
|
||
function getAdvancedFinalTemplate(
|
||
name: string,
|
||
projectName: string,
|
||
programName: string,
|
||
nextSteps?: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `Incredible news, ${name}!` : 'Incredible news!'
|
||
|
||
const celebrationBanner = `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||
<tr>
|
||
<td style="background: linear-gradient(135deg, ${BRAND.darkBlue} 0%, #0891b2 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
||
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Outstanding Achievement</p>
|
||
<h2 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">You're a Finalist!</h2>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${celebrationBanner}
|
||
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has been selected as a <strong>Finalist</strong> in ${escapeHtml(programName)}.`)}
|
||
${infoBox('You are now among the top projects competing for the grand prize!', 'success')}
|
||
${nextSteps ? paragraph(`<strong>What Happens Next:</strong> ${escapeHtml(nextSteps)}`) : paragraph('Prepare for the final stage of the competition. Our team will contact you with details about the finalist presentations and judging.')}
|
||
`
|
||
|
||
return {
|
||
subject: `You're a Finalist! "${projectName}" selected for finals`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
Your project "${projectName}" has been selected as a Finalist in ${programName}.
|
||
|
||
You are now among the top projects competing for the grand prize!
|
||
|
||
${nextSteps || 'Prepare for the final stage of the competition. Our team will contact you with details about the finalist presentations and judging.'}
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "Mentor Assigned" email template (for team)
|
||
*/
|
||
function getMentorAssignedTemplate(
|
||
name: string,
|
||
projectName: string,
|
||
mentorName: string,
|
||
mentorBio?: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||
|
||
const mentorCard = `
|
||
<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;">
|
||
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Your Mentor</p>
|
||
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${escapeHtml(mentorName)}</p>
|
||
${mentorBio ? `<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(mentorBio)}</p>` : ''}
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${paragraph(`Great news! A mentor has been assigned to support your project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong>.`)}
|
||
${mentorCard}
|
||
${paragraph('Your mentor will provide guidance, feedback, and support as you develop your ocean protection initiative. They will reach out to you shortly to introduce themselves and schedule your first meeting.')}
|
||
${infoBox('Mentorship is a valuable opportunity - make the most of their expertise!', 'info')}
|
||
`
|
||
|
||
return {
|
||
subject: `A mentor has been assigned to "${projectName}"`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
Great news! A mentor has been assigned to support your project "${projectName}".
|
||
|
||
Your Mentor: ${mentorName}
|
||
${mentorBio || ''}
|
||
|
||
Your mentor will provide guidance, feedback, and support as you develop your ocean protection initiative. They will reach out to you shortly to introduce themselves and schedule your first meeting.
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "Not Selected" email template
|
||
*/
|
||
function getNotSelectedTemplate(
|
||
name: string,
|
||
projectName: string,
|
||
roundName: string,
|
||
feedbackUrl?: string,
|
||
encouragement?: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${paragraph(`Thank you for participating in ${escapeHtml(roundName)} with your project <strong>"${escapeHtml(projectName)}"</strong>.`)}
|
||
${paragraph('After careful consideration by our jury, we regret to inform you that your project was not selected to advance to the next round.')}
|
||
${infoBox('This decision was incredibly difficult given the high quality of submissions we received this year.', 'info')}
|
||
${feedbackUrl ? ctaButton(feedbackUrl, 'View Jury Feedback') : ''}
|
||
${paragraph(encouragement ? escapeHtml(encouragement) : 'We encourage you to continue developing your ocean protection initiative and to apply again in future editions. Your commitment to protecting our oceans is valuable and appreciated.')}
|
||
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 14px; text-align: center;">
|
||
Thank you for being part of the Monaco Ocean Protection Challenge community.
|
||
</p>
|
||
`
|
||
|
||
return {
|
||
subject: `Update on your application: "${projectName}"`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
Thank you for participating in ${roundName} with your project "${projectName}".
|
||
|
||
After careful consideration by our jury, we regret to inform you that your project was not selected to advance to the next round.
|
||
|
||
This decision was incredibly difficult given the high quality of submissions we received this year.
|
||
|
||
${feedbackUrl ? `View jury feedback: ${feedbackUrl}` : ''}
|
||
|
||
${encouragement || 'We encourage you to continue developing your ocean protection initiative and to apply again in future editions.'}
|
||
|
||
Thank you for being part of the Monaco Ocean Protection Challenge community.
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "Winner Announcement" email template
|
||
*/
|
||
function getWinnerAnnouncementTemplate(
|
||
name: string,
|
||
projectName: string,
|
||
awardName: string,
|
||
prizeDetails?: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
||
|
||
const trophyBanner = `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||
<tr>
|
||
<td style="background: linear-gradient(135deg, #f59e0b 0%, #eab308 100%); border-radius: 12px; padding: 32px; text-align: center;">
|
||
<p style="color: #ffffff; font-size: 48px; margin: 0 0 12px 0;">🏆</p>
|
||
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Winner</p>
|
||
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${escapeHtml(awardName)}</h2>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${trophyBanner}
|
||
${paragraph(`We are thrilled to announce that your project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has been selected as the winner of the <strong>${escapeHtml(awardName)}</strong>!`)}
|
||
${infoBox('Your outstanding work in ocean protection has made a lasting impression on our jury.', 'success')}
|
||
${prizeDetails ? paragraph(`<strong>Your Prize:</strong> ${escapeHtml(prizeDetails)}`) : ''}
|
||
${paragraph('Our team will be in touch shortly with details about the award ceremony and next steps.')}
|
||
`
|
||
|
||
return {
|
||
subject: `You Won! "${projectName}" wins ${awardName}`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
We are thrilled to announce that your project "${projectName}" has been selected as the winner of the ${awardName}!
|
||
|
||
Your outstanding work in ocean protection has made a lasting impression on our jury.
|
||
|
||
${prizeDetails ? `Your Prize: ${prizeDetails}` : ''}
|
||
|
||
Our team will be in touch shortly with details about the award ceremony and next steps.
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "Assigned to Project" email template (for jury)
|
||
*/
|
||
function getAssignedToProjectTemplate(
|
||
name: string,
|
||
projectName: string,
|
||
roundName: string,
|
||
deadline?: string,
|
||
assignmentsUrl?: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||
|
||
const projectCard = `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||
<tr>
|
||
<td style="background-color: ${BRAND.lightGray}; border-left: 4px solid ${BRAND.teal}; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
||
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">New Assignment</p>
|
||
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;">${escapeHtml(projectName)}</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`
|
||
|
||
const deadlineBox = deadline ? `
|
||
<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;">${escapeHtml(deadline)}</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
` : ''
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${paragraph(`You have been assigned a new project to evaluate for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
|
||
${projectCard}
|
||
${deadlineBox}
|
||
${paragraph('Please review the project materials and submit your evaluation before the deadline.')}
|
||
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignment') : ''}
|
||
`
|
||
|
||
return {
|
||
subject: `New Assignment: "${projectName}" - ${roundName}`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
You have been assigned a new project to evaluate for ${roundName}.
|
||
|
||
Project: ${projectName}
|
||
${deadline ? `Deadline: ${deadline}` : ''}
|
||
|
||
Please review the project materials and submit your evaluation before the deadline.
|
||
|
||
${assignmentsUrl ? `View assignment: ${assignmentsUrl}` : ''}
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "COI Reassignment" email template (for jury receiving a reassigned project)
|
||
*/
|
||
function getCOIReassignedTemplate(
|
||
name: string,
|
||
projectName: string,
|
||
roundName: string,
|
||
deadline?: string,
|
||
assignmentsUrl?: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||
|
||
const projectCard = `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||
<tr>
|
||
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
||
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Reassigned Project</p>
|
||
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;">${escapeHtml(projectName)}</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`
|
||
|
||
const deadlineBox = deadline ? `
|
||
<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;">${escapeHtml(deadline)}</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
` : ''
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${paragraph(`A project has been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>, because the previously assigned juror declared a conflict of interest.`)}
|
||
${projectCard}
|
||
${deadlineBox}
|
||
${paragraph('Please review the project materials and submit your evaluation before the deadline. This is an additional project on top of your existing assignments.')}
|
||
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignment') : ''}
|
||
`
|
||
|
||
return {
|
||
subject: `Project Reassigned to You: "${projectName}" - ${roundName}`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
A project has been reassigned to you for evaluation in ${roundName}, because the previously assigned juror declared a conflict of interest.
|
||
|
||
Project: ${projectName}
|
||
${deadline ? `Deadline: ${deadline}` : ''}
|
||
|
||
Please review the project materials and submit your evaluation before the deadline. This is an additional project on top of your existing assignments.
|
||
|
||
${assignmentsUrl ? `View assignment: ${assignmentsUrl}` : ''}
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "Manual Reassignment" email template (for jury)
|
||
* Sent when an admin manually transfers a project assignment to a juror.
|
||
*/
|
||
function getManualReassignedTemplate(
|
||
name: string,
|
||
projectNames: string[],
|
||
roundName: string,
|
||
deadline?: string,
|
||
assignmentsUrl?: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||
const count = projectNames.length
|
||
const isSingle = count === 1
|
||
|
||
const projectList = projectNames.map((p) => `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 8px 0;">
|
||
<tr>
|
||
<td style="background-color: #eff6ff; border-left: 4px solid ${BRAND.darkBlue}; border-radius: 0 8px 8px 0; padding: 14px 20px;">
|
||
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(p)}</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`).join('')
|
||
|
||
const deadlineBox = deadline ? `
|
||
<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;">${escapeHtml(deadline)}</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
` : ''
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${paragraph(`An administrator has <strong>reassigned ${isSingle ? 'a project' : `${count} projects`}</strong> to you for evaluation in <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
|
||
${projectList}
|
||
${deadlineBox}
|
||
${paragraph(`Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.`)}
|
||
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignments') : ''}
|
||
`
|
||
|
||
const projectListText = projectNames.map((p) => ` - ${p}`).join('\n')
|
||
|
||
return {
|
||
subject: `Project${isSingle ? '' : 's'} Reassigned to You - ${roundName}`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
An administrator has reassigned ${isSingle ? 'a project' : `${count} projects`} to you for evaluation in ${roundName}.
|
||
|
||
${isSingle ? `Project: ${projectNames[0]}` : `Projects:\n${projectListText}`}
|
||
${deadline ? `Deadline: ${deadline}` : ''}
|
||
|
||
Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.
|
||
|
||
${assignmentsUrl ? `View assignments: ${assignmentsUrl}` : ''}
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "Dropout Reassignment" email template (for jury)
|
||
* Sent when a juror drops out and their projects are redistributed.
|
||
*/
|
||
function getDropoutReassignedTemplate(
|
||
name: string,
|
||
projectNames: string[],
|
||
roundName: string,
|
||
droppedJurorName: string,
|
||
deadline?: string,
|
||
assignmentsUrl?: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||
const count = projectNames.length
|
||
const isSingle = count === 1
|
||
|
||
const projectList = projectNames.map((p) => `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 8px 0;">
|
||
<tr>
|
||
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 8px 8px 0; padding: 14px 20px;">
|
||
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(p)}</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`).join('')
|
||
|
||
const deadlineBox = deadline ? `
|
||
<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;">${escapeHtml(deadline)}</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
` : ''
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${paragraph(`Due to a juror becoming unavailable, ${isSingle ? 'a project has' : `${count} projects have`} been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
|
||
${projectList}
|
||
${deadlineBox}
|
||
${paragraph(`${isSingle ? 'This project was' : 'These projects were'} previously assigned to ${escapeHtml(droppedJurorName)}, who is no longer available. Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.`)}
|
||
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignments') : ''}
|
||
`
|
||
|
||
const projectListText = projectNames.map((p) => ` - ${p}`).join('\n')
|
||
|
||
return {
|
||
subject: `Project${isSingle ? '' : 's'} Reassigned to You (Juror Unavailable) - ${roundName}`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
Due to a juror becoming unavailable, ${isSingle ? 'a project has' : `${count} projects have`} been reassigned to you for evaluation in ${roundName}.
|
||
|
||
${isSingle ? `Project: ${projectNames[0]}` : `Projects:\n${projectListText}`}
|
||
${deadline ? `Deadline: ${deadline}` : ''}
|
||
|
||
${isSingle ? 'This project was' : 'These projects were'} previously assigned to ${droppedJurorName}, who is no longer available. Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.
|
||
|
||
${assignmentsUrl ? `View assignments: ${assignmentsUrl}` : ''}
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "Batch Assigned" email template (for jury)
|
||
*/
|
||
function getBatchAssignedTemplate(
|
||
name: string,
|
||
projectCount: number,
|
||
roundName: string,
|
||
deadline?: string,
|
||
assignmentsUrl?: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||
|
||
const deadlineBox = deadline ? `
|
||
<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;">${escapeHtml(deadline)}</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
` : ''
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${paragraph(`You have been assigned projects to evaluate for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
|
||
${statCard('Projects Assigned', projectCount)}
|
||
${deadlineBox}
|
||
${paragraph('Please review each project and submit your evaluations before the deadline. Your expert assessment is crucial to identifying the most promising ocean protection initiatives.')}
|
||
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View All Assignments') : ''}
|
||
`
|
||
|
||
return {
|
||
subject: `${projectCount} Projects Assigned - ${roundName}`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
You have been assigned ${projectCount} project${projectCount !== 1 ? 's' : ''} to evaluate for ${roundName}.
|
||
|
||
${deadline ? `Deadline: ${deadline}` : ''}
|
||
|
||
Please review each project and submit your evaluations before the deadline.
|
||
|
||
${assignmentsUrl ? `View assignments: ${assignmentsUrl}` : ''}
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "Round Now Open" email template (for jury)
|
||
*/
|
||
function getRoundNowOpenTemplate(
|
||
name: string,
|
||
roundName: string,
|
||
projectCount: number,
|
||
deadline?: string,
|
||
assignmentsUrl?: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||
|
||
const openBanner = `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||
<tr>
|
||
<td style="background: linear-gradient(135deg, ${BRAND.teal} 0%, #0891b2 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
||
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Evaluation Round</p>
|
||
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${escapeHtml(roundName)} is Now Open</h2>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`
|
||
|
||
const deadlineBox = deadline ? `
|
||
<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;">${escapeHtml(deadline)}</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
` : ''
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${openBanner}
|
||
${statCard('Projects to Evaluate', projectCount)}
|
||
${deadlineBox}
|
||
${paragraph('The evaluation round is now open. Please log in to the MOPC Portal to begin reviewing your assigned projects.')}
|
||
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Start Evaluating') : ''}
|
||
`
|
||
|
||
return {
|
||
subject: `${roundName} is Now Open - ${projectCount} Projects Await`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
${roundName} is now open for evaluation.
|
||
|
||
You have ${projectCount} project${projectCount !== 1 ? 's' : ''} to evaluate.
|
||
${deadline ? `Deadline: ${deadline}` : ''}
|
||
|
||
Please log in to the MOPC Portal to begin reviewing your assigned projects.
|
||
|
||
${assignmentsUrl ? `Start evaluating: ${assignmentsUrl}` : ''}
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "24 Hour Reminder" email template (for jury)
|
||
*/
|
||
function getReminder24HTemplate(
|
||
name: string,
|
||
pendingCount: number,
|
||
roundName: string,
|
||
deadline: string,
|
||
assignmentsUrl?: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||
|
||
const urgentBox = `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||
<tr>
|
||
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||
<p style="color: #92400e; margin: 0; font-size: 14px; font-weight: 600;">⚠ 24 Hours Remaining</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${urgentBox}
|
||
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong> closes in 24 hours.`)}
|
||
${statCard('Pending Evaluations', pendingCount)}
|
||
${infoBox(`<strong>Deadline:</strong> ${escapeHtml(deadline)}`, 'warning')}
|
||
${paragraph('Please complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
|
||
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
|
||
`
|
||
|
||
return {
|
||
subject: `Reminder: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} due in 24 hours`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
This is a reminder that ${roundName} closes in 24 hours.
|
||
|
||
You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''}.
|
||
Deadline: ${deadline}
|
||
|
||
Please complete your remaining evaluations before the deadline.
|
||
|
||
${assignmentsUrl ? `Complete evaluations: ${assignmentsUrl}` : ''}
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "3 Days Remaining" email template (for jury)
|
||
*/
|
||
function getReminder3DaysTemplate(
|
||
name: string,
|
||
pendingCount: number,
|
||
roundName: string,
|
||
deadline: string,
|
||
assignmentsUrl?: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||
|
||
const urgentBox = `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||
<tr>
|
||
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||
<p style="color: #92400e; margin: 0; font-size: 14px; font-weight: 600;">⚠ 3 Days Remaining</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${urgentBox}
|
||
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong> closes in 3 days.`)}
|
||
${statCard('Pending Evaluations', pendingCount)}
|
||
${infoBox(`<strong>Deadline:</strong> ${escapeHtml(deadline)}`, 'warning')}
|
||
${paragraph('Please plan to complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
|
||
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
|
||
`
|
||
|
||
return {
|
||
subject: `Reminder: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} due in 3 days`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
This is a reminder that ${roundName} closes in 3 days.
|
||
|
||
You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''}.
|
||
Deadline: ${deadline}
|
||
|
||
Please plan to complete your remaining evaluations before the deadline.
|
||
|
||
${assignmentsUrl ? `Complete evaluations: ${assignmentsUrl}` : ''}
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "1 Hour Reminder" email template (for jury)
|
||
*/
|
||
function getReminder1HTemplate(
|
||
name: string,
|
||
pendingCount: number,
|
||
roundName: string,
|
||
deadline: string,
|
||
assignmentsUrl?: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `${name},` : 'Attention,'
|
||
|
||
const urgentBanner = `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||
<tr>
|
||
<td style="background: linear-gradient(135deg, ${BRAND.red} 0%, #dc2626 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
||
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Urgent</p>
|
||
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">1 Hour Remaining</h2>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${urgentBanner}
|
||
${paragraph(`<strong style="color: ${BRAND.red};">${escapeHtml(roundName)}</strong> closes in <strong>1 hour</strong>.`)}
|
||
${statCard('Evaluations Still Pending', pendingCount)}
|
||
${paragraph('Please submit your remaining evaluations immediately to ensure they are counted.')}
|
||
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Submit Now') : ''}
|
||
`
|
||
|
||
return {
|
||
subject: `URGENT: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} due in 1 hour`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
URGENT
|
||
|
||
${roundName} closes in 1 hour!
|
||
|
||
You have ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} still pending.
|
||
|
||
Please submit your remaining evaluations immediately.
|
||
|
||
${assignmentsUrl ? `Submit now: ${assignmentsUrl}` : ''}
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "Award Voting Open" email template (for jury)
|
||
*/
|
||
function getAwardVotingOpenTemplate(
|
||
name: string,
|
||
awardName: string,
|
||
finalistCount: number,
|
||
deadline?: string,
|
||
votingUrl?: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||
|
||
const awardBanner = `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||
<tr>
|
||
<td style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
||
<p style="color: #ffffff; font-size: 36px; margin: 0 0 8px 0;">🏆</p>
|
||
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Special Award</p>
|
||
<h2 style="color: #ffffff; margin: 0; font-size: 22px; font-weight: 700;">${escapeHtml(awardName)}</h2>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${awardBanner}
|
||
${paragraph(`Voting is now open for the <strong style="color: ${BRAND.darkBlue};">${escapeHtml(awardName)}</strong>.`)}
|
||
${statCard('Finalists', finalistCount)}
|
||
${deadline ? infoBox(`<strong>Voting closes:</strong> ${escapeHtml(deadline)}`, 'warning') : ''}
|
||
${paragraph('Please review the finalist projects and cast your vote for the most deserving recipient.')}
|
||
${votingUrl ? ctaButton(votingUrl, 'Cast Your Vote') : ''}
|
||
`
|
||
|
||
return {
|
||
subject: `Vote Now: ${awardName}`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
Voting is now open for the ${awardName}.
|
||
|
||
${finalistCount} finalists are competing for this award.
|
||
${deadline ? `Voting closes: ${deadline}` : ''}
|
||
|
||
Please review the finalist projects and cast your vote.
|
||
|
||
${votingUrl ? `Cast your vote: ${votingUrl}` : ''}
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "Mentee Assigned" email template (for mentor)
|
||
*/
|
||
function getMenteeAssignedTemplate(
|
||
name: string,
|
||
projectName: string,
|
||
teamLeadName: string,
|
||
teamLeadEmail?: string,
|
||
projectUrl?: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||
|
||
const projectCard = `
|
||
<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;">
|
||
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Your New Mentee</p>
|
||
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${escapeHtml(projectName)}</p>
|
||
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
|
||
<strong>Team Lead:</strong> ${escapeHtml(teamLeadName)}${teamLeadEmail ? ` (${escapeHtml(teamLeadEmail)})` : ''}
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${paragraph('You have been assigned as a mentor to a new project in the Monaco Ocean Protection Challenge.')}
|
||
${projectCard}
|
||
${paragraph('As a mentor, you play a crucial role in guiding this team toward success. Please reach out to introduce yourself and schedule your first meeting.')}
|
||
${infoBox('Your expertise and guidance can make a significant impact on their ocean protection initiative.', 'info')}
|
||
${projectUrl ? ctaButton(projectUrl, 'View Project Details') : ''}
|
||
`
|
||
|
||
return {
|
||
subject: `New Mentee Assignment: "${projectName}"`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
You have been assigned as a mentor to a new project in the Monaco Ocean Protection Challenge.
|
||
|
||
Project: ${projectName}
|
||
Team Lead: ${teamLeadName}${teamLeadEmail ? ` (${teamLeadEmail})` : ''}
|
||
|
||
Please reach out to introduce yourself and schedule your first meeting.
|
||
|
||
${projectUrl ? `View project: ${projectUrl}` : ''}
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "Mentee Advanced" email template (for mentor)
|
||
*/
|
||
function getMenteeAdvancedTemplate(
|
||
name: string,
|
||
projectName: string,
|
||
roundName: string,
|
||
nextRoundName?: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${infoBox('Great news about your mentee!', 'success')}
|
||
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has advanced to the next stage!`)}
|
||
${statCard('Advanced From', roundName)}
|
||
${nextRoundName ? paragraph(`They will now compete in <strong>${escapeHtml(nextRoundName)}</strong>.`) : ''}
|
||
${paragraph('Your guidance is making a difference. Continue supporting the team as they progress in the competition.')}
|
||
`
|
||
|
||
return {
|
||
subject: `Your Mentee Advanced: "${projectName}"`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
Great news! Your mentee project "${projectName}" has advanced to the next stage.
|
||
|
||
Advanced from: ${roundName}
|
||
${nextRoundName ? `Now competing in: ${nextRoundName}` : ''}
|
||
|
||
Your guidance is making a difference. Continue supporting the team as they progress.
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "Mentee Won" email template (for mentor)
|
||
*/
|
||
function getMenteeWonTemplate(
|
||
name: string,
|
||
projectName: string,
|
||
awardName: string
|
||
): EmailTemplate {
|
||
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
||
|
||
const trophyBanner = `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||
<tr>
|
||
<td style="background: linear-gradient(135deg, #f59e0b 0%, #eab308 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
||
<p style="color: #ffffff; font-size: 48px; margin: 0 0 12px 0;">🏆</p>
|
||
<p style="color: #ffffff; margin: 0; font-size: 16px; font-weight: 600;">Your Mentee Won!</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${trophyBanner}
|
||
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has won the <strong>${escapeHtml(awardName)}</strong>!`)}
|
||
${infoBox('Your mentorship played a vital role in their success.', 'success')}
|
||
${paragraph('Thank you for your dedication and support. The impact of your guidance extends beyond this competition.')}
|
||
`
|
||
|
||
return {
|
||
subject: `Your Mentee Won: "${projectName}" - ${awardName}`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
Your mentee project "${projectName}" has won the ${awardName}!
|
||
|
||
Your mentorship played a vital role in their success.
|
||
|
||
Thank you for your dedication and support.
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "New Application" email template (for admins)
|
||
*/
|
||
function getNewApplicationTemplate(
|
||
projectName: string,
|
||
applicantName: string,
|
||
applicantEmail: string,
|
||
programName: string,
|
||
reviewUrl?: string
|
||
): EmailTemplate {
|
||
const applicationCard = `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||
<tr>
|
||
<td style="background-color: ${BRAND.lightGray}; border-left: 4px solid ${BRAND.teal}; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
||
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">New Application</p>
|
||
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 18px; font-weight: 700;">${escapeHtml(projectName)}</p>
|
||
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
|
||
<strong>Applicant:</strong> ${escapeHtml(applicantName)} (${escapeHtml(applicantEmail)})
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`
|
||
|
||
const content = `
|
||
${sectionTitle('New Application Received')}
|
||
${paragraph(`A new application has been submitted to <strong style="color: ${BRAND.darkBlue};">${escapeHtml(programName)}</strong>.`)}
|
||
${applicationCard}
|
||
${reviewUrl ? ctaButton(reviewUrl, 'Review Application') : ''}
|
||
`
|
||
|
||
return {
|
||
subject: `New Application: "${projectName}"`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
New Application Received
|
||
|
||
A new application has been submitted to ${programName}.
|
||
|
||
Project: ${projectName}
|
||
Applicant: ${applicantName} (${applicantEmail})
|
||
|
||
${reviewUrl ? `Review application: ${reviewUrl}` : ''}
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "Project Advanced" notification email template
|
||
*/
|
||
export function getAdvancementNotificationTemplate(
|
||
name: string,
|
||
projectName: string,
|
||
fromRoundName: string,
|
||
toRoundName: string,
|
||
customMessage?: string,
|
||
accountUrl?: string,
|
||
fullCustomBody?: boolean,
|
||
): EmailTemplate {
|
||
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
||
|
||
const escapedMessage = customMessage
|
||
? escapeHtml(customMessage).replace(/\n/g, '<br>')
|
||
: null
|
||
|
||
// Full custom body mode: only the custom message inside the branded wrapper
|
||
if (fullCustomBody && escapedMessage) {
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">${escapedMessage}</div>
|
||
${accountUrl
|
||
? ctaButton(accountUrl, 'Create Your Account')
|
||
: ctaButton('/applicant', 'View Your Dashboard')}
|
||
`
|
||
return {
|
||
subject: `Your project has advanced: "${projectName}"`,
|
||
html: getEmailWrapper(content),
|
||
text: `${greeting}\n\n${customMessage}\n\n${accountUrl ? `Create your account: ${getBaseUrl()}${accountUrl}` : `Visit your dashboard: ${getBaseUrl()}/applicant`}\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
||
}
|
||
}
|
||
|
||
const celebrationBanner = `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||
<tr>
|
||
<td style="background: linear-gradient(135deg, #059669 0%, #0d9488 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
||
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Great News</p>
|
||
<h2 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">Your project has advanced!</h2>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${celebrationBanner}
|
||
${infoBox(`<strong>"${escapeHtml(projectName)}"</strong>`, 'success')}
|
||
${infoBox(`Advanced from <strong>${escapeHtml(fromRoundName)}</strong> to <strong>${escapeHtml(toRoundName)}</strong>`, 'info')}
|
||
${
|
||
escapedMessage
|
||
? `<div style="background-color: #f5f5f5; border-radius: 8px; padding: 20px; margin: 20px 0; color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7;">${escapedMessage}</div>`
|
||
: paragraph('Our team will be in touch with more details about the next phase.')
|
||
}
|
||
${accountUrl
|
||
? ctaButton(accountUrl, 'Create Your Account')
|
||
: ctaButton('/applicant', 'View Your Dashboard')}
|
||
`
|
||
|
||
return {
|
||
subject: `Your project has advanced: "${projectName}"`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
Your project has advanced!
|
||
|
||
Project: ${projectName}
|
||
Advanced from: ${fromRoundName}
|
||
To: ${toRoundName}
|
||
|
||
${customMessage || 'Our team will be in touch with more details about the next phase.'}
|
||
|
||
${accountUrl
|
||
? `Create your account: ${getBaseUrl()}${accountUrl}`
|
||
: `Visit your dashboard: ${getBaseUrl()}/applicant`}
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "Project Not Advanced" (rejection) notification email template
|
||
*/
|
||
export function getRejectionNotificationTemplate(
|
||
name: string,
|
||
projectName: string,
|
||
roundName: string,
|
||
customMessage?: string,
|
||
fullCustomBody?: boolean,
|
||
): EmailTemplate {
|
||
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
|
||
|
||
const escapedMessage = customMessage
|
||
? escapeHtml(customMessage).replace(/\n/g, '<br>')
|
||
: null
|
||
|
||
// Full custom body mode: only the custom message inside the branded wrapper
|
||
if (fullCustomBody && escapedMessage) {
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">${escapedMessage}</div>
|
||
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 14px; text-align: center;">
|
||
Thank you for being part of the Monaco Ocean Protection Challenge community.
|
||
</p>
|
||
`
|
||
return {
|
||
subject: `Update on your application: "${projectName}"`,
|
||
html: getEmailWrapper(content),
|
||
text: `${greeting}\n\n${customMessage}\n\nThank you for being part of the Monaco Ocean Protection Challenge community.\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
||
}
|
||
}
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${paragraph(`Thank you for your participation in <strong>${escapeHtml(roundName)}</strong> with your project <strong>"${escapeHtml(projectName)}"</strong>.`)}
|
||
${infoBox('After careful review by our jury, we regret to inform you that your project was not selected to advance at this stage.', 'info')}
|
||
${
|
||
escapedMessage
|
||
? `<div style="background-color: #f5f5f5; border-radius: 8px; padding: 20px; margin: 20px 0; color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7;">${escapedMessage}</div>`
|
||
: ''
|
||
}
|
||
${paragraph('We encourage you to continue developing your ocean protection initiative and to apply again in future editions. Your commitment to protecting our oceans is valuable and appreciated.')}
|
||
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 14px; text-align: center;">
|
||
Thank you for being part of the Monaco Ocean Protection Challenge community.
|
||
</p>
|
||
`
|
||
|
||
return {
|
||
subject: `Update on your application: "${projectName}"`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
Thank you for your participation in ${roundName} with your project "${projectName}".
|
||
|
||
After careful review by our jury, we regret to inform you that your project was not selected to advance at this stage.
|
||
|
||
${customMessage || ''}
|
||
|
||
We encourage you to continue developing your ocean protection initiative and to apply again in future editions.
|
||
|
||
Thank you for being part of the Monaco Ocean Protection Challenge community.
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "Award Winner" notification email template — used when finalizing
|
||
* the terminal round of a special award (no further rounds to advance to).
|
||
*/
|
||
export function getAwardWinnerNotificationTemplate(
|
||
name: string,
|
||
projectName: string,
|
||
winnerLabel: string,
|
||
customMessage?: string,
|
||
accountUrl?: string,
|
||
fullCustomBody?: boolean,
|
||
): EmailTemplate {
|
||
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
||
|
||
const escapedMessage = customMessage
|
||
? escapeHtml(customMessage).replace(/\n/g, '<br>')
|
||
: null
|
||
|
||
if (fullCustomBody && escapedMessage) {
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">${escapedMessage}</div>
|
||
${accountUrl ? ctaButton(accountUrl, 'Create Your Account') : ctaButton('/applicant', 'View Your Dashboard')}
|
||
`
|
||
return {
|
||
subject: `Your project has won: "${projectName}"`,
|
||
html: getEmailWrapper(content),
|
||
text: `${greeting}\n\n${customMessage}\n\n${accountUrl ? `Create your account: ${getBaseUrl()}${accountUrl}` : `Visit your dashboard: ${getBaseUrl()}/applicant`}\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
||
}
|
||
}
|
||
|
||
const winnerBanner = `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||
<tr>
|
||
<td style="background: linear-gradient(135deg, #b45309 0%, #f59e0b 100%); border-radius: 12px; padding: 28px; text-align: center;">
|
||
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Winner Announcement</p>
|
||
<h2 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">Your project has won!</h2>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${winnerBanner}
|
||
${infoBox(`<strong>"${escapeHtml(projectName)}"</strong> has been selected as a winner of <strong>${escapeHtml(winnerLabel)}</strong>.`, 'success')}
|
||
${
|
||
escapedMessage
|
||
? `<div style="background-color: #f5f5f5; border-radius: 8px; padding: 20px; margin: 20px 0; color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7;">${escapedMessage}</div>`
|
||
: paragraph('Our team will reach out shortly with details on the next steps, including award presentation, communications, and any administrative requirements.')
|
||
}
|
||
${accountUrl ? ctaButton(accountUrl, 'Create Your Account') : ctaButton('/applicant', 'View Your Dashboard')}
|
||
`
|
||
|
||
return {
|
||
subject: `Your project has won: "${projectName}"`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
Your project has won!
|
||
|
||
Project: ${projectName}
|
||
${winnerLabel}
|
||
|
||
"${projectName}" has been selected as a winner of ${winnerLabel}.
|
||
|
||
${customMessage || 'Our team will reach out shortly with details on the next steps, including award presentation, communications, and any administrative requirements.'}
|
||
|
||
${accountUrl ? `Create your account: ${getBaseUrl()}${accountUrl}` : `Visit your dashboard: ${getBaseUrl()}/applicant`}
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate "Under Consideration for Special Award" notification email template
|
||
*/
|
||
export function getAwardSelectionNotificationTemplate(
|
||
name: string,
|
||
projectName: string,
|
||
awardName: string,
|
||
customMessage?: string,
|
||
accountUrl?: string,
|
||
): EmailTemplate {
|
||
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
|
||
|
||
const announcementBanner = `
|
||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||
<tr>
|
||
<td style="background: linear-gradient(135deg, ${BRAND.darkBlue} 0%, ${BRAND.teal} 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
||
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Special Award Consideration</p>
|
||
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">Your project is under consideration</h2>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
`
|
||
|
||
const escapedMessage = customMessage
|
||
? escapeHtml(customMessage).replace(/\n/g, '<br>')
|
||
: null
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${announcementBanner}
|
||
${infoBox(`<strong>"${escapeHtml(projectName)}"</strong> has been shortlisted for consideration for the <strong>${escapeHtml(awardName)}</strong>.`, 'info')}
|
||
${paragraph('This means your project has caught the attention of our selection committee and is being evaluated for this special recognition. Please note that this is not a final award — further review and evaluation steps may follow.')}
|
||
${
|
||
escapedMessage
|
||
? `<div style="background-color: #f5f5f5; border-radius: 8px; padding: 20px; margin: 20px 0; color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7;">${escapedMessage}</div>`
|
||
: paragraph('Our team will be in touch with more details about this award and next steps.')
|
||
}
|
||
${accountUrl
|
||
? ctaButton(accountUrl, 'Create Your Account')
|
||
: ctaButton('/applicant', 'View Your Dashboard')}
|
||
`
|
||
|
||
return {
|
||
subject: `Your project is under consideration for ${awardName}: "${projectName}"`,
|
||
html: getEmailWrapper(content),
|
||
text: `
|
||
${greeting}
|
||
|
||
Your project is under consideration for a special award.
|
||
|
||
Project: ${projectName}
|
||
Award: ${awardName}
|
||
|
||
Your project has been shortlisted for consideration for the ${awardName}. This means your project has caught the attention of our selection committee and is being evaluated for this special recognition. Please note that this is not a final award — further review and evaluation steps may follow.
|
||
|
||
${customMessage || 'Our team will be in touch with more details about this award and next steps.'}
|
||
|
||
${accountUrl
|
||
? `Create your account: ${getBaseUrl()}${accountUrl}`
|
||
: `Visit your dashboard: ${getBaseUrl()}/applicant`}
|
||
|
||
---
|
||
Monaco Ocean Protection Challenge
|
||
Together for a healthier ocean.
|
||
`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate a preview HTML wrapper for admin email previews
|
||
*/
|
||
export function getEmailPreviewHtml(subject: string, body: string): string {
|
||
const formattedBody = escapeHtml(body).replace(/\n/g, '<br>')
|
||
const content = `
|
||
${sectionTitle('Hello [Name],')}
|
||
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">
|
||
${formattedBody}
|
||
</div>
|
||
${ctaButton('#', '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 getEmailWrapper(content)
|
||
}
|
||
|
||
/**
|
||
* Generate "Account Setup Reminder" email template
|
||
* Sent to semi-finalist team members who haven't set up their account yet.
|
||
*/
|
||
export function getAccountReminderTemplate(
|
||
name: string,
|
||
projectName: string,
|
||
accountUrl: string,
|
||
): EmailTemplate {
|
||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||
|
||
const content = `
|
||
${sectionTitle(greeting)}
|
||
${paragraph(`Your project <strong>"${escapeHtml(projectName)}"</strong> has been selected as a semi-finalist in the Monaco Ocean Protection Challenge.`)}
|
||
${infoBox('Please set up your account to access your applicant dashboard and stay up to date with the competition.', 'warning')}
|
||
${ctaButton(accountUrl, 'Set Up Your Account')}
|
||
${paragraph('If you have any questions, please contact the MOPC team.')}
|
||
`
|
||
|
||
return {
|
||
subject: `Action Required: Set up your MOPC account — "${projectName}"`,
|
||
html: getEmailWrapper(content),
|
||
text: `${greeting}\n\nYour project "${projectName}" has been selected as a semi-finalist in the Monaco Ocean Protection Challenge.\n\nPlease set up your account to access your applicant dashboard.\n\nSet up your account: ${getBaseUrl()}${accountUrl}\n\nIf you have any questions, please contact the MOPC team.\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Template registry mapping notification types to template generators
|
||
*/
|
||
type TemplateGenerator = (context: NotificationEmailContext) => EmailTemplate
|
||
|
||
export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
|
||
// Team/Applicant templates
|
||
ADVANCED_SEMIFINAL: (ctx) =>
|
||
getAdvancedSemifinalTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectName as string) || 'Your Project',
|
||
(ctx.metadata?.programName as string) || 'MOPC',
|
||
ctx.metadata?.nextSteps as string | undefined
|
||
),
|
||
ADVANCED_FINAL: (ctx) =>
|
||
getAdvancedFinalTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectName as string) || 'Your Project',
|
||
(ctx.metadata?.programName as string) || 'MOPC',
|
||
ctx.metadata?.nextSteps as string | undefined
|
||
),
|
||
MENTOR_ASSIGNED: (ctx) =>
|
||
getMentorAssignedTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectName as string) || 'Your Project',
|
||
(ctx.metadata?.mentorName as string) || 'Your Mentor',
|
||
ctx.metadata?.mentorBio as string | undefined
|
||
),
|
||
NOT_SELECTED: (ctx) =>
|
||
getNotSelectedTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectName as string) || 'Your Project',
|
||
(ctx.metadata?.roundName as string) || 'this round',
|
||
ctx.linkUrl,
|
||
ctx.metadata?.encouragement as string | undefined
|
||
),
|
||
WINNER_ANNOUNCEMENT: (ctx) =>
|
||
getWinnerAnnouncementTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectName as string) || 'Your Project',
|
||
(ctx.metadata?.awardName as string) || 'the Award',
|
||
ctx.metadata?.prizeDetails as string | undefined
|
||
),
|
||
|
||
// Jury templates
|
||
ASSIGNED_TO_PROJECT: (ctx) =>
|
||
getAssignedToProjectTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectName as string) || 'Project',
|
||
(ctx.metadata?.roundName as string) || 'this round',
|
||
ctx.metadata?.deadline as string | undefined,
|
||
ctx.linkUrl
|
||
),
|
||
COI_REASSIGNED: (ctx) =>
|
||
getCOIReassignedTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectName as string) || 'Project',
|
||
(ctx.metadata?.roundName as string) || 'this round',
|
||
ctx.metadata?.deadline as string | undefined,
|
||
ctx.linkUrl
|
||
),
|
||
MANUAL_REASSIGNED: (ctx) =>
|
||
getManualReassignedTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectNames as string[]) || [(ctx.metadata?.projectName as string) || 'Project'],
|
||
(ctx.metadata?.roundName as string) || 'this round',
|
||
ctx.metadata?.deadline as string | undefined,
|
||
ctx.linkUrl
|
||
),
|
||
DROPOUT_REASSIGNED: (ctx) =>
|
||
getDropoutReassignedTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectNames as string[]) || [(ctx.metadata?.projectName as string) || 'Project'],
|
||
(ctx.metadata?.roundName as string) || 'this round',
|
||
(ctx.metadata?.droppedJurorName as string) || 'a fellow juror',
|
||
ctx.metadata?.deadline as string | undefined,
|
||
ctx.linkUrl
|
||
),
|
||
BATCH_ASSIGNED: (ctx) =>
|
||
getBatchAssignedTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectCount as number) || 1,
|
||
(ctx.metadata?.roundName as string) || 'this round',
|
||
ctx.metadata?.deadline as string | undefined,
|
||
ctx.linkUrl
|
||
),
|
||
ROUND_NOW_OPEN: (ctx) =>
|
||
getRoundNowOpenTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.roundName as string) || 'Evaluation Round',
|
||
(ctx.metadata?.projectCount as number) || 0,
|
||
ctx.metadata?.deadline as string | undefined,
|
||
ctx.linkUrl
|
||
),
|
||
REMINDER_3_DAYS: (ctx) =>
|
||
getReminder3DaysTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.pendingCount as number) || 0,
|
||
(ctx.metadata?.roundName as string) || 'this round',
|
||
(ctx.metadata?.deadline as string) || 'Soon',
|
||
ctx.linkUrl
|
||
),
|
||
REMINDER_24H: (ctx) =>
|
||
getReminder24HTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.pendingCount as number) || 0,
|
||
(ctx.metadata?.roundName as string) || 'this round',
|
||
(ctx.metadata?.deadline as string) || 'Soon',
|
||
ctx.linkUrl
|
||
),
|
||
REMINDER_1H: (ctx) =>
|
||
getReminder1HTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.pendingCount as number) || 0,
|
||
(ctx.metadata?.roundName as string) || 'this round',
|
||
(ctx.metadata?.deadline as string) || 'Very Soon',
|
||
ctx.linkUrl
|
||
),
|
||
AWARD_VOTING_OPEN: (ctx) =>
|
||
getAwardVotingOpenTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.awardName as string) || 'Special Award',
|
||
(ctx.metadata?.finalistCount as number) || 0,
|
||
ctx.metadata?.deadline as string | undefined,
|
||
ctx.linkUrl
|
||
),
|
||
|
||
// Mentor templates
|
||
MENTEE_ASSIGNED: (ctx) =>
|
||
getMenteeAssignedTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectName as string) || 'Project',
|
||
(ctx.metadata?.teamLeadName as string) || 'Team Lead',
|
||
ctx.metadata?.teamLeadEmail as string | undefined,
|
||
ctx.linkUrl
|
||
),
|
||
MENTEE_ADVANCED: (ctx) =>
|
||
getMenteeAdvancedTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectName as string) || 'Project',
|
||
(ctx.metadata?.roundName as string) || 'this round',
|
||
ctx.metadata?.nextRoundName as string | undefined
|
||
),
|
||
MENTEE_WON: (ctx) =>
|
||
getMenteeWonTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectName as string) || 'Project',
|
||
(ctx.metadata?.awardName as string) || 'Award'
|
||
),
|
||
|
||
ADVANCEMENT_NOTIFICATION: (ctx) =>
|
||
getAdvancementNotificationTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectName as string) || 'Your Project',
|
||
(ctx.metadata?.fromRoundName as string) || 'previous round',
|
||
(ctx.metadata?.toRoundName as string) || 'next round',
|
||
ctx.metadata?.customMessage as string | undefined,
|
||
ctx.metadata?.accountUrl as string | undefined,
|
||
ctx.metadata?.fullCustomBody as boolean | undefined,
|
||
),
|
||
REJECTION_NOTIFICATION: (ctx) =>
|
||
getRejectionNotificationTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectName as string) || 'Your Project',
|
||
(ctx.metadata?.roundName as string) || 'this round',
|
||
ctx.metadata?.customMessage as string | undefined,
|
||
ctx.metadata?.fullCustomBody as boolean | undefined,
|
||
),
|
||
|
||
AWARD_WINNER_NOTIFICATION: (ctx) =>
|
||
getAwardWinnerNotificationTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectName as string) || 'Your Project',
|
||
(ctx.metadata?.awardName as string) || 'Special Award',
|
||
ctx.metadata?.customMessage as string | undefined,
|
||
ctx.metadata?.accountUrl as string | undefined,
|
||
ctx.metadata?.fullCustomBody as boolean | undefined,
|
||
),
|
||
|
||
AWARD_SELECTION_NOTIFICATION: (ctx) =>
|
||
getAwardSelectionNotificationTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectName as string) || 'Your Project',
|
||
(ctx.metadata?.awardName as string) || 'Special Award',
|
||
ctx.metadata?.customMessage as string | undefined,
|
||
ctx.metadata?.accountUrl as string | undefined,
|
||
),
|
||
|
||
ACCOUNT_REMINDER: (ctx) =>
|
||
getAccountReminderTemplate(
|
||
ctx.name || '',
|
||
(ctx.metadata?.projectName as string) || 'Your Project',
|
||
(ctx.metadata?.accountUrl as string) || '/accept-invite',
|
||
),
|
||
|
||
// Admin templates
|
||
NEW_APPLICATION: (ctx) =>
|
||
getNewApplicationTemplate(
|
||
(ctx.metadata?.projectName as string) || 'New Project',
|
||
(ctx.metadata?.applicantName as string) || 'Applicant',
|
||
(ctx.metadata?.applicantEmail as string) || '',
|
||
(ctx.metadata?.programName as string) || 'MOPC',
|
||
ctx.linkUrl
|
||
),
|
||
}
|
||
|
||
/**
|
||
* Send styled notification email using the appropriate template
|
||
*/
|
||
export async function sendStyledNotificationEmail(
|
||
email: string,
|
||
name: string,
|
||
type: string,
|
||
context: NotificationEmailContext,
|
||
subjectOverride?: string
|
||
): Promise<void> {
|
||
// Safety net: always ensure linkUrl is absolute before passing to templates
|
||
const safeContext = {
|
||
...context,
|
||
linkUrl: ensureAbsoluteUrl(context.linkUrl),
|
||
}
|
||
const templateGenerator = NOTIFICATION_EMAIL_TEMPLATES[type]
|
||
|
||
let template: EmailTemplate
|
||
|
||
if (templateGenerator) {
|
||
// Use styled template
|
||
template = templateGenerator({ ...safeContext, name })
|
||
// Apply subject override if provided
|
||
if (subjectOverride) {
|
||
template.subject = subjectOverride
|
||
}
|
||
} else {
|
||
// Fall back to generic template
|
||
template = getNotificationEmailTemplate(
|
||
name,
|
||
subjectOverride || safeContext.title,
|
||
safeContext.message,
|
||
safeContext.linkUrl
|
||
)
|
||
}
|
||
|
||
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
|
||
}
|
||
|
||
// =============================================================================
|
||
// Email Sending Functions
|
||
// =============================================================================
|
||
|
||
/**
|
||
* Send password reset email
|
||
*/
|
||
export async function sendPasswordResetEmail(
|
||
email: string,
|
||
url: string,
|
||
expiryMinutes: number = 30
|
||
): Promise<void> {
|
||
const template = getPasswordResetTemplate(url, expiryMinutes)
|
||
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
|
||
}
|
||
|
||
/**
|
||
* 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)
|
||
await sendEmail({ 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,
|
||
expiryHours?: number
|
||
): Promise<void> {
|
||
const template = getGenericInvitationTemplate(name || '', url, role, expiryHours)
|
||
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
|
||
}
|
||
|
||
/**
|
||
* Send award juror notification — used both for the initial assignment
|
||
* notification and for admin-triggered reminders.
|
||
*/
|
||
export async function sendAwardJurorNotificationEmail(opts: {
|
||
email: string
|
||
name: string | null
|
||
awardName: string
|
||
url: string
|
||
eligibleCount?: number
|
||
votingEndAt?: Date | null
|
||
customMessage?: string
|
||
isReminder?: boolean
|
||
}): Promise<void> {
|
||
const template = getAwardJurorNotificationTemplate(opts.name || '', opts.awardName, opts.url, {
|
||
eligibleCount: opts.eligibleCount,
|
||
votingEndAt: opts.votingEndAt,
|
||
customMessage: opts.customMessage,
|
||
isReminder: opts.isReminder,
|
||
})
|
||
await sendEmail({ to: opts.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)
|
||
await sendEmail({ 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
|
||
)
|
||
await sendEmail({ 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
|
||
)
|
||
await sendEmail({ 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>
|
||
`
|
||
await sendEmail({
|
||
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
|
||
)
|
||
await sendEmail({ 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
|
||
)
|
||
await sendEmail({ 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 = escapeHtml(body).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, ensureAbsoluteUrl(linkUrl))
|
||
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
|
||
}
|
||
|
||
// =============================================================================
|
||
// Mentor onboarding (one-shot, on first MENTOR role grant)
|
||
// =============================================================================
|
||
|
||
function getMentorOnboardingTemplate(name: string, baseUrl: string): EmailTemplate {
|
||
const mentorUrl = `${baseUrl.replace(/\/$/, '')}/mentor`
|
||
const subject = 'Welcome to MOPC mentoring'
|
||
const text = [
|
||
`Hi ${name || 'there'},`,
|
||
'',
|
||
'You have been added as a mentor for the Monaco Ocean Protection Challenge.',
|
||
'',
|
||
'As a mentor, you will:',
|
||
' • Be matched with one or more shortlisted projects',
|
||
' • Communicate with project teams in a private workspace',
|
||
' • Share files, comments, and milestone feedback',
|
||
' • Help projects sharpen their submissions before the live final',
|
||
'',
|
||
`Your mentor dashboard: ${mentorUrl}`,
|
||
'',
|
||
'If you also have other roles on the platform (e.g. juror), look for the',
|
||
'"Switch View" pill in the top-right of any page to move between dashboards.',
|
||
'',
|
||
'The MOPC team',
|
||
].join('\n')
|
||
|
||
const html = `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
|
||
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
|
||
<div style="background:#053d57;padding:24px 28px;color:#fefefe;">
|
||
<h1 style="margin:0;font-size:20px;font-weight:600;">Welcome to MOPC mentoring</h1>
|
||
</div>
|
||
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
|
||
<p style="margin-top:0;">Hi ${name || 'there'},</p>
|
||
<p>You have been added as a mentor for the Monaco Ocean Protection Challenge.</p>
|
||
<p>As a mentor, you will:</p>
|
||
<ul style="padding-left:20px;">
|
||
<li>Be matched with one or more shortlisted projects</li>
|
||
<li>Communicate with project teams in a private workspace</li>
|
||
<li>Share files, comments, and milestone feedback</li>
|
||
<li>Help projects sharpen their submissions before the live final</li>
|
||
</ul>
|
||
<p style="margin-top:24px;">
|
||
<a href="${mentorUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Open Mentor Dashboard</a>
|
||
</p>
|
||
<p style="margin-top:24px;color:#64748b;font-size:12px;">
|
||
If you also have other roles on the platform, use the "Switch View" pill in the top-right of any page to move between dashboards.
|
||
</p>
|
||
</div>
|
||
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
|
||
Monaco Ocean Protection Challenge
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
`.trim()
|
||
|
||
return { subject, text, html }
|
||
}
|
||
|
||
/**
|
||
* Send mentor onboarding email. Idempotency is enforced at the call site
|
||
* (see user.bulkUpdateRoles / user.updateRoles) by checking
|
||
* User.mentorOnboardingSentAt.
|
||
*/
|
||
export async function sendMentorOnboardingEmail(email: string, name: string | null): Promise<void> {
|
||
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
|
||
const template = getMentorOnboardingTemplate(name || '', baseUrl)
|
||
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
|
||
}
|
||
|
||
// =============================================================================
|
||
// Per-team mentor assignment (fires every time a mentor is added to a project)
|
||
// =============================================================================
|
||
|
||
function getMentorTeamAssignmentTemplate(
|
||
name: string,
|
||
projectTitle: string,
|
||
workspaceUrl: string,
|
||
): EmailTemplate {
|
||
const subject = `You've been assigned to a new MOPC project: "${projectTitle}"`
|
||
const greeting = name ? `Hi ${name},` : 'Hi there,'
|
||
const text = [
|
||
greeting,
|
||
'',
|
||
`You have been assigned as a mentor to the project "${projectTitle}".`,
|
||
'',
|
||
'You may have co-mentors on this team — you can collaborate together in the project workspace.',
|
||
'',
|
||
`Open the workspace: ${workspaceUrl}`,
|
||
'',
|
||
'The MOPC team',
|
||
].join('\n')
|
||
|
||
const html = `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
|
||
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
|
||
<div style="background:#053d57;padding:24px 28px;color:#fefefe;">
|
||
<h1 style="margin:0;font-size:20px;font-weight:600;">New mentor assignment</h1>
|
||
</div>
|
||
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
|
||
<p style="margin-top:0;">${name ? `Hi ${escapeHtml(name)},` : 'Hi there,'}</p>
|
||
<p>You have been assigned as a mentor to the project <strong>${escapeHtml(projectTitle)}</strong>.</p>
|
||
<p style="margin-top:24px;">
|
||
<a href="${workspaceUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Open Project Workspace</a>
|
||
</p>
|
||
<p style="margin-top:24px;color:#64748b;font-size:12px;">
|
||
You may have co-mentors on this team — you can collaborate together in the project workspace.
|
||
</p>
|
||
</div>
|
||
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
|
||
Monaco Ocean Protection Challenge
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
`.trim()
|
||
|
||
return { subject, text, html }
|
||
}
|
||
|
||
/**
|
||
* Send a per-team mentor assignment email. Fires every time a mentor is added
|
||
* to a specific project (distinct from the one-time onboarding email).
|
||
* Idempotency is enforced at the call site via MentorAssignment.notificationSentAt.
|
||
* Never throws — failures are caught and logged.
|
||
*/
|
||
export async function sendMentorTeamAssignmentEmail(
|
||
email: string,
|
||
name: string | null,
|
||
projectTitle: string,
|
||
projectId: string,
|
||
): Promise<void> {
|
||
try {
|
||
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
|
||
const workspaceUrl = `${baseUrl.replace(/\/$/, '')}/mentor/workspace/${projectId}`
|
||
const template = getMentorTeamAssignmentTemplate(name || '', projectTitle, workspaceUrl)
|
||
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
|
||
} catch (error) {
|
||
console.error('[sendMentorTeamAssignmentEmail] failed', { email, projectId, error })
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// Mentor change requests (PR 8) — admin notification when an applicant or admin
|
||
// opens a MentorChangeRequest. Mentors are NOT notified (per design decision).
|
||
// =============================================================================
|
||
|
||
function getMentorChangeRequestTemplate(
|
||
projectTitle: string,
|
||
requesterName: string | null,
|
||
reason: string,
|
||
adminDashboardUrl: string,
|
||
): EmailTemplate {
|
||
const subject = `Mentor change request for "${projectTitle}"`
|
||
const requesterLabel = requesterName || 'a team member'
|
||
const text = [
|
||
'Hi MOPC admins,',
|
||
'',
|
||
`A mentor change request has been opened by ${requesterLabel} for the project "${projectTitle}".`,
|
||
'',
|
||
'Reason:',
|
||
`"${reason}"`,
|
||
'',
|
||
`Review the request: ${adminDashboardUrl}`,
|
||
'',
|
||
'The MOPC team',
|
||
].join('\n')
|
||
|
||
const html = `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
|
||
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
|
||
<div style="background:#053d57;padding:24px 28px;color:#fefefe;">
|
||
<h1 style="margin:0;font-size:20px;font-weight:600;">Mentor change request</h1>
|
||
</div>
|
||
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
|
||
<p style="margin-top:0;">Hi MOPC admins,</p>
|
||
<p>A mentor change request has been opened by <strong>${escapeHtml(requesterLabel)}</strong> for the project <strong>${escapeHtml(projectTitle)}</strong>.</p>
|
||
<blockquote style="margin:16px 0;padding:12px 16px;background:#f1f5f9;border-left:3px solid #557f8c;border-radius:4px;color:#0f172a;font-style:italic;white-space:pre-wrap;">${escapeHtml(reason)}</blockquote>
|
||
<p style="margin-top:24px;">
|
||
<a href="${adminDashboardUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Review Request</a>
|
||
</p>
|
||
<p style="margin-top:24px;color:#64748b;font-size:12px;">
|
||
Mentors are not notified of change requests; only admins see this.
|
||
</p>
|
||
</div>
|
||
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
|
||
Monaco Ocean Protection Challenge
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
`.trim()
|
||
|
||
return { subject, text, html }
|
||
}
|
||
|
||
/**
|
||
* Notify all SUPER_ADMIN / PROGRAM_ADMIN users that a mentor change request
|
||
* has been opened for a project. Sends one email per recipient.
|
||
* Never throws — failures are caught and logged so the calling mutation
|
||
* (mentor.requestChange) never fails because of email infrastructure issues.
|
||
*/
|
||
export async function sendMentorChangeRequestEmail(
|
||
adminEmails: string[],
|
||
projectTitle: string,
|
||
requesterName: string | null,
|
||
reason: string,
|
||
adminDashboardUrl: string,
|
||
): Promise<void> {
|
||
try {
|
||
if (adminEmails.length === 0) {
|
||
console.warn('[sendMentorChangeRequestEmail] no admin recipients; skipping')
|
||
return
|
||
}
|
||
const template = getMentorChangeRequestTemplate(
|
||
projectTitle,
|
||
requesterName,
|
||
reason,
|
||
adminDashboardUrl,
|
||
)
|
||
await Promise.all(
|
||
adminEmails.map((email) =>
|
||
sendEmail({
|
||
to: email,
|
||
subject: template.subject,
|
||
text: template.text,
|
||
html: template.html,
|
||
}).catch((err) => {
|
||
console.error('[sendMentorChangeRequestEmail] send failed', { email, err })
|
||
}),
|
||
),
|
||
)
|
||
} catch (error) {
|
||
console.error('[sendMentorChangeRequestEmail] failed', { error })
|
||
}
|
||
}
|
||
|
||
function getFinalistConfirmationTemplate(
|
||
name: string,
|
||
projectTitle: string,
|
||
deadlineIso: string,
|
||
confirmUrl: string,
|
||
): EmailTemplate {
|
||
const subject = `Grand Finale: confirm your attendance for "${projectTitle}"`
|
||
const greeting = name ? `Hi ${name},` : 'Hi,'
|
||
const text = [
|
||
greeting,
|
||
'',
|
||
`Congratulations — your project "${projectTitle}" has been selected as a finalist`,
|
||
'for the Monaco Ocean Protection Challenge grand finale.',
|
||
'',
|
||
`Please confirm your team's attendance by ${deadlineIso}.`,
|
||
'On the confirmation page you will:',
|
||
' • Choose which team members will attend',
|
||
' • Indicate who needs visa support',
|
||
'',
|
||
`Confirm here: ${confirmUrl}`,
|
||
'',
|
||
'If your team cannot attend, please use the same link to decline so',
|
||
'we can offer the slot to a waitlisted team in time.',
|
||
'',
|
||
'The MOPC team',
|
||
].join('\n')
|
||
|
||
const html = `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<body style="margin:0;padding:0;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#0f172a;">
|
||
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
|
||
<div style="background:#053d57;padding:24px 28px;color:#fefefe;">
|
||
<h1 style="margin:0;font-size:20px;font-weight:600;">You're a Grand Finale finalist</h1>
|
||
</div>
|
||
<div style="padding:24px 28px;line-height:1.5;font-size:14px;">
|
||
<p style="margin-top:0;">${greeting}</p>
|
||
<p>Congratulations — your project <strong>${escapeHtml(projectTitle)}</strong> has been selected as a finalist for the Monaco Ocean Protection Challenge grand finale.</p>
|
||
<p style="margin-top:20px;padding:12px 16px;background:#fef3c7;border-left:3px solid #d97706;border-radius:4px;">
|
||
<strong>Confirm by ${escapeHtml(deadlineIso)}.</strong>
|
||
</p>
|
||
<p>On the confirmation page you'll choose which team members will attend and indicate who needs visa support.</p>
|
||
<p style="margin-top:24px;">
|
||
<a href="${confirmUrl}" style="display:inline-block;padding:10px 20px;background:#de0f1e;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">Confirm Attendance</a>
|
||
</p>
|
||
<p style="margin-top:24px;color:#64748b;font-size:12px;">
|
||
If your team cannot attend, please use the same link to decline so we can offer the slot to a waitlisted team in time.
|
||
</p>
|
||
</div>
|
||
<div style="padding:16px 28px;background:#f1f5f9;color:#64748b;font-size:12px;text-align:center;">
|
||
Monaco Ocean Protection Challenge
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
`.trim()
|
||
|
||
return { subject, text, html }
|
||
}
|
||
|
||
/**
|
||
* Send a finalist confirmation email. Failures are intentionally not awaited
|
||
* inside any DB transaction — the calling tRPC mutation logs failures but
|
||
* does not roll back the confirmation row creation.
|
||
*/
|
||
export async function sendFinalistConfirmationEmail(
|
||
email: string,
|
||
name: string | null,
|
||
projectTitle: string,
|
||
deadline: Date,
|
||
confirmUrl: string,
|
||
): Promise<void> {
|
||
const template = getFinalistConfirmationTemplate(name || '', projectTitle, deadline.toISOString(), confirmUrl)
|
||
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
|
||
}
|
||
|
||
// =============================================================================
|
||
// LUNCH (PR 6)
|
||
// =============================================================================
|
||
|
||
type LunchRecapPayload = {
|
||
event: { eventAt: Date | null; venue: string | null } | null
|
||
members: Array<{
|
||
name: string
|
||
project: { name: string } | null
|
||
dish: { name: string } | null
|
||
allergens: string[]
|
||
allergenOther: string | null
|
||
}>
|
||
externals: Array<{
|
||
name: string
|
||
project: { name: string } | null
|
||
dish: { name: string } | null
|
||
allergens: string[]
|
||
allergenOther: string | null
|
||
roleNote?: string | null
|
||
}>
|
||
dishCounts: Record<string, number>
|
||
dietaryCounts: Record<string, number>
|
||
allergenCounts: Record<string, number>
|
||
summary: { total: number; picked: number; missing: number }
|
||
}
|
||
|
||
/**
|
||
* Send a lunch reminder to one attendee whose pick is still missing.
|
||
* Failures are caught at the cron layer; this function may throw on
|
||
* individual failures so the caller can decide.
|
||
*/
|
||
export async function sendLunchReminderEmail(opts: {
|
||
to: string
|
||
memberName: string
|
||
eventAt: Date
|
||
venue: string | null
|
||
changeDeadline: Date
|
||
pickUrl: string
|
||
}): Promise<void> {
|
||
const fmt = new Intl.DateTimeFormat('en-GB', {
|
||
timeZone: 'Europe/Monaco', dateStyle: 'long', timeStyle: 'short',
|
||
})
|
||
const subject = `Pick your lunch dish — deadline ${fmt.format(opts.changeDeadline)} (Monaco)`
|
||
const html = `
|
||
<!DOCTYPE html>
|
||
<html><body style="font-family:system-ui,-apple-system,sans-serif;background:#f8fafc;padding:24px;">
|
||
<div style="max-width:560px;margin:0 auto;background:white;border-radius:12px;padding:32px;">
|
||
<h2 style="margin:0 0 16px;color:#0f172a;">Pick your lunch dish</h2>
|
||
<p>Hi ${escapeHtml(opts.memberName)},</p>
|
||
<p>You haven't picked your lunch dish for the Monaco Ocean Protection Challenge grand finale yet.</p>
|
||
<p>
|
||
<strong>Event:</strong> ${fmt.format(opts.eventAt)} (Europe/Monaco)<br/>
|
||
${opts.venue ? `<strong>Venue:</strong> ${escapeHtml(opts.venue)}<br/>` : ''}
|
||
<strong>Deadline to pick:</strong> ${fmt.format(opts.changeDeadline)}
|
||
</p>
|
||
<p style="margin-top:24px;">
|
||
<a href="${opts.pickUrl}" style="display:inline-block;background:#053d57;color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;">Open the picker</a>
|
||
</p>
|
||
<p style="margin-top:24px;color:#64748b;font-size:12px;">
|
||
If you have any questions, reply to this email and we'll help.
|
||
</p>
|
||
</div>
|
||
</body></html>`.trim()
|
||
const text = `Pick your lunch dish.
|
||
Event: ${opts.eventAt.toISOString()}
|
||
${opts.venue ? `Venue: ${opts.venue}\n` : ''}Deadline: ${opts.changeDeadline.toISOString()}
|
||
${opts.pickUrl}`
|
||
await sendEmail({ to: opts.to, subject, text, html })
|
||
}
|
||
|
||
/**
|
||
* Send the lunch recap manifest to admins + extra recipients.
|
||
* Caller passes the assembled recap payload from `buildRecapPayload`.
|
||
*/
|
||
export async function sendLunchRecapEmail(
|
||
recipients: string[],
|
||
payload: LunchRecapPayload,
|
||
): Promise<void> {
|
||
if (recipients.length === 0) return
|
||
const fmt = new Intl.DateTimeFormat('en-GB', {
|
||
timeZone: 'Europe/Monaco', dateStyle: 'long', timeStyle: 'short',
|
||
})
|
||
const subject = `Lunch manifest — ${payload.event?.eventAt ? fmt.format(payload.event.eventAt) : 'TBD'}`
|
||
const dishLines = Object.entries(payload.dishCounts)
|
||
.sort(([, a], [, b]) => b - a)
|
||
.map(([name, n]) => `<li>${n}× ${escapeHtml(name)}</li>`).join('')
|
||
const dietaryLines = Object.entries(payload.dietaryCounts)
|
||
.map(([name, n]) => `<li>${n}× ${name.replace('_', ' ').toLowerCase()}</li>`).join('')
|
||
const allergenLines = Object.entries(payload.allergenCounts)
|
||
.sort(([, a], [, b]) => b - a)
|
||
.map(([name, n]) => `<li>${n}× ${name.replace('_', ' ').toLowerCase()}</li>`).join('')
|
||
const formatAllergens = (allergens: string[], other: string | null) =>
|
||
[...allergens.map(a => a.replace('_', ' ').toLowerCase()), other].filter(Boolean).join(', ')
|
||
const memberRows = payload.members.map((r) => `
|
||
<tr>
|
||
<td style="padding:6px 10px;border:1px solid #e2e8f0;">${escapeHtml(r.project?.name ?? '')}</td>
|
||
<td style="padding:6px 10px;border:1px solid #e2e8f0;">${escapeHtml(r.name)}</td>
|
||
<td style="padding:6px 10px;border:1px solid #e2e8f0;">${r.dish ? escapeHtml(r.dish.name) : '<em style="color:#94a3b8;">not picked</em>'}</td>
|
||
<td style="padding:6px 10px;border:1px solid #e2e8f0;">${escapeHtml(formatAllergens(r.allergens, r.allergenOther))}</td>
|
||
</tr>`).join('')
|
||
const externalRows = payload.externals.map((r) => `
|
||
<tr>
|
||
<td style="padding:6px 10px;border:1px solid #e2e8f0;">External${r.project?.name ? ` (with ${escapeHtml(r.project.name)})` : ''}</td>
|
||
<td style="padding:6px 10px;border:1px solid #e2e8f0;">${escapeHtml(r.name)}${r.roleNote ? ` — <em>${escapeHtml(r.roleNote)}</em>` : ''}</td>
|
||
<td style="padding:6px 10px;border:1px solid #e2e8f0;">${r.dish ? escapeHtml(r.dish.name) : '<em style="color:#94a3b8;">not picked</em>'}</td>
|
||
<td style="padding:6px 10px;border:1px solid #e2e8f0;">${escapeHtml(formatAllergens(r.allergens, r.allergenOther))}</td>
|
||
</tr>`).join('')
|
||
const html = `
|
||
<!DOCTYPE html>
|
||
<html><body style="font-family:system-ui,-apple-system,sans-serif;background:#f8fafc;padding:24px;">
|
||
<div style="max-width:760px;margin:0 auto;background:white;border-radius:12px;padding:32px;">
|
||
<h2 style="margin:0 0 8px;color:#0f172a;">Lunch manifest</h2>
|
||
${payload.event?.eventAt ? `<p style="color:#475569;">${fmt.format(payload.event.eventAt)}${payload.event.venue ? ` · ${escapeHtml(payload.event.venue)}` : ''}</p>` : ''}
|
||
<p><strong>${payload.summary.picked}/${payload.summary.total} picked</strong>${payload.summary.missing ? ` (${payload.summary.missing} missing)` : ''}.</p>
|
||
<h3 style="margin-top:24px;">Dishes</h3>
|
||
<ul>${dishLines || '<li>None picked yet</li>'}</ul>
|
||
${dietaryLines ? `<h3>Dietary needs</h3><ul>${dietaryLines}</ul>` : ''}
|
||
<h3>Allergens</h3>
|
||
<ul>${allergenLines || '<li>None reported</li>'}</ul>
|
||
<h3 style="margin-top:24px;">Per-attendee</h3>
|
||
<table style="border-collapse:collapse;width:100%;font-size:14px;">
|
||
<thead><tr>
|
||
<th style="padding:6px 10px;background:#f1f5f9;border:1px solid #e2e8f0;text-align:left;">Team</th>
|
||
<th style="padding:6px 10px;background:#f1f5f9;border:1px solid #e2e8f0;text-align:left;">Name</th>
|
||
<th style="padding:6px 10px;background:#f1f5f9;border:1px solid #e2e8f0;text-align:left;">Dish</th>
|
||
<th style="padding:6px 10px;background:#f1f5f9;border:1px solid #e2e8f0;text-align:left;">Allergies</th>
|
||
</tr></thead>
|
||
<tbody>${memberRows}${externalRows}</tbody>
|
||
</table>
|
||
</div>
|
||
</body></html>`.trim()
|
||
const text = `${payload.summary.picked}/${payload.summary.total} picked. See HTML version for the full manifest.`
|
||
for (const to of recipients) {
|
||
await sendEmail({ to, subject, text, html })
|
||
}
|
||
}
|
||
|