2026-02-14 15:26:42 +01:00
import nodemailer from 'nodemailer'
import type { Transporter } from 'nodemailer'
import { prisma } from '@/lib/prisma'
2026-03-06 13:37:50 +01:00
/ * *
* 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 } )
}
2026-02-14 15:26:42 +01:00
// Cached transporter and config hash to detect changes
let cachedTransporter : Transporter | null = null
let cachedConfigHash = ''
let cachedFrom = ''
2026-03-04 13:29:06 +01:00
let cachedAt = 0
const CACHE_TTL = 60 _000 // 1 minute
2026-02-14 15:26:42 +01:00
/ * *
* Get SMTP transporter using database settings with env var fallback .
* Caches the transporter and rebuilds it when settings change .
2026-03-04 13:29:06 +01:00
* Uses connection pooling for reliable bulk sends .
2026-02-14 15:26:42 +01:00
* /
async function getTransporter ( ) : Promise < { transporter : Transporter ; from : string } > {
2026-03-04 13:29:06 +01:00
// Fast path: return cached transporter if still fresh
if ( cachedTransporter && Date . now ( ) - cachedAt < CACHE_TTL ) {
return { transporter : cachedTransporter , from : cachedFrom }
}
2026-02-14 15:26:42 +01:00
// 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 ) {
2026-03-04 13:29:06 +01:00
cachedAt = Date . now ( )
2026-02-14 15:26:42 +01:00
return { transporter : cachedTransporter , from : cachedFrom }
}
2026-03-04 13:29:06 +01:00
// 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
2026-02-14 15:26:42 +01:00
cachedTransporter = nodemailer . createTransport ( {
host ,
port : parseInt ( port ) ,
secure : port === '465' ,
auth : { user , pass } ,
2026-03-04 13:29:06 +01:00
pool : true ,
maxConnections : 5 ,
maxMessages : 10 ,
socketTimeout : 30_000 ,
connectionTimeout : 15_000 ,
} as nodemailer . TransportOptions )
2026-02-14 15:26:42 +01:00
cachedConfigHash = configHash
cachedFrom = from
2026-03-04 13:29:06 +01:00
cachedAt = Date . now ( )
2026-02-14 15:26:42 +01:00
return { transporter : cachedTransporter , from : cachedFrom }
}
2026-03-04 13:29:06 +01:00
/ * *
* 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 ) )
}
2026-02-14 15:26:42 +01:00
// Legacy references for backward compat — default sender from env
const defaultFrom = process . env . EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.com>'
2026-02-23 14:27:58 +01:00
// =============================================================================
// Helpers
// =============================================================================
2026-03-07 16:59:56 +01:00
/ * *
* 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 , ''' )
}
2026-02-23 14:27:58 +01:00
/ * *
* 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 } `
}
2026-02-14 15:26:42 +01:00
// =============================================================================
// 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 : P i x e l s P e r I n c h >
< / o : O f f i c e D o c u m e n t S e t t i n g s >
< / xml >
< / noscript >
< ! [ endif ] -- >
< / head >
< body style = "margin: 0; padding: 0; background-color: ${BRAND.darkBlue}; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased;" >
< table role = "presentation" width = "100%" cellspacing = "0" cellpadding = "0" border = "0" style = "background-image: url('https://s3.monaco-opc.com/public/ocean.png'); background-size: cover; background-position: center top; background-repeat: no-repeat; background-color: ${BRAND.darkBlue};" >
< tr >
< td align = "center" style = "padding: 40px 20px;" >
<!-- Main Container -->
< table role = "presentation" width = "600" cellspacing = "0" cellpadding = "0" border = "0" style = "max-width: 600px; width: 100%;" >
<!-- Content Box -->
< tr >
< td style = "background-color: ${BRAND.white}; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);" >
< table role = "presentation" width = "100%" cellspacing = "0" cellpadding = "0" border = "0" >
<!-- Small Logo Header -->
< tr >
< td align = "center" style = "padding: 32px 40px 24px 40px;" >
< img src = "${getSmallLogoUrl()}" alt = "MOPC" width = "100" height = "auto" style = "display: block; border: 0; max-width: 100px;" >
< / td >
< / tr >
<!-- Email Content -->
< tr >
< td style = "padding: 0 40px 32px 40px;" >
$ { content }
< / td >
< / tr >
<!-- Footer -->
< tr >
< td style = "padding: 0 40px 32px 40px; border-top: 1px solid #e5e7eb;" >
< table role = "presentation" width = "100%" cellspacing = "0" cellpadding = "0" border = "0" >
< tr >
< td align = "center" style = "padding-top: 24px;" >
< img src = "${getBigLogoUrl()}" alt = "Monaco Ocean Protection Challenge" width = "180" height = "auto" style = "display: block; border: 0; max-width: 180px; margin-bottom: 12px;" >
< / td >
< / tr >
< tr >
< td align = "center" >
< p style = "color: ${BRAND.textMuted}; font-size: 13px; margin: 0; line-height: 1.5;" >
Together for a healthier ocean
< / p >
< p style = "color: ${BRAND.textMuted}; font-size: 12px; margin: 8px 0 0 0;" >
& copy ; $ { new Date ( ) . getFullYear ( ) } Monaco Ocean Protection Challenge
< / p >
< / td >
< / tr >
< / table >
< / td >
< / tr >
< / table >
< / td >
< / tr >
< / table >
< / td >
< / tr >
< / table >
< / body >
< / html >
`
}
/ * *
* Generate a styled CTA button
* /
function ctaButton ( url : string , text : string ) : string {
2026-02-23 14:27:58 +01:00
// Ensure URL is always absolute for email clients
const safeUrl = ensureAbsoluteUrl ( url ) || url
2026-02-14 15:26:42 +01:00
return `
< table role = "presentation" width = "100%" cellspacing = "0" cellpadding = "0" border = "0" style = "margin: 24px 0;" >
< tr >
< td align = "center" >
2026-02-23 14:27:58 +01:00
< 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 >
2026-02-14 15:26:42 +01:00
< / td >
< / tr >
< / table >
`
}
/ * *
* Generate styled section title
* /
function sectionTitle ( text : string ) : string {
2026-03-07 16:59:56 +01:00
return ` <h2 style="color: ${ BRAND . darkBlue } ; margin: 0 0 16px 0; font-size: 22px; font-weight: 600; line-height: 1.3;"> ${ escapeHtml ( text ) } </h2> `
2026-02-14 15:26:42 +01:00
}
/ * *
* 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;" >
2026-03-07 16:59:56 +01:00
< 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 >
2026-02-14 15:26:42 +01:00
< / 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 .
` ,
}
}
2026-03-05 13:49:43 +01:00
/ * *
* 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 .
` ,
}
}
2026-02-14 15:26:42 +01:00
/ * *
* Generate generic invitation email template ( not round - specific )
* /
2026-02-17 22:05:58 +01:00
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' : '' } `
}
2026-02-14 15:26:42 +01:00
function getGenericInvitationTemplate (
name : string ,
url : string ,
2026-02-17 22:05:58 +01:00
role : string ,
expiryHours : number = 72
2026-02-14 15:26:42 +01:00
) : 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,'
2026-02-17 22:05:58 +01:00
const expiryLabel = formatExpiryLabel ( expiryHours )
2026-02-14 15:26:42 +01:00
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' ) }
2026-02-17 22:05:58 +01:00
$ { infoBox ( ` This link will expire in ${ expiryLabel } . ` , 'info' ) }
2026-02-14 15:26:42 +01:00
`
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 }
2026-02-17 22:05:58 +01:00
This link will expire in $ { expiryLabel } .
2026-02-14 15:26:42 +01:00
-- -
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 >
2026-03-07 16:59:56 +01:00
< p style = "color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;" > $ { escapeHtml ( deadline ) } < / p >
2026-02-14 15:26:42 +01:00
< / td >
< / tr >
< / table >
`
const content = `
$ { sectionTitle ( greeting ) }
2026-03-07 16:59:56 +01:00
$ { paragraph ( ` This is a friendly reminder about your pending evaluations for <strong style="color: ${ BRAND . darkBlue } ;"> ${ escapeHtml ( roundName ) } </strong>. ` ) }
2026-02-14 15:26:42 +01:00
$ { 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
2026-03-07 16:59:56 +01:00
const formattedMessage = escapeHtml ( message ) . replace ( /\n/g , '<br>' )
2026-02-14 15:26:42 +01:00
// 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;" >
2026-03-07 16:59:56 +01:00
< h3 style = "color: #065f46; margin: 0; font-size: 18px; font-weight: 700;" > $ { escapeHtml ( title ) } < / h3 >
2026-02-14 15:26:42 +01:00
< / 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 .
` ,
}
}
2026-04-29 13:17:29 +02:00
/ * *
* 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 .
` ,
}
}
2026-02-14 15:26:42 +01:00
/ * *
* Generate jury invitation email template
* /
function getJuryInvitationTemplate (
name : string ,
url : string ,
roundName : string
) : EmailTemplate {
const greeting = name ? ` Hello ${ name } , ` : 'Hello,'
const content = `
$ { sectionTitle ( greeting ) }
2026-03-07 16:59:56 +01:00
$ { paragraph ( ` You've been invited to serve as a jury member for <strong style="color: ${ BRAND . darkBlue } ;"> ${ escapeHtml ( roundName ) } </strong>. ` ) }
2026-02-14 15:26:42 +01:00
$ { 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
2026-03-07 16:59:56 +01:00
? ` <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> `
2026-02-14 15:26:42 +01:00
: ''
const content = `
$ { sectionTitle ( greeting ) }
2026-03-07 16:59:56 +01:00
$ { 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' ) }
2026-02-14 15:26:42 +01:00
$ { 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 ) }
2026-03-07 16:59:56 +01:00
$ { 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. ` ) }
2026-02-14 15:26:42 +01:00
$ { 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 }
2026-03-07 16:59:56 +01:00
$ { paragraph ( ` Your project <strong style="color: ${ BRAND . darkBlue } ;">" ${ escapeHtml ( projectName ) } "</strong> has been selected to advance to the semi-finals of ${ escapeHtml ( programName ) } . ` ) }
2026-02-14 15:26:42 +01:00
$ { infoBox ( 'Your innovative approach to ocean protection stood out among hundreds of submissions.' , 'success' ) }
2026-03-07 16:59:56 +01:00
$ { 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.' ) }
2026-02-14 15:26:42 +01:00
`
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 }
2026-03-07 16:59:56 +01:00
$ { paragraph ( ` Your project <strong style="color: ${ BRAND . darkBlue } ;">" ${ escapeHtml ( projectName ) } "</strong> has been selected as a <strong>Finalist</strong> in ${ escapeHtml ( programName ) } . ` ) }
2026-02-14 15:26:42 +01:00
$ { infoBox ( 'You are now among the top projects competing for the grand prize!' , 'success' ) }
2026-03-07 16:59:56 +01:00
$ { 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.' ) }
2026-02-14 15:26:42 +01:00
`
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 >
2026-03-07 16:59:56 +01:00
< 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> ` : '' }
2026-02-14 15:26:42 +01:00
< / td >
< / tr >
< / table >
`
const content = `
$ { sectionTitle ( greeting ) }
2026-03-07 16:59:56 +01:00
$ { paragraph ( ` Great news! A mentor has been assigned to support your project <strong style="color: ${ BRAND . darkBlue } ;">" ${ escapeHtml ( projectName ) } "</strong>. ` ) }
2026-02-14 15:26:42 +01:00
$ { 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 ) }
2026-03-07 16:59:56 +01:00
$ { paragraph ( ` Thank you for participating in ${ escapeHtml ( roundName ) } with your project <strong>" ${ escapeHtml ( projectName ) } "</strong>. ` ) }
2026-02-14 15:26:42 +01:00
$ { 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' ) : '' }
2026-03-07 16:59:56 +01:00
$ { 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.' ) }
2026-02-14 15:26:42 +01:00
< 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;" > & # 127942 ; < / p >
< p style = "color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;" > Winner < / p >
2026-03-07 16:59:56 +01:00
< h2 style = "color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;" > $ { escapeHtml ( awardName ) } < / h2 >
2026-02-14 15:26:42 +01:00
< / td >
< / tr >
< / table >
`
const content = `
$ { sectionTitle ( greeting ) }
$ { trophyBanner }
2026-03-07 16:59:56 +01:00
$ { 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>! ` ) }
2026-02-14 15:26:42 +01:00
$ { infoBox ( 'Your outstanding work in ocean protection has made a lasting impression on our jury.' , 'success' ) }
2026-03-07 16:59:56 +01:00
$ { prizeDetails ? paragraph ( ` <strong>Your Prize:</strong> ${ escapeHtml ( prizeDetails ) } ` ) : '' }
2026-02-14 15:26:42 +01:00
$ { 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 >
2026-03-07 16:59:56 +01:00
< p style = "color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;" > $ { escapeHtml ( projectName ) } < / p >
2026-02-14 15:26:42 +01:00
< / 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 >
2026-03-07 16:59:56 +01:00
< p style = "color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;" > $ { escapeHtml ( deadline ) } < / p >
2026-02-14 15:26:42 +01:00
< / td >
< / tr >
< / table >
` : ''
const content = `
$ { sectionTitle ( greeting ) }
2026-03-07 16:59:56 +01:00
$ { paragraph ( ` You have been assigned a new project to evaluate for <strong style="color: ${ BRAND . darkBlue } ;"> ${ escapeHtml ( roundName ) } </strong>. ` ) }
2026-02-14 15:26:42 +01:00
$ { 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 .
` ,
}
}
2026-02-23 14:56:30 +01:00
/ * *
* 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 >
2026-03-07 16:59:56 +01:00
< p style = "color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;" > $ { escapeHtml ( projectName ) } < / p >
2026-02-23 14:56:30 +01:00
< / 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 >
2026-03-07 16:59:56 +01:00
< p style = "color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;" > $ { escapeHtml ( deadline ) } < / p >
2026-02-23 14:56:30 +01:00
< / td >
< / tr >
< / table >
` : ''
const content = `
$ { sectionTitle ( greeting ) }
2026-03-07 16:59:56 +01:00
$ { 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. ` ) }
2026-02-23 14:56:30 +01:00
$ { 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;" >
2026-03-07 16:59:56 +01:00
< p style = "color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;" > $ { escapeHtml ( p ) } < / p >
2026-02-23 14:56:30 +01:00
< / 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 >
2026-03-07 16:59:56 +01:00
< p style = "color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;" > $ { escapeHtml ( deadline ) } < / p >
2026-02-23 14:56:30 +01:00
< / td >
< / tr >
< / table >
` : ''
const content = `
$ { sectionTitle ( greeting ) }
2026-03-07 16:59:56 +01:00
$ { 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>. ` ) }
2026-02-23 14:56:30 +01:00
$ { 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 .
` ,
}
}
2026-02-23 16:08:46 +01:00
/ * *
* 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;" >
2026-03-07 16:59:56 +01:00
< p style = "color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;" > $ { escapeHtml ( p ) } < / p >
2026-02-23 16:08:46 +01:00
< / 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 >
2026-03-07 16:59:56 +01:00
< p style = "color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;" > $ { escapeHtml ( deadline ) } < / p >
2026-02-23 16:08:46 +01:00
< / td >
< / tr >
< / table >
` : ''
const content = `
$ { sectionTitle ( greeting ) }
2026-03-07 16:59:56 +01:00
$ { 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>. ` ) }
2026-02-23 16:08:46 +01:00
$ { projectList }
$ { deadlineBox }
2026-03-07 16:59:56 +01:00
$ { 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. ` ) }
2026-02-23 16:08:46 +01:00
$ { 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 .
` ,
}
}
2026-02-14 15:26:42 +01:00
/ * *
* 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 >
2026-03-07 16:59:56 +01:00
< p style = "color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;" > $ { escapeHtml ( deadline ) } < / p >
2026-02-14 15:26:42 +01:00
< / td >
< / tr >
< / table >
` : ''
const content = `
$ { sectionTitle ( greeting ) }
2026-03-07 16:59:56 +01:00
$ { paragraph ( ` You have been assigned projects to evaluate for <strong style="color: ${ BRAND . darkBlue } ;"> ${ escapeHtml ( roundName ) } </strong>. ` ) }
2026-02-14 15:26:42 +01:00
$ { 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 >
2026-03-07 16:59:56 +01:00
< h2 style = "color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;" > $ { escapeHtml ( roundName ) } is Now Open < / h2 >
2026-02-14 15:26:42 +01:00
< / 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 >
2026-03-07 16:59:56 +01:00
< p style = "color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;" > $ { escapeHtml ( deadline ) } < / p >
2026-02-14 15:26:42 +01:00
< / 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;" > & # 9888 ; 24 Hours Remaining < / p >
< / td >
< / tr >
< / table >
`
const content = `
$ { sectionTitle ( greeting ) }
$ { urgentBox }
2026-03-07 16:59:56 +01:00
$ { paragraph ( ` This is a reminder that <strong style="color: ${ BRAND . darkBlue } ;"> ${ escapeHtml ( roundName ) } </strong> closes in 24 hours. ` ) }
2026-02-14 15:26:42 +01:00
$ { statCard ( 'Pending Evaluations' , pendingCount ) }
2026-03-07 16:59:56 +01:00
$ { infoBox ( ` <strong>Deadline:</strong> ${ escapeHtml ( deadline ) } ` , 'warning' ) }
2026-02-14 15:26:42 +01:00
$ { 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 .
` ,
}
}
2026-02-19 12:59:35 +01:00
/ * *
* 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;" > & # 9888 ; 3 Days Remaining < / p >
< / td >
< / tr >
< / table >
`
const content = `
$ { sectionTitle ( greeting ) }
$ { urgentBox }
2026-03-07 16:59:56 +01:00
$ { paragraph ( ` This is a reminder that <strong style="color: ${ BRAND . darkBlue } ;"> ${ escapeHtml ( roundName ) } </strong> closes in 3 days. ` ) }
2026-02-19 12:59:35 +01:00
$ { statCard ( 'Pending Evaluations' , pendingCount ) }
2026-03-07 16:59:56 +01:00
$ { infoBox ( ` <strong>Deadline:</strong> ${ escapeHtml ( deadline ) } ` , 'warning' ) }
2026-02-19 12:59:35 +01:00
$ { 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 .
` ,
}
}
2026-02-14 15:26:42 +01:00
/ * *
* 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 }
2026-03-07 16:59:56 +01:00
$ { paragraph ( ` <strong style="color: ${ BRAND . red } ;"> ${ escapeHtml ( roundName ) } </strong> closes in <strong>1 hour</strong>. ` ) }
2026-02-14 15:26:42 +01:00
$ { 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;" > & # 127942 ; < / 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 >
2026-03-07 16:59:56 +01:00
< h2 style = "color: #ffffff; margin: 0; font-size: 22px; font-weight: 700;" > $ { escapeHtml ( awardName ) } < / h2 >
2026-02-14 15:26:42 +01:00
< / td >
< / tr >
< / table >
`
const content = `
$ { sectionTitle ( greeting ) }
$ { awardBanner }
2026-03-07 16:59:56 +01:00
$ { paragraph ( ` Voting is now open for the <strong style="color: ${ BRAND . darkBlue } ;"> ${ escapeHtml ( awardName ) } </strong>. ` ) }
2026-02-14 15:26:42 +01:00
$ { statCard ( 'Finalists' , finalistCount ) }
2026-03-07 16:59:56 +01:00
$ { deadline ? infoBox ( ` <strong>Voting closes:</strong> ${ escapeHtml ( deadline ) } ` , 'warning' ) : '' }
2026-02-14 15:26:42 +01:00
$ { 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 >
2026-03-07 16:59:56 +01:00
< p style = "color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;" > $ { escapeHtml ( projectName ) } < / p >
2026-02-14 15:26:42 +01:00
< p style = "color: ${BRAND.textDark}; margin: 0; font-size: 14px;" >
2026-03-07 16:59:56 +01:00
< strong > Team Lead : < / strong > $ { escapeHtml ( teamLeadName ) } $ { teamLeadEmail ? ` ( ${ escapeHtml ( teamLeadEmail ) } ) ` : '' }
2026-02-14 15:26:42 +01:00
< / 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' ) }
2026-03-07 16:59:56 +01:00
$ { paragraph ( ` Your mentee project <strong style="color: ${ BRAND . darkBlue } ;">" ${ escapeHtml ( projectName ) } "</strong> has advanced to the next stage! ` ) }
2026-02-14 15:26:42 +01:00
$ { statCard ( 'Advanced From' , roundName ) }
2026-03-07 16:59:56 +01:00
$ { nextRoundName ? paragraph ( ` They will now compete in <strong> ${ escapeHtml ( nextRoundName ) } </strong>. ` ) : '' }
2026-02-14 15:26:42 +01:00
$ { 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;" > & # 127942 ; < / 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 }
2026-03-07 16:59:56 +01:00
$ { paragraph ( ` Your mentee project <strong style="color: ${ BRAND . darkBlue } ;">" ${ escapeHtml ( projectName ) } "</strong> has won the <strong> ${ escapeHtml ( awardName ) } </strong>! ` ) }
2026-02-14 15:26:42 +01:00
$ { 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 >
2026-03-07 16:59:56 +01:00
< p style = "color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 18px; font-weight: 700;" > $ { escapeHtml ( projectName ) } < / p >
2026-02-14 15:26:42 +01:00
< p style = "color: ${BRAND.textDark}; margin: 0; font-size: 14px;" >
2026-03-07 16:59:56 +01:00
< strong > Applicant : < / strong > $ { escapeHtml ( applicantName ) } ( $ { escapeHtml ( applicantEmail ) } )
2026-02-14 15:26:42 +01:00
< / p >
< / td >
< / tr >
< / table >
`
const content = `
$ { sectionTitle ( 'New Application Received' ) }
2026-03-07 16:59:56 +01:00
$ { paragraph ( ` A new application has been submitted to <strong style="color: ${ BRAND . darkBlue } ;"> ${ escapeHtml ( programName ) } </strong>. ` ) }
2026-02-14 15:26:42 +01:00
$ { 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 .
` ,
}
}
2026-03-03 19:14:41 +01:00
/ * *
* Generate "Project Advanced" notification email template
* /
export function getAdvancementNotificationTemplate (
name : string ,
projectName : string ,
fromRoundName : string ,
toRoundName : string ,
customMessage? : string ,
accountUrl? : string ,
2026-03-04 13:29:06 +01:00
fullCustomBody? : boolean ,
2026-03-03 19:14:41 +01:00
) : EmailTemplate {
const greeting = name ? ` Congratulations ${ name } ! ` : 'Congratulations!'
2026-03-04 13:29:06 +01:00
const escapedMessage = customMessage
2026-03-07 16:59:56 +01:00
? escapeHtml ( customMessage ) . replace ( /\n/g , '<br>' )
2026-03-04 13:29:06 +01:00
: 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. ` ,
}
}
2026-03-03 19:14:41 +01:00
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 }
2026-03-07 16:59:56 +01:00
$ { infoBox ( ` <strong>" ${ escapeHtml ( projectName ) } "</strong> ` , 'success' ) }
$ { infoBox ( ` Advanced from <strong> ${ escapeHtml ( fromRoundName ) } </strong> to <strong> ${ escapeHtml ( toRoundName ) } </strong> ` , 'info' ) }
2026-03-03 19:14:41 +01:00
$ {
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 ,
2026-03-04 13:29:06 +01:00
customMessage? : string ,
fullCustomBody? : boolean ,
2026-03-03 19:14:41 +01:00
) : EmailTemplate {
const greeting = name ? ` Dear ${ name } , ` : 'Dear Applicant,'
const escapedMessage = customMessage
2026-03-07 16:59:56 +01:00
? escapeHtml ( customMessage ) . replace ( /\n/g , '<br>' )
2026-03-03 19:14:41 +01:00
: null
2026-03-04 13:29:06 +01:00
// 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. ` ,
}
}
2026-03-03 19:14:41 +01:00
const content = `
$ { sectionTitle ( greeting ) }
2026-03-07 16:59:56 +01:00
$ { paragraph ( ` Thank you for your participation in <strong> ${ escapeHtml ( roundName ) } </strong> with your project <strong>" ${ escapeHtml ( projectName ) } "</strong>. ` ) }
2026-03-03 19:14:41 +01:00
$ { 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 .
` ,
}
}
2026-05-05 20:30:35 +02:00
/ * *
* 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 .
` ,
}
}
2026-03-03 19:14:41 +01:00
/ * *
2026-03-04 00:04:28 +01:00
* Generate "Under Consideration for Special Award" notification email template
2026-03-03 19:14:41 +01:00
* /
export function getAwardSelectionNotificationTemplate (
name : string ,
projectName : string ,
awardName : string ,
customMessage? : string ,
accountUrl? : string ,
) : EmailTemplate {
const greeting = name ? ` Dear ${ name } , ` : 'Dear Applicant,'
2026-03-04 00:04:28 +01:00
const announcementBanner = `
2026-03-03 19:14:41 +01:00
< table role = "presentation" width = "100%" cellspacing = "0" cellpadding = "0" border = "0" style = "margin: 20px 0;" >
< tr >
2026-03-04 00:04:28 +01:00
< 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 >
2026-03-03 19:14:41 +01:00
< / td >
< / tr >
< / table >
`
const escapedMessage = customMessage
2026-03-07 16:59:56 +01:00
? escapeHtml ( customMessage ) . replace ( /\n/g , '<br>' )
2026-03-03 19:14:41 +01:00
: null
const content = `
$ { sectionTitle ( greeting ) }
2026-03-04 00:04:28 +01:00
$ { announcementBanner }
2026-03-07 16:59:56 +01:00
$ { infoBox ( ` <strong>" ${ escapeHtml ( projectName ) } "</strong> has been shortlisted for consideration for the <strong> ${ escapeHtml ( awardName ) } </strong>. ` , 'info' ) }
2026-03-04 00:04:28 +01:00
$ { 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.' ) }
2026-03-03 19:14:41 +01:00
$ {
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 {
2026-03-04 00:04:28 +01:00
subject : ` Your project is under consideration for ${ awardName } : " ${ projectName } " ` ,
2026-03-03 19:14:41 +01:00
html : getEmailWrapper ( content ) ,
text : `
$ { greeting }
2026-03-04 00:04:28 +01:00
Your project is under consideration for a special award .
2026-03-03 19:14:41 +01:00
Project : $ { projectName }
Award : $ { awardName }
2026-03-04 00:04:28 +01:00
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 .
2026-03-03 19:14:41 +01:00
$ { 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 {
2026-03-07 16:59:56 +01:00
const formattedBody = escapeHtml ( body ) . replace ( /\n/g , '<br>' )
2026-03-03 19:14:41 +01:00
const content = `
fix: pipeline progress, message variables, jury invite flow, accept-invite UX
- Pipeline: SUBMISSION rounds count IN_PROGRESS + COMPLETED for progress %
- Round engine: remove phantom SubmissionFileRequirement check blocking auto-transition
- Messages: implement {{userName}}, {{projectName}}, {{roundName}}, {{programName}}, {{deadline}} substitution
- Email preview: show greeting, CTA button, and footer matching actual sent email
- Message composer: add green dot indicator for active rounds in round selector
- User create: generate invite token atomically (prevents stuck INVITED state on email failure)
- Jury invites: use jury-specific email template mentioning round context
- Bulk invite: animated progress bar, batch size hint, success/failure counts
- Accept invite: distinguish server errors (retry button) from expired tokens (redirect)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:47:42 -04:00
$ { sectionTitle ( 'Hello [Name],' ) }
< div style = "color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;" >
2026-03-03 19:14:41 +01:00
$ { formattedBody }
< / div >
fix: pipeline progress, message variables, jury invite flow, accept-invite UX
- Pipeline: SUBMISSION rounds count IN_PROGRESS + COMPLETED for progress %
- Round engine: remove phantom SubmissionFileRequirement check blocking auto-transition
- Messages: implement {{userName}}, {{projectName}}, {{roundName}}, {{programName}}, {{deadline}} substitution
- Email preview: show greeting, CTA button, and footer matching actual sent email
- Message composer: add green dot indicator for active rounds in round selector
- User create: generate invite token atomically (prevents stuck INVITED state on email failure)
- Jury invites: use jury-specific email template mentioning round context
- Bulk invite: animated progress bar, batch size hint, success/failure counts
- Accept invite: distinguish server errors (retry button) from expired tokens (redirect)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:47:42 -04:00
$ { 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 >
2026-03-03 19:14:41 +01:00
`
return getEmailWrapper ( content )
}
2026-03-04 15:41:03 +01:00
/ * *
* 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 ) }
2026-03-07 16:59:56 +01:00
$ { paragraph ( ` Your project <strong>" ${ escapeHtml ( projectName ) } "</strong> has been selected as a semi-finalist in the Monaco Ocean Protection Challenge. ` ) }
2026-03-04 15:41:03 +01:00
$ { 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. ` ,
}
}
2026-02-14 15:26:42 +01:00
/ * *
* 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
) ,
2026-02-23 14:56:30 +01:00
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
) ,
2026-02-23 16:08:46 +01:00
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
) ,
2026-02-14 15:26:42 +01:00
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
) ,
2026-02-19 12:59:35 +01:00
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
) ,
2026-02-14 15:26:42 +01:00
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'
) ,
2026-03-03 19:14:41 +01:00
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 ,
2026-03-04 13:29:06 +01:00
ctx . metadata ? . fullCustomBody as boolean | undefined ,
2026-03-03 19:14:41 +01:00
) ,
REJECTION_NOTIFICATION : ( ctx ) = >
getRejectionNotificationTemplate (
ctx . name || '' ,
( ctx . metadata ? . projectName as string ) || 'Your Project' ,
( ctx . metadata ? . roundName as string ) || 'this round' ,
2026-03-04 13:29:06 +01:00
ctx . metadata ? . customMessage as string | undefined ,
ctx . metadata ? . fullCustomBody as boolean | undefined ,
2026-03-03 19:14:41 +01:00
) ,
2026-05-05 20:30:35 +02:00
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 ,
) ,
2026-03-03 19:14:41 +01:00
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 ,
) ,
2026-03-04 15:41:03 +01:00
ACCOUNT_REMINDER : ( ctx ) = >
getAccountReminderTemplate (
ctx . name || '' ,
( ctx . metadata ? . projectName as string ) || 'Your Project' ,
( ctx . metadata ? . accountUrl as string ) || '/accept-invite' ,
) ,
2026-02-14 15:26:42 +01:00
// 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 > {
2026-02-23 14:27:58 +01:00
// Safety net: always ensure linkUrl is absolute before passing to templates
const safeContext = {
. . . context ,
linkUrl : ensureAbsoluteUrl ( context . linkUrl ) ,
}
2026-02-14 15:26:42 +01:00
const templateGenerator = NOTIFICATION_EMAIL_TEMPLATES [ type ]
let template : EmailTemplate
if ( templateGenerator ) {
// Use styled template
2026-02-23 14:27:58 +01:00
template = templateGenerator ( { . . . safeContext , name } )
2026-02-14 15:26:42 +01:00
// Apply subject override if provided
if ( subjectOverride ) {
template . subject = subjectOverride
}
} else {
// Fall back to generic template
template = getNotificationEmailTemplate (
name ,
2026-02-23 14:27:58 +01:00
subjectOverride || safeContext . title ,
safeContext . message ,
safeContext . linkUrl
2026-02-14 15:26:42 +01:00
)
}
2026-03-06 13:37:50 +01:00
await sendEmail ( { to : email , subject : template.subject , text : template.text , html : template.html } )
2026-02-14 15:26:42 +01:00
}
// =============================================================================
// Email Sending Functions
// =============================================================================
2026-03-05 13:49:43 +01:00
/ * *
* Send password reset email
* /
export async function sendPasswordResetEmail (
email : string ,
url : string ,
expiryMinutes : number = 30
) : Promise < void > {
const template = getPasswordResetTemplate ( url , expiryMinutes )
2026-03-06 13:37:50 +01:00
await sendEmail ( { to : email , subject : template.subject , text : template.text , html : template.html } )
2026-03-05 13:49:43 +01:00
}
2026-02-14 15:26:42 +01:00
/ * *
* 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 )
2026-03-06 13:37:50 +01:00
await sendEmail ( { to : email , subject : template.subject , text : template.text , html : template.html } )
2026-02-14 15:26:42 +01:00
}
/ * *
* Send generic invitation email ( not round - specific )
* /
export async function sendInvitationEmail (
email : string ,
name : string | null ,
url : string ,
2026-02-17 22:05:58 +01:00
role : string ,
expiryHours? : number
2026-02-14 15:26:42 +01:00
) : Promise < void > {
2026-02-17 22:05:58 +01:00
const template = getGenericInvitationTemplate ( name || '' , url , role , expiryHours )
2026-03-06 13:37:50 +01:00
await sendEmail ( { to : email , subject : template.subject , text : template.text , html : template.html } )
2026-02-14 15:26:42 +01:00
}
2026-04-29 13:17:29 +02:00
/ * *
* 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 } )
}
2026-02-14 15:26:42 +01:00
/ * *
* 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 )
2026-03-06 13:37:50 +01:00
await sendEmail ( { to : email , subject : template.subject , text : template.text , html : template.html } )
2026-02-14 15:26:42 +01:00
}
/ * *
* 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
)
2026-03-06 13:37:50 +01:00
await sendEmail ( { to : email , subject : template.subject , text : template.text , html : template.html } )
2026-02-14 15:26:42 +01:00
}
/ * *
* 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
)
2026-03-06 13:37:50 +01:00
await sendEmail ( { to : email , subject : template.subject , text : template.text , html : template.html } )
2026-02-14 15:26:42 +01:00
}
/ * *
* 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 >
`
2026-03-06 13:37:50 +01:00
await sendEmail ( {
2026-02-14 15:26:42 +01:00
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
)
2026-03-06 13:37:50 +01:00
await sendEmail ( { to : email , subject : template.subject , text : template.text , html : template.html } )
2026-02-14 15:26:42 +01:00
}
/ * *
* 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
)
2026-03-06 13:37:50 +01:00
await sendEmail ( { to : email , subject : template.subject , text : template.text , html : template.html } )
2026-02-14 15:26:42 +01:00
}
/ * *
* 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
2026-03-07 16:59:56 +01:00
const formattedBody = escapeHtml ( body ) . replace ( /\n/g , '<br>' )
2026-02-14 15:26:42 +01:00
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 > {
2026-02-23 14:27:58 +01:00
const template = getNotificationEmailTemplate ( name , subject , body , ensureAbsoluteUrl ( linkUrl ) )
2026-03-06 13:37:50 +01:00
await sendEmail ( { to : email , subject : template.subject , text : template.text , html : template.html } )
2026-02-14 15:26:42 +01:00
}
2026-04-28 15:58:17 +02:00
// =============================================================================
// 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 } )
}
2026-04-28 17:55:09 +02:00
2026-05-22 16:16:28 +02:00
// =============================================================================
// 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 } )
}
}
2026-04-28 17:55:09 +02:00
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 } )
}
2026-04-29 02:37:17 +02:00
// =============================================================================
// 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 } )
}
}