2026-01-30 13:41:32 +01:00
import nodemailer from 'nodemailer'
2026-01-31 11:49:35 +01:00
import type { Transporter } from 'nodemailer'
import { prisma } from '@/lib/prisma'
2026-01-30 13:41:32 +01:00
2026-01-31 11:49:35 +01:00
// Cached transporter and config hash to detect changes
let cachedTransporter : Transporter | null = null
let cachedConfigHash = ''
let cachedFrom = ''
/ * *
* Get SMTP transporter using database settings with env var fallback .
* Caches the transporter and rebuilds it when settings change .
* /
async function getTransporter ( ) : Promise < { transporter : Transporter ; from : string } > {
// Read DB settings
const dbSettings = await prisma . systemSettings . findMany ( {
where : {
2026-02-03 23:19:45 +01:00
key : { in : [ 'smtp_host' , 'smtp_port' , 'smtp_user' , 'smtp_password' , 'email_from_name' , 'email_from' ] } ,
2026-01-31 11:49:35 +01:00
} ,
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 || ''
2026-02-03 23:19:45 +01:00
// Combine sender name and email into "Name <email>" format
const fromName = db . email_from_name || 'MOPC Portal'
const fromEmail = db . email_from || process . env . EMAIL_FROM || 'noreply@monaco-opc.com'
const from = ` ${ fromName } < ${ fromEmail } > `
2026-01-31 11:49:35 +01:00
// Check if config changed since last call
const configHash = ` ${ host } : ${ port } : ${ user } : ${ pass } : ${ from } `
if ( cachedTransporter && configHash === cachedConfigHash ) {
return { transporter : cachedTransporter , from : cachedFrom }
}
// Create new transporter
cachedTransporter = nodemailer . createTransport ( {
host ,
port : parseInt ( port ) ,
secure : port === '465' ,
auth : { user , pass } ,
} )
cachedConfigHash = configHash
cachedFrom = from
return { transporter : cachedTransporter , from : cachedFrom }
}
// Legacy references for backward compat — default sender from env
2026-02-03 22:47:24 +01:00
const defaultFrom = process . env . EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.com>'
2026-01-30 13:41:32 +01:00
// =============================================================================
// Brand Colors & Logo URLs
// =============================================================================
const BRAND = {
red : '#de0f1e' ,
redHover : '#b91c1c' ,
darkBlue : '#053d57' ,
teal : '#557f8c' ,
white : '#fefefe' ,
lightGray : '#f5f5f5' ,
textDark : '#1f2937' ,
textMuted : '#6b7280' ,
}
Add profile settings page, mentor management, and S3 email logos
- Add universal /settings/profile page accessible to all roles with
avatar upload, bio, phone, password change, and account deletion
- Expand updateProfile endpoint to accept bio (metadataJson), phone,
and notification preference
- Add deleteAccount endpoint with password confirmation
- Add Profile Settings link to all nav components (admin, jury, mentor,
observer)
- Add /admin/mentors list page and /admin/mentors/[id] detail page for
mentor management
- Add Mentors nav item to admin sidebar
- Update email logo URLs to S3 (s3.monaco-opc.com/public/)
- Add ocean.png background image to email wrapper
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:57:12 +01:00
const getSmallLogoUrl = ( ) = > 'https://s3.monaco-opc.com/public/MOPC-blue-small.png'
const getBigLogoUrl = ( ) = > 'https://s3.monaco-opc.com/public/MOPC-blue-long.png'
2026-01-30 13:41:32 +01:00
// =============================================================================
// 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 >
Add profile settings page, mentor management, and S3 email logos
- Add universal /settings/profile page accessible to all roles with
avatar upload, bio, phone, password change, and account deletion
- Expand updateProfile endpoint to accept bio (metadataJson), phone,
and notification preference
- Add deleteAccount endpoint with password confirmation
- Add Profile Settings link to all nav components (admin, jury, mentor,
observer)
- Add /admin/mentors list page and /admin/mentors/[id] detail page for
mentor management
- Add Mentors nav item to admin sidebar
- Update email logo URLs to S3 (s3.monaco-opc.com/public/)
- Add ocean.png background image to email wrapper
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:57:12 +01:00
< 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};" >
2026-01-30 13:41:32 +01:00
< 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 >
2026-01-31 11:49:35 +01:00
< td style = "background-color: ${BRAND.white}; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);" >
2026-01-30 13:41:32 +01:00
< 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 >
2026-01-31 11:49:35 +01:00
< td style = "padding: 0 40px 32px 40px;" >
2026-01-30 13:41:32 +01:00
$ { content }
< / td >
< / tr >
2026-01-31 11:49:35 +01:00
<!-- Footer -->
2026-01-30 13:41:32 +01:00
< tr >
2026-01-31 11:49:35 +01:00
< 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 >
2026-01-30 13:41:32 +01:00
< / td >
< / tr >
< / table >
< / td >
< / tr >
< / table >
< / td >
< / tr >
< / table >
< / body >
< / html >
`
}
/ * *
* Generate a styled CTA button
* /
function ctaButton ( url : string , text : string ) : string {
return `
< table role = "presentation" width = "100%" cellspacing = "0" cellpadding = "0" border = "0" style = "margin: 24px 0;" >
< tr >
< td align = "center" >
< a href = "${url}" target = "_blank" style = "display: inline-block; background-color: ${BRAND.red}; color: #ffffff; text-decoration: none; padding: 16px 40px; border-radius: 8px; font-weight: 600; font-size: 16px; mso-padding-alt: 0;" >
<!-- [if mso]>
< i style = "letter-spacing: 40px; mso-font-width: -100%; mso-text-raise: 30pt;" > & nbsp ; < / i >
< ! [ endif ] -- >
< span style = "mso-text-raise: 15pt;" > $ { text } < / span >
<!-- [if mso]>
< i style = "letter-spacing: 40px; mso-font-width: -100%;" > & nbsp ; < / i >
< ! [ endif ] -- >
< / a >
< / td >
< / tr >
< / table >
`
}
/ * *
* Generate styled section title
* /
function sectionTitle ( text : string ) : string {
return ` <h2 style="color: ${ BRAND . darkBlue } ; margin: 0 0 16px 0; font-size: 22px; font-weight: 600; line-height: 1.3;"> ${ text } </h2> `
}
/ * *
* Generate styled paragraph
* /
function paragraph ( text : string ) : string {
return ` <p style="color: ${ BRAND . textDark } ; margin: 0 0 16px 0; font-size: 15px; line-height: 1.6;"> ${ text } </p> `
}
/ * *
* Generate styled info box
* /
function infoBox ( content : string , variant : 'warning' | 'info' | 'success' = 'info' ) : string {
const colors = {
warning : { bg : '#fef3c7' , border : '#f59e0b' , text : '#92400e' } ,
info : { bg : '#e0f2fe' , border : '#0ea5e9' , text : '#0369a1' } ,
success : { bg : '#dcfce7' , border : '#22c55e' , text : '#166534' } ,
}
const c = colors [ variant ]
return `
< table role = "presentation" width = "100%" cellspacing = "0" cellpadding = "0" border = "0" style = "margin: 20px 0;" >
< tr >
< td style = "background-color: ${c.bg}; border-left: 4px solid ${c.border}; border-radius: 0 8px 8px 0; padding: 16px 20px;" >
< p style = "color: ${c.text}; margin: 0; font-size: 14px; font-weight: 500;" > $ { content } < / p >
< / td >
< / tr >
< / table >
`
}
/ * *
* Generate styled stat card
* /
function statCard ( label : string , value : string | number ) : string {
return `
< table role = "presentation" width = "100%" cellspacing = "0" cellpadding = "0" border = "0" style = "margin: 20px 0;" >
< tr >
< td style = "background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px; text-align: center;" >
< p style = "color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;" > $ { label } < / p >
< p style = "color: ${BRAND.darkBlue}; margin: 0; font-size: 42px; font-weight: 700; line-height: 1;" > $ { value } < / p >
< / td >
< / tr >
< / table >
`
}
// =============================================================================
// Email Templates
// =============================================================================
interface EmailTemplate {
subject : string
html : string
text : string
}
/ * *
* Generate magic link email template
* /
function getMagicLinkTemplate ( url : string , expiryMinutes : number = 15 ) : EmailTemplate {
const content = `
$ { sectionTitle ( 'Sign in to your account' ) }
2026-02-03 22:47:24 +01:00
$ { paragraph ( 'Click the button below to securely sign in to the MOPC Portal.' ) }
2026-01-30 13:41:32 +01:00
$ { 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 {
2026-02-03 22:47:24 +01:00
subject : 'Sign in to MOPC Portal' ,
2026-01-30 13:41:32 +01:00
html : getEmailWrapper ( content ) ,
text : `
2026-02-03 22:47:24 +01:00
Sign in to MOPC Portal
2026-01-30 13:41:32 +01:00
=== === === === === === === === =
Click the link below to sign in to your account :
$ { url }
This link will expire in $ { expiryMinutes } minutes .
If you didn ' t request this email , you can safely ignore it .
-- -
Monaco Ocean Protection Challenge
Together for a healthier ocean .
` ,
}
}
/ * *
* Generate generic invitation email template ( not round - specific )
* /
function getGenericInvitationTemplate (
name : string ,
url : string ,
role : string
) : EmailTemplate {
const roleLabel = role === 'JURY_MEMBER' ? 'jury member' : role . toLowerCase ( ) . replace ( '_' , ' ' )
const greeting = name ? ` Hello ${ name } , ` : 'Hello,'
const content = `
$ { sectionTitle ( greeting ) }
$ { paragraph ( ` You've been invited to join the Monaco Ocean Protection Challenge platform as a <strong> ${ roleLabel } </strong>. ` ) }
$ { paragraph ( 'Click the button below to set up your account and get started.' ) }
$ { ctaButton ( url , 'Accept Invitation' ) }
2026-01-31 14:13:16 +01:00
$ { infoBox ( 'This link will expire in 7 days.' , 'info' ) }
2026-01-30 13:41:32 +01:00
`
return {
2026-02-03 22:47:24 +01:00
subject : "You're invited to join the MOPC Portal" ,
2026-01-30 13:41:32 +01:00
html : getEmailWrapper ( content ) ,
text : `
$ { greeting }
You ' ve been invited to join the Monaco Ocean Protection Challenge platform as a $ { roleLabel } .
Click the link below to set up your account and get started :
$ { url }
2026-01-31 14:13:16 +01:00
This link will expire in 7 days .
2026-01-30 13:41:32 +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 >
< p style = "color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;" > $ { deadline } < / p >
< / td >
< / tr >
< / table >
`
const content = `
$ { sectionTitle ( greeting ) }
$ { paragraph ( ` This is a friendly reminder about your pending evaluations for <strong style="color: ${ BRAND . darkBlue } ;"> ${ roundName } </strong>. ` ) }
$ { statCard ( 'Pending Evaluations' , pendingCount ) }
$ { deadlineBox }
$ { paragraph ( 'Your expert evaluation helps identify the most promising ocean conservation projects. Please complete your reviews before the deadline.' ) }
$ { ctaButton ( assignmentsUrl , 'View My Assignments' ) }
`
return {
subject : ` Reminder: ${ pendingCount } evaluation ${ pendingCount !== 1 ? 's' : '' } awaiting your review ` ,
html : getEmailWrapper ( content ) ,
text : `
$ { greeting }
This is a friendly reminder that you have $ { pendingCount } pending evaluation $ { pendingCount !== 1 ? 's' : '' } for $ { roundName } .
Deadline : $ { deadline }
Please complete your evaluations before the deadline to ensure your feedback is included in the selection process .
View your assignments : $ { assignmentsUrl }
-- -
Monaco Ocean Protection Challenge
Together for a healthier ocean .
` ,
}
}
/ * *
* Generate announcement email template
* /
function getAnnouncementTemplate (
name : string ,
title : string ,
message : string ,
ctaText? : string ,
ctaUrl? : string
) : EmailTemplate {
const greeting = name ? ` Hello ${ name } , ` : 'Hello,'
const ctaTextPlain = ctaText && ctaUrl ? ` \ n ${ ctaText } : ${ ctaUrl } \ n ` : ''
// Escape HTML in message but preserve line breaks
const formattedMessage = message
. replace ( /&/g , '&' )
. replace ( /</g , '<' )
. replace ( />/g , '>' )
. replace ( /\n/g , '<br>' )
// Title card with success styling
const titleCard = `
< table role = "presentation" width = "100%" cellspacing = "0" cellpadding = "0" border = "0" style = "margin: 20px 0;" >
< tr >
< td style = "background-color: #ecfdf5; border-left: 4px solid #059669; border-radius: 0 12px 12px 0; padding: 20px 24px;" >
< h3 style = "color: #065f46; margin: 0; font-size: 18px; font-weight: 700;" > $ { title } < / h3 >
< / td >
< / tr >
< / table >
`
const content = `
$ { sectionTitle ( greeting ) }
$ { titleCard }
< div style = "color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;" >
$ { formattedMessage }
< / div >
$ { ctaText && ctaUrl ? ctaButton ( ctaUrl , ctaText ) : '' }
`
return {
subject : title ,
html : getEmailWrapper ( content ) ,
text : `
$ { greeting }
$ { title }
$ { message }
$ { ctaTextPlain }
-- -
Monaco Ocean Protection Challenge
Together for a healthier ocean .
` ,
}
}
/ * *
* Generate jury invitation email template
* /
function getJuryInvitationTemplate (
name : string ,
url : string ,
roundName : string
) : EmailTemplate {
const greeting = name ? ` Hello ${ name } , ` : 'Hello,'
const content = `
$ { sectionTitle ( greeting ) }
$ { paragraph ( ` You've been invited to serve as a jury member for <strong style="color: ${ BRAND . darkBlue } ;"> ${ roundName } </strong>. ` ) }
$ { paragraph ( 'As a jury member, you\'ll evaluate innovative ocean protection projects and help select the most promising initiatives.' ) }
$ { ctaButton ( url , 'Accept Invitation' ) }
< p style = "color: ${BRAND.textMuted}; margin: 20px 0 0 0; font-size: 13px; text-align: center;" >
This link will allow you to access the platform and view your assigned projects .
< / p >
`
return {
subject : ` You're invited to evaluate projects for ${ roundName } ` ,
html : getEmailWrapper ( content ) ,
text : `
$ { greeting }
You ' ve been invited to serve as a jury member for $ { roundName } .
As a jury member , you ' ll evaluate innovative ocean protection projects and help select the most promising initiatives .
Click the link below to accept your invitation :
$ { url }
-- -
Monaco Ocean Protection Challenge
Together for a healthier ocean .
` ,
}
}
2026-02-03 19:48:41 +01:00
/ * *
* Generate application confirmation email template
* /
function getApplicationConfirmationTemplate (
name : string ,
projectName : string ,
programName : string ,
customMessage? : string
) : EmailTemplate {
const greeting = name ? ` Hello ${ name } , ` : 'Hello,'
const customMessageHtml = customMessage
? ` <div style="color: ${ BRAND . textDark } ; font-size: 15px; line-height: 1.7; margin: 20px 0; padding: 20px; background-color: ${ BRAND . lightGray } ; border-radius: 8px;"> ${ customMessage . replace ( /\n/g , '<br>' ) } </div> `
: ''
const content = `
$ { sectionTitle ( greeting ) }
$ { paragraph ( ` Thank you for submitting your application to <strong style="color: ${ BRAND . darkBlue } ;"> ${ programName } </strong>! ` ) }
$ { infoBox ( ` Your project "<strong> ${ projectName } </strong>" has been successfully received. ` , 'success' ) }
$ { customMessageHtml }
$ { paragraph ( 'Our team will review your submission and get back to you soon. In the meantime, if you have any questions, please don\'t hesitate to reach out.' ) }
< p style = "color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;" >
You will receive email updates about your application status .
< / p >
`
return {
subject : ` Application Received - ${ projectName } ` ,
html : getEmailWrapper ( content ) ,
text : `
$ { greeting }
Thank you for submitting your application to $ { programName } !
Your project "${projectName}" has been successfully received .
$ { customMessage || '' }
Our team will review your submission and get back to you soon . In the meantime , if you have any questions , please don ' t hesitate to reach out .
-- -
Monaco Ocean Protection Challenge
Together for a healthier ocean .
` ,
}
}
/ * *
* Generate team member invite email template
* /
function getTeamMemberInviteTemplate (
name : string ,
projectName : string ,
teamLeadName : string ,
inviteUrl : string
) : EmailTemplate {
const greeting = name ? ` Hello ${ name } , ` : 'Hello,'
const content = `
$ { sectionTitle ( greeting ) }
$ { paragraph ( ` <strong> ${ teamLeadName } </strong> has invited you to join their team for the project "<strong style="color: ${ BRAND . darkBlue } ;"> ${ projectName } </strong>" on the Monaco Ocean Protection Challenge platform. ` ) }
$ { paragraph ( 'Click the button below to accept the invitation and set up your account.' ) }
$ { ctaButton ( inviteUrl , 'Accept Invitation' ) }
$ { infoBox ( 'This invitation link will expire in 30 days.' , 'info' ) }
< p style = "color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;" >
If you weren ' t expecting this invitation , you can safely ignore this email .
< / p >
`
return {
subject : ` You've been invited to join " ${ projectName } " ` ,
html : getEmailWrapper ( content ) ,
text : `
$ { greeting }
$ { teamLeadName } has invited you to join their team for the project "${projectName}" on the Monaco Ocean Protection Challenge platform .
Click the link below to accept the invitation and set up your account :
$ { inviteUrl }
This invitation link will expire in 30 days .
If you weren ' t expecting this invitation , you can safely ignore this email .
-- -
Monaco Ocean Protection Challenge
Together for a healthier ocean .
` ,
}
}
2026-01-30 13:41:32 +01:00
// =============================================================================
// Email Sending Functions
// =============================================================================
/ * *
* Send magic link email for authentication
* /
export async function sendMagicLinkEmail (
email : string ,
url : string
) : Promise < void > {
const expiryMinutes = parseInt ( process . env . MAGIC_LINK_EXPIRY || '900' ) / 60
const template = getMagicLinkTemplate ( url , expiryMinutes )
2026-01-31 11:49:35 +01:00
const { transporter , from } = await getTransporter ( )
2026-01-30 13:41:32 +01:00
await transporter . sendMail ( {
2026-01-31 11:49:35 +01:00
from ,
2026-01-30 13:41:32 +01:00
to : email ,
subject : template.subject ,
text : template.text ,
html : template.html ,
} )
}
/ * *
* Send generic invitation email ( not round - specific )
* /
export async function sendInvitationEmail (
email : string ,
name : string | null ,
url : string ,
role : string
) : Promise < void > {
const template = getGenericInvitationTemplate ( name || '' , url , role )
2026-01-31 11:49:35 +01:00
const { transporter , from } = await getTransporter ( )
2026-01-30 13:41:32 +01:00
await transporter . sendMail ( {
2026-01-31 11:49:35 +01:00
from ,
2026-01-30 13:41:32 +01:00
to : email ,
subject : template.subject ,
text : template.text ,
html : template.html ,
} )
}
/ * *
* Send jury invitation email ( round - specific )
* /
export async function sendJuryInvitationEmail (
email : string ,
name : string | null ,
url : string ,
roundName : string
) : Promise < void > {
const template = getJuryInvitationTemplate ( name || '' , url , roundName )
2026-01-31 11:49:35 +01:00
const { transporter , from } = await getTransporter ( )
2026-01-30 13:41:32 +01:00
await transporter . sendMail ( {
2026-01-31 11:49:35 +01:00
from ,
2026-01-30 13:41:32 +01:00
to : email ,
subject : template.subject ,
text : template.text ,
html : template.html ,
} )
}
/ * *
* Send evaluation reminder email
* /
export async function sendEvaluationReminderEmail (
email : string ,
name : string | null ,
pendingCount : number ,
roundName : string ,
deadline : string ,
assignmentsUrl : string
) : Promise < void > {
const template = getEvaluationReminderTemplate (
name || '' ,
pendingCount ,
roundName ,
deadline ,
assignmentsUrl
)
2026-01-31 11:49:35 +01:00
const { transporter , from } = await getTransporter ( )
2026-01-30 13:41:32 +01:00
await transporter . sendMail ( {
2026-01-31 11:49:35 +01:00
from ,
2026-01-30 13:41:32 +01:00
to : email ,
subject : template.subject ,
text : template.text ,
html : template.html ,
} )
}
/ * *
* Send announcement email
* /
export async function sendAnnouncementEmail (
email : string ,
name : string | null ,
title : string ,
message : string ,
ctaText? : string ,
ctaUrl? : string
) : Promise < void > {
const template = getAnnouncementTemplate (
name || '' ,
title ,
message ,
ctaText ,
ctaUrl
)
2026-01-31 11:49:35 +01:00
const { transporter , from } = await getTransporter ( )
2026-01-30 13:41:32 +01:00
await transporter . sendMail ( {
2026-01-31 11:49:35 +01:00
from ,
2026-01-30 13:41:32 +01:00
to : email ,
subject : template.subject ,
text : template.text ,
html : template.html ,
} )
}
/ * *
* Send a test email to verify SMTP configuration
* /
export async function sendTestEmail ( toEmail : string ) : Promise < boolean > {
try {
const content = `
$ { sectionTitle ( 'Test Email' ) }
2026-02-03 22:47:24 +01:00
$ { paragraph ( 'This is a test email from the MOPC Portal.' ) }
2026-01-30 13:41:32 +01:00
$ { 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-01-31 11:49:35 +01:00
const { transporter , from } = await getTransporter ( )
2026-01-30 13:41:32 +01:00
await transporter . sendMail ( {
2026-01-31 11:49:35 +01:00
from ,
2026-01-30 13:41:32 +01:00
to : toEmail ,
2026-02-03 22:47:24 +01:00
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.' ,
2026-01-30 13:41:32 +01:00
html : getEmailWrapper ( content ) ,
} )
return true
} catch {
return false
}
}
/ * *
* Verify SMTP connection
* /
export async function verifyEmailConnection ( ) : Promise < boolean > {
try {
2026-01-31 11:49:35 +01:00
const { transporter } = await getTransporter ( )
2026-01-30 13:41:32 +01:00
await transporter . verify ( )
return true
} catch {
return false
}
}
2026-02-03 19:48:41 +01:00
/ * *
* Send application confirmation email to applicant
* /
export async function sendApplicationConfirmationEmail (
email : string ,
applicantName : string ,
projectName : string ,
programName : string ,
customMessage? : string
) : Promise < void > {
const template = getApplicationConfirmationTemplate (
applicantName ,
projectName ,
programName ,
customMessage
)
const { transporter , from } = await getTransporter ( )
await transporter . sendMail ( {
from ,
to : email ,
subject : template.subject ,
text : template.text ,
html : template.html ,
} )
}
/ * *
* Send team member invite email
* /
export async function sendTeamMemberInviteEmail (
email : string ,
memberName : string ,
projectName : string ,
teamLeadName : string ,
inviteUrl : string
) : Promise < void > {
const template = getTeamMemberInviteTemplate (
memberName ,
projectName ,
teamLeadName ,
inviteUrl
)
const { transporter , from } = await getTransporter ( )
await transporter . sendMail ( {
from ,
to : email ,
subject : template.subject ,
text : template.text ,
html : template.html ,
} )
}
2026-02-03 21:30:25 +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
const formattedBody = body
. replace ( /&/g , '&' )
. replace ( /</g , '<' )
. replace ( />/g , '>' )
. replace ( /\n/g , '<br>' )
const content = `
$ { sectionTitle ( greeting ) }
< div style = "color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;" >
$ { formattedBody }
< / div >
$ { linkUrl ? ctaButton ( linkUrl , 'View Details' ) : '' }
< p style = "color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;" >
2026-02-03 22:47:24 +01:00
You received this email because of your notification preferences on the MOPC Portal .
2026-02-03 21:30:25 +01:00
< / p >
`
return {
subject ,
html : getEmailWrapper ( content ) ,
text : `
$ { greeting }
$ { body }
$ { linkUrl ? ` View details: ${ linkUrl } ` : '' }
-- -
Monaco Ocean Protection Challenge
Together for a healthier ocean .
` ,
}
}
/ * *
* Send notification email ( triggered by in - app notification system )
* /
export async function sendNotificationEmail (
email : string ,
name : string ,
subject : string ,
body : string ,
linkUrl? : string
) : Promise < void > {
const template = getNotificationEmailTemplate ( name , subject , body , linkUrl )
const { transporter , from } = await getTransporter ( )
await transporter . sendMail ( {
from ,
to : email ,
subject : template.subject ,
text : template.text ,
html : template.html ,
} )
}