fix: email XSS sanitization, bulk invite concurrency, error handling (code review batch 2)
- Add escapeHtml() helper and apply to all user-supplied variables in 20+ HTML email templates - Auto-escape in sectionTitle() and statCard() helpers for defense-in-depth - Replace 5 instances of incomplete manual escaping with escapeHtml() - Refactor bulkInviteTeamMembers: batch all DB writes in $transaction, then send emails via Promise.allSettled with concurrency pool of 10 - Fix inner catch block in award-eligibility-job.ts to capture its own error variable Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
171
src/lib/email.ts
171
src/lib/email.ts
@@ -107,6 +107,19 @@ const defaultFrom = process.env.EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.c
|
|||||||
// Helpers
|
// Helpers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape user-supplied strings for safe injection into HTML email templates.
|
||||||
|
* Prevents XSS if email content is rendered in a webmail client.
|
||||||
|
*/
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the base URL for links in emails.
|
* Get the base URL for links in emails.
|
||||||
* Uses NEXTAUTH_URL with a safe production fallback.
|
* Uses NEXTAUTH_URL with a safe production fallback.
|
||||||
@@ -266,7 +279,7 @@ function ctaButton(url: string, text: string): string {
|
|||||||
* Generate styled section title
|
* Generate styled section title
|
||||||
*/
|
*/
|
||||||
function sectionTitle(text: string): string {
|
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>`
|
return `<h2 style="color: ${BRAND.darkBlue}; margin: 0 0 16px 0; font-size: 22px; font-weight: 600; line-height: 1.3;">${escapeHtml(text)}</h2>`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -305,8 +318,8 @@ function statCard(label: string, value: string | number): string {
|
|||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px; text-align: center;">
|
<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.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;">${value}</p>
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 42px; font-weight: 700; line-height: 1;">${escapeHtml(String(value))}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -462,7 +475,7 @@ function getEvaluationReminderTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
<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: #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>
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -470,7 +483,7 @@ function getEvaluationReminderTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`This is a friendly reminder about your pending evaluations for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
${paragraph(`This is a friendly reminder about your pending evaluations for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
|
||||||
${statCard('Pending Evaluations', pendingCount)}
|
${statCard('Pending Evaluations', pendingCount)}
|
||||||
${deadlineBox}
|
${deadlineBox}
|
||||||
${paragraph('Your expert evaluation helps identify the most promising ocean conservation projects. Please complete your reviews before the deadline.')}
|
${paragraph('Your expert evaluation helps identify the most promising ocean conservation projects. Please complete your reviews before the deadline.')}
|
||||||
@@ -512,18 +525,14 @@ function getAnnouncementTemplate(
|
|||||||
const ctaTextPlain = ctaText && ctaUrl ? `\n${ctaText}: ${ctaUrl}\n` : ''
|
const ctaTextPlain = ctaText && ctaUrl ? `\n${ctaText}: ${ctaUrl}\n` : ''
|
||||||
|
|
||||||
// Escape HTML in message but preserve line breaks
|
// Escape HTML in message but preserve line breaks
|
||||||
const formattedMessage = message
|
const formattedMessage = escapeHtml(message).replace(/\n/g, '<br>')
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\n/g, '<br>')
|
|
||||||
|
|
||||||
// Title card with success styling
|
// Title card with success styling
|
||||||
const titleCard = `
|
const titleCard = `
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #ecfdf5; border-left: 4px solid #059669; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
<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>
|
<h3 style="color: #065f46; margin: 0; font-size: 18px; font-weight: 700;">${escapeHtml(title)}</h3>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -567,7 +576,7 @@ function getJuryInvitationTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`You've been invited to serve as a jury member for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
${paragraph(`You've been invited to serve as a jury member for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
|
||||||
${paragraph('As a jury member, you\'ll evaluate innovative ocean protection projects and help select the most promising initiatives.')}
|
${paragraph('As a jury member, you\'ll evaluate innovative ocean protection projects and help select the most promising initiatives.')}
|
||||||
${ctaButton(url, 'Accept Invitation')}
|
${ctaButton(url, 'Accept Invitation')}
|
||||||
<p style="color: ${BRAND.textMuted}; margin: 20px 0 0 0; font-size: 13px; text-align: center;">
|
<p style="color: ${BRAND.textMuted}; margin: 20px 0 0 0; font-size: 13px; text-align: center;">
|
||||||
@@ -608,13 +617,13 @@ function getApplicationConfirmationTemplate(
|
|||||||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||||||
|
|
||||||
const customMessageHtml = customMessage
|
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>`
|
? `<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 = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`Thank you for submitting your application to <strong style="color: ${BRAND.darkBlue};">${programName}</strong>!`)}
|
${paragraph(`Thank you for submitting your application to <strong style="color: ${BRAND.darkBlue};">${escapeHtml(programName)}</strong>!`)}
|
||||||
${infoBox(`Your project "<strong>${projectName}</strong>" has been successfully received.`, 'success')}
|
${infoBox(`Your project "<strong>${escapeHtml(projectName)}</strong>" has been successfully received.`, 'success')}
|
||||||
${customMessageHtml}
|
${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.')}
|
${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;">
|
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
|
||||||
@@ -656,7 +665,7 @@ function getTeamMemberInviteTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${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(`<strong>${escapeHtml(teamLeadName)}</strong> has invited you to join their team for the project "<strong style="color: ${BRAND.darkBlue};">${escapeHtml(projectName)}</strong>" on the Monaco Ocean Protection Challenge platform.`)}
|
||||||
${paragraph('Click the button below to accept the invitation and set up your account.')}
|
${paragraph('Click the button below to accept the invitation and set up your account.')}
|
||||||
${ctaButton(inviteUrl, 'Accept Invitation')}
|
${ctaButton(inviteUrl, 'Accept Invitation')}
|
||||||
${infoBox('This invitation link will expire in 30 days.', 'info')}
|
${infoBox('This invitation link will expire in 30 days.', 'info')}
|
||||||
@@ -729,9 +738,9 @@ function getAdvancedSemifinalTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${celebrationBanner}
|
${celebrationBanner}
|
||||||
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has been selected to advance to the semi-finals of ${programName}.`)}
|
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has been selected to advance to the semi-finals of ${escapeHtml(programName)}.`)}
|
||||||
${infoBox('Your innovative approach to ocean protection stood out among hundreds of submissions.', 'success')}
|
${infoBox('Your innovative approach to ocean protection stood out among hundreds of submissions.', 'success')}
|
||||||
${nextSteps ? paragraph(`<strong>Next Steps:</strong> ${nextSteps}`) : paragraph('Our team will be in touch shortly with details about the next phase of the competition.')}
|
${nextSteps ? paragraph(`<strong>Next Steps:</strong> ${escapeHtml(nextSteps)}`) : paragraph('Our team will be in touch shortly with details about the next phase of the competition.')}
|
||||||
`
|
`
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -778,9 +787,9 @@ function getAdvancedFinalTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${celebrationBanner}
|
${celebrationBanner}
|
||||||
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has been selected as a <strong>Finalist</strong> in ${programName}.`)}
|
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has been selected as a <strong>Finalist</strong> in ${escapeHtml(programName)}.`)}
|
||||||
${infoBox('You are now among the top projects competing for the grand prize!', 'success')}
|
${infoBox('You are now among the top projects competing for the grand prize!', 'success')}
|
||||||
${nextSteps ? paragraph(`<strong>What Happens Next:</strong> ${nextSteps}`) : paragraph('Prepare for the final stage of the competition. Our team will contact you with details about the finalist presentations and judging.')}
|
${nextSteps ? paragraph(`<strong>What Happens Next:</strong> ${escapeHtml(nextSteps)}`) : paragraph('Prepare for the final stage of the competition. Our team will contact you with details about the finalist presentations and judging.')}
|
||||||
`
|
`
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -818,8 +827,8 @@ function getMentorAssignedTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px;">
|
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px;">
|
||||||
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Your Mentor</p>
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Your Mentor</p>
|
||||||
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${mentorName}</p>
|
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${escapeHtml(mentorName)}</p>
|
||||||
${mentorBio ? `<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px; line-height: 1.5;">${mentorBio}</p>` : ''}
|
${mentorBio ? `<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(mentorBio)}</p>` : ''}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -827,7 +836,7 @@ function getMentorAssignedTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`Great news! A mentor has been assigned to support your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong>.`)}
|
${paragraph(`Great news! A mentor has been assigned to support your project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong>.`)}
|
||||||
${mentorCard}
|
${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.')}
|
${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')}
|
${infoBox('Mentorship is a valuable opportunity - make the most of their expertise!', 'info')}
|
||||||
@@ -867,11 +876,11 @@ function getNotSelectedTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`Thank you for participating in ${roundName} with your project <strong>"${projectName}"</strong>.`)}
|
${paragraph(`Thank you for participating in ${escapeHtml(roundName)} with your project <strong>"${escapeHtml(projectName)}"</strong>.`)}
|
||||||
${paragraph('After careful consideration by our jury, we regret to inform you that your project was not selected to advance to the next round.')}
|
${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')}
|
${infoBox('This decision was incredibly difficult given the high quality of submissions we received this year.', 'info')}
|
||||||
${feedbackUrl ? ctaButton(feedbackUrl, 'View Jury Feedback') : ''}
|
${feedbackUrl ? ctaButton(feedbackUrl, 'View Jury Feedback') : ''}
|
||||||
${paragraph(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.')}
|
${paragraph(encouragement ? escapeHtml(encouragement) : 'We encourage you to continue developing your ocean protection initiative and to apply again in future editions. Your commitment to protecting our oceans is valuable and appreciated.')}
|
||||||
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 14px; text-align: center;">
|
<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.
|
Thank you for being part of the Monaco Ocean Protection Challenge community.
|
||||||
</p>
|
</p>
|
||||||
@@ -919,7 +928,7 @@ function getWinnerAnnouncementTemplate(
|
|||||||
<td style="background: linear-gradient(135deg, #f59e0b 0%, #eab308 100%); border-radius: 12px; padding: 32px; text-align: center;">
|
<td style="background: linear-gradient(135deg, #f59e0b 0%, #eab308 100%); border-radius: 12px; padding: 32px; text-align: center;">
|
||||||
<p style="color: #ffffff; font-size: 48px; margin: 0 0 12px 0;">🏆</p>
|
<p style="color: #ffffff; font-size: 48px; margin: 0 0 12px 0;">🏆</p>
|
||||||
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Winner</p>
|
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Winner</p>
|
||||||
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${awardName}</h2>
|
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${escapeHtml(awardName)}</h2>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -928,9 +937,9 @@ function getWinnerAnnouncementTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${trophyBanner}
|
${trophyBanner}
|
||||||
${paragraph(`We are thrilled to announce that your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has been selected as the winner of the <strong>${awardName}</strong>!`)}
|
${paragraph(`We are thrilled to announce that your project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has been selected as the winner of the <strong>${escapeHtml(awardName)}</strong>!`)}
|
||||||
${infoBox('Your outstanding work in ocean protection has made a lasting impression on our jury.', 'success')}
|
${infoBox('Your outstanding work in ocean protection has made a lasting impression on our jury.', 'success')}
|
||||||
${prizeDetails ? paragraph(`<strong>Your Prize:</strong> ${prizeDetails}`) : ''}
|
${prizeDetails ? paragraph(`<strong>Your Prize:</strong> ${escapeHtml(prizeDetails)}`) : ''}
|
||||||
${paragraph('Our team will be in touch shortly with details about the award ceremony and next steps.')}
|
${paragraph('Our team will be in touch shortly with details about the award ceremony and next steps.')}
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -972,7 +981,7 @@ function getAssignedToProjectTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: ${BRAND.lightGray}; border-left: 4px solid ${BRAND.teal}; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
<td style="background-color: ${BRAND.lightGray}; border-left: 4px solid ${BRAND.teal}; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
||||||
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">New Assignment</p>
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">New Assignment</p>
|
||||||
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;">${projectName}</p>
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;">${escapeHtml(projectName)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -983,7 +992,7 @@ function getAssignedToProjectTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
<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: #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>
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -991,7 +1000,7 @@ function getAssignedToProjectTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`You have been assigned a new project to evaluate for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
${paragraph(`You have been assigned a new project to evaluate for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
|
||||||
${projectCard}
|
${projectCard}
|
||||||
${deadlineBox}
|
${deadlineBox}
|
||||||
${paragraph('Please review the project materials and submit your evaluation before the deadline.')}
|
${paragraph('Please review the project materials and submit your evaluation before the deadline.')}
|
||||||
@@ -1037,7 +1046,7 @@ function getCOIReassignedTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
||||||
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Reassigned Project</p>
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Reassigned Project</p>
|
||||||
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;">${projectName}</p>
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;">${escapeHtml(projectName)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1048,7 +1057,7 @@ function getCOIReassignedTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
<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: #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>
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1056,7 +1065,7 @@ function getCOIReassignedTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`A project has been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>, because the previously assigned juror declared a conflict of interest.`)}
|
${paragraph(`A project has been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>, because the previously assigned juror declared a conflict of interest.`)}
|
||||||
${projectCard}
|
${projectCard}
|
||||||
${deadlineBox}
|
${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.')}
|
${paragraph('Please review the project materials and submit your evaluation before the deadline. This is an additional project on top of your existing assignments.')}
|
||||||
@@ -1104,7 +1113,7 @@ function getManualReassignedTemplate(
|
|||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 8px 0;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 8px 0;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #eff6ff; border-left: 4px solid ${BRAND.darkBlue}; border-radius: 0 8px 8px 0; padding: 14px 20px;">
|
<td style="background-color: #eff6ff; border-left: 4px solid ${BRAND.darkBlue}; border-radius: 0 8px 8px 0; padding: 14px 20px;">
|
||||||
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;">${p}</p>
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(p)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1115,7 +1124,7 @@ function getManualReassignedTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
<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: #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>
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1123,7 +1132,7 @@ function getManualReassignedTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`An administrator has <strong>reassigned ${isSingle ? 'a project' : `${count} projects`}</strong> to you for evaluation in <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
${paragraph(`An administrator has <strong>reassigned ${isSingle ? 'a project' : `${count} projects`}</strong> to you for evaluation in <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
|
||||||
${projectList}
|
${projectList}
|
||||||
${deadlineBox}
|
${deadlineBox}
|
||||||
${paragraph(`Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.`)}
|
${paragraph(`Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.`)}
|
||||||
@@ -1174,7 +1183,7 @@ function getDropoutReassignedTemplate(
|
|||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 8px 0;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 8px 0;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 8px 8px 0; padding: 14px 20px;">
|
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 8px 8px 0; padding: 14px 20px;">
|
||||||
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;">${p}</p>
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(p)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1185,7 +1194,7 @@ function getDropoutReassignedTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
<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: #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>
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1193,10 +1202,10 @@ function getDropoutReassignedTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`Due to a juror becoming unavailable, ${isSingle ? 'a project has' : `${count} projects have`} been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
${paragraph(`Due to a juror becoming unavailable, ${isSingle ? 'a project has' : `${count} projects have`} been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
|
||||||
${projectList}
|
${projectList}
|
||||||
${deadlineBox}
|
${deadlineBox}
|
||||||
${paragraph(`${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.`)}
|
${paragraph(`${isSingle ? 'This project was' : 'These projects were'} previously assigned to ${escapeHtml(droppedJurorName)}, who is no longer available. Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.`)}
|
||||||
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignments') : ''}
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignments') : ''}
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -1241,7 +1250,7 @@ function getBatchAssignedTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
<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: #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>
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1249,7 +1258,7 @@ function getBatchAssignedTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`You have been assigned projects to evaluate for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
${paragraph(`You have been assigned projects to evaluate for <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong>.`)}
|
||||||
${statCard('Projects Assigned', projectCount)}
|
${statCard('Projects Assigned', projectCount)}
|
||||||
${deadlineBox}
|
${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.')}
|
${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.')}
|
||||||
@@ -1294,7 +1303,7 @@ function getRoundNowOpenTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background: linear-gradient(135deg, ${BRAND.teal} 0%, #0891b2 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
<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>
|
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Evaluation Round</p>
|
||||||
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${roundName} is Now Open</h2>
|
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${escapeHtml(roundName)} is Now Open</h2>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1305,7 +1314,7 @@ function getRoundNowOpenTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
<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: #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>
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${escapeHtml(deadline)}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1367,9 +1376,9 @@ function getReminder24HTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${urgentBox}
|
${urgentBox}
|
||||||
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${roundName}</strong> closes in 24 hours.`)}
|
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong> closes in 24 hours.`)}
|
||||||
${statCard('Pending Evaluations', pendingCount)}
|
${statCard('Pending Evaluations', pendingCount)}
|
||||||
${infoBox(`<strong>Deadline:</strong> ${deadline}`, 'warning')}
|
${infoBox(`<strong>Deadline:</strong> ${escapeHtml(deadline)}`, 'warning')}
|
||||||
${paragraph('Please complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
|
${paragraph('Please complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
|
||||||
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
|
||||||
`
|
`
|
||||||
@@ -1421,9 +1430,9 @@ function getReminder3DaysTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${urgentBox}
|
${urgentBox}
|
||||||
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${roundName}</strong> closes in 3 days.`)}
|
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${escapeHtml(roundName)}</strong> closes in 3 days.`)}
|
||||||
${statCard('Pending Evaluations', pendingCount)}
|
${statCard('Pending Evaluations', pendingCount)}
|
||||||
${infoBox(`<strong>Deadline:</strong> ${deadline}`, 'warning')}
|
${infoBox(`<strong>Deadline:</strong> ${escapeHtml(deadline)}`, 'warning')}
|
||||||
${paragraph('Please plan to complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
|
${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') : ''}
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
|
||||||
`
|
`
|
||||||
@@ -1476,7 +1485,7 @@ function getReminder1HTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${urgentBanner}
|
${urgentBanner}
|
||||||
${paragraph(`<strong style="color: ${BRAND.red};">${roundName}</strong> closes in <strong>1 hour</strong>.`)}
|
${paragraph(`<strong style="color: ${BRAND.red};">${escapeHtml(roundName)}</strong> closes in <strong>1 hour</strong>.`)}
|
||||||
${statCard('Evaluations Still Pending', pendingCount)}
|
${statCard('Evaluations Still Pending', pendingCount)}
|
||||||
${paragraph('Please submit your remaining evaluations immediately to ensure they are counted.')}
|
${paragraph('Please submit your remaining evaluations immediately to ensure they are counted.')}
|
||||||
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Submit Now') : ''}
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Submit Now') : ''}
|
||||||
@@ -1521,7 +1530,7 @@ function getAwardVotingOpenTemplate(
|
|||||||
<td style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
<td style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
||||||
<p style="color: #ffffff; font-size: 36px; margin: 0 0 8px 0;">🏆</p>
|
<p style="color: #ffffff; font-size: 36px; margin: 0 0 8px 0;">🏆</p>
|
||||||
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Special Award</p>
|
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Special Award</p>
|
||||||
<h2 style="color: #ffffff; margin: 0; font-size: 22px; font-weight: 700;">${awardName}</h2>
|
<h2 style="color: #ffffff; margin: 0; font-size: 22px; font-weight: 700;">${escapeHtml(awardName)}</h2>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -1530,9 +1539,9 @@ function getAwardVotingOpenTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${awardBanner}
|
${awardBanner}
|
||||||
${paragraph(`Voting is now open for the <strong style="color: ${BRAND.darkBlue};">${awardName}</strong>.`)}
|
${paragraph(`Voting is now open for the <strong style="color: ${BRAND.darkBlue};">${escapeHtml(awardName)}</strong>.`)}
|
||||||
${statCard('Finalists', finalistCount)}
|
${statCard('Finalists', finalistCount)}
|
||||||
${deadline ? infoBox(`<strong>Voting closes:</strong> ${deadline}`, 'warning') : ''}
|
${deadline ? infoBox(`<strong>Voting closes:</strong> ${escapeHtml(deadline)}`, 'warning') : ''}
|
||||||
${paragraph('Please review the finalist projects and cast your vote for the most deserving recipient.')}
|
${paragraph('Please review the finalist projects and cast your vote for the most deserving recipient.')}
|
||||||
${votingUrl ? ctaButton(votingUrl, 'Cast Your Vote') : ''}
|
${votingUrl ? ctaButton(votingUrl, 'Cast Your Vote') : ''}
|
||||||
`
|
`
|
||||||
@@ -1576,9 +1585,9 @@ function getMenteeAssignedTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px;">
|
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px;">
|
||||||
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Your New Mentee</p>
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Your New Mentee</p>
|
||||||
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${projectName}</p>
|
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${escapeHtml(projectName)}</p>
|
||||||
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
|
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
|
||||||
<strong>Team Lead:</strong> ${teamLeadName}${teamLeadEmail ? ` (${teamLeadEmail})` : ''}
|
<strong>Team Lead:</strong> ${escapeHtml(teamLeadName)}${teamLeadEmail ? ` (${escapeHtml(teamLeadEmail)})` : ''}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1630,9 +1639,9 @@ function getMenteeAdvancedTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${infoBox('Great news about your mentee!', 'success')}
|
${infoBox('Great news about your mentee!', 'success')}
|
||||||
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has advanced to the next stage!`)}
|
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has advanced to the next stage!`)}
|
||||||
${statCard('Advanced From', roundName)}
|
${statCard('Advanced From', roundName)}
|
||||||
${nextRoundName ? paragraph(`They will now compete in <strong>${nextRoundName}</strong>.`) : ''}
|
${nextRoundName ? paragraph(`They will now compete in <strong>${escapeHtml(nextRoundName)}</strong>.`) : ''}
|
||||||
${paragraph('Your guidance is making a difference. Continue supporting the team as they progress in the competition.')}
|
${paragraph('Your guidance is making a difference. Continue supporting the team as they progress in the competition.')}
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -1680,7 +1689,7 @@ function getMenteeWonTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${trophyBanner}
|
${trophyBanner}
|
||||||
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has won the <strong>${awardName}</strong>!`)}
|
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${escapeHtml(projectName)}"</strong> has won the <strong>${escapeHtml(awardName)}</strong>!`)}
|
||||||
${infoBox('Your mentorship played a vital role in their success.', 'success')}
|
${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.')}
|
${paragraph('Thank you for your dedication and support. The impact of your guidance extends beyond this competition.')}
|
||||||
`
|
`
|
||||||
@@ -1719,9 +1728,9 @@ function getNewApplicationTemplate(
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: ${BRAND.lightGray}; border-left: 4px solid ${BRAND.teal}; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
<td style="background-color: ${BRAND.lightGray}; border-left: 4px solid ${BRAND.teal}; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
||||||
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">New Application</p>
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">New Application</p>
|
||||||
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 18px; font-weight: 700;">${projectName}</p>
|
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 18px; font-weight: 700;">${escapeHtml(projectName)}</p>
|
||||||
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
|
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
|
||||||
<strong>Applicant:</strong> ${applicantName} (${applicantEmail})
|
<strong>Applicant:</strong> ${escapeHtml(applicantName)} (${escapeHtml(applicantEmail)})
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1730,7 +1739,7 @@ function getNewApplicationTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle('New Application Received')}
|
${sectionTitle('New Application Received')}
|
||||||
${paragraph(`A new application has been submitted to <strong style="color: ${BRAND.darkBlue};">${programName}</strong>.`)}
|
${paragraph(`A new application has been submitted to <strong style="color: ${BRAND.darkBlue};">${escapeHtml(programName)}</strong>.`)}
|
||||||
${applicationCard}
|
${applicationCard}
|
||||||
${reviewUrl ? ctaButton(reviewUrl, 'Review Application') : ''}
|
${reviewUrl ? ctaButton(reviewUrl, 'Review Application') : ''}
|
||||||
`
|
`
|
||||||
@@ -1770,11 +1779,7 @@ export function getAdvancementNotificationTemplate(
|
|||||||
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
||||||
|
|
||||||
const escapedMessage = customMessage
|
const escapedMessage = customMessage
|
||||||
? customMessage
|
? escapeHtml(customMessage).replace(/\n/g, '<br>')
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\n/g, '<br>')
|
|
||||||
: null
|
: null
|
||||||
|
|
||||||
// Full custom body mode: only the custom message inside the branded wrapper
|
// Full custom body mode: only the custom message inside the branded wrapper
|
||||||
@@ -1807,8 +1812,8 @@ export function getAdvancementNotificationTemplate(
|
|||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${celebrationBanner}
|
${celebrationBanner}
|
||||||
${infoBox(`<strong>"${projectName}"</strong>`, 'success')}
|
${infoBox(`<strong>"${escapeHtml(projectName)}"</strong>`, 'success')}
|
||||||
${infoBox(`Advanced from <strong>${fromRoundName}</strong> to <strong>${toRoundName}</strong>`, 'info')}
|
${infoBox(`Advanced from <strong>${escapeHtml(fromRoundName)}</strong> to <strong>${escapeHtml(toRoundName)}</strong>`, 'info')}
|
||||||
${
|
${
|
||||||
escapedMessage
|
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>`
|
? `<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>`
|
||||||
@@ -1857,11 +1862,7 @@ export function getRejectionNotificationTemplate(
|
|||||||
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
|
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
|
||||||
|
|
||||||
const escapedMessage = customMessage
|
const escapedMessage = customMessage
|
||||||
? customMessage
|
? escapeHtml(customMessage).replace(/\n/g, '<br>')
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\n/g, '<br>')
|
|
||||||
: null
|
: null
|
||||||
|
|
||||||
// Full custom body mode: only the custom message inside the branded wrapper
|
// Full custom body mode: only the custom message inside the branded wrapper
|
||||||
@@ -1882,7 +1883,7 @@ export function getRejectionNotificationTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`Thank you for your participation in <strong>${roundName}</strong> with your project <strong>"${projectName}"</strong>.`)}
|
${paragraph(`Thank you for your participation in <strong>${escapeHtml(roundName)}</strong> with your project <strong>"${escapeHtml(projectName)}"</strong>.`)}
|
||||||
${infoBox('After careful review by our jury, we regret to inform you that your project was not selected to advance at this stage.', 'info')}
|
${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
|
escapedMessage
|
||||||
@@ -1942,17 +1943,13 @@ export function getAwardSelectionNotificationTemplate(
|
|||||||
`
|
`
|
||||||
|
|
||||||
const escapedMessage = customMessage
|
const escapedMessage = customMessage
|
||||||
? customMessage
|
? escapeHtml(customMessage).replace(/\n/g, '<br>')
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\n/g, '<br>')
|
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${announcementBanner}
|
${announcementBanner}
|
||||||
${infoBox(`<strong>"${projectName}"</strong> has been shortlisted for consideration for the <strong>${awardName}</strong>.`, 'info')}
|
${infoBox(`<strong>"${escapeHtml(projectName)}"</strong> has been shortlisted for consideration for the <strong>${escapeHtml(awardName)}</strong>.`, 'info')}
|
||||||
${paragraph('This means your project has caught the attention of our selection committee and is being evaluated for this special recognition. Please note that this is not a final award — further review and evaluation steps may follow.')}
|
${paragraph('This means your project has caught the attention of our selection committee and is being evaluated for this special recognition. Please note that this is not a final award — further review and evaluation steps may follow.')}
|
||||||
${
|
${
|
||||||
escapedMessage
|
escapedMessage
|
||||||
@@ -1994,11 +1991,7 @@ Together for a healthier ocean.
|
|||||||
* Generate a preview HTML wrapper for admin email previews
|
* Generate a preview HTML wrapper for admin email previews
|
||||||
*/
|
*/
|
||||||
export function getEmailPreviewHtml(subject: string, body: string): string {
|
export function getEmailPreviewHtml(subject: string, body: string): string {
|
||||||
const formattedBody = body
|
const formattedBody = escapeHtml(body).replace(/\n/g, '<br>')
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\n/g, '<br>')
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(subject)}
|
${sectionTitle(subject)}
|
||||||
<div style="color: #1f2937; font-size: 15px; line-height: 1.7; margin: 20px 0;">
|
<div style="color: #1f2937; font-size: 15px; line-height: 1.7; margin: 20px 0;">
|
||||||
@@ -2021,7 +2014,7 @@ export function getAccountReminderTemplate(
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`Your project <strong>"${projectName}"</strong> has been selected as a semi-finalist in the Monaco Ocean Protection Challenge.`)}
|
${paragraph(`Your project <strong>"${escapeHtml(projectName)}"</strong> has been selected as a semi-finalist in the Monaco Ocean Protection Challenge.`)}
|
||||||
${infoBox('Please set up your account to access your applicant dashboard and stay up to date with the competition.', 'warning')}
|
${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')}
|
${ctaButton(accountUrl, 'Set Up Your Account')}
|
||||||
${paragraph('If you have any questions, please contact the MOPC team.')}
|
${paragraph('If you have any questions, please contact the MOPC team.')}
|
||||||
@@ -2454,11 +2447,7 @@ function getNotificationEmailTemplate(
|
|||||||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||||||
|
|
||||||
// Format body text preserving line breaks
|
// Format body text preserving line breaks
|
||||||
const formattedBody = body
|
const formattedBody = escapeHtml(body).replace(/\n/g, '<br>')
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\n/g, '<br>')
|
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
|
|||||||
@@ -1264,35 +1264,55 @@ export const roundRouter = router({
|
|||||||
const expiryHours = await getInviteExpiryHours(ctx.prisma as unknown as import('@prisma/client').PrismaClient)
|
const expiryHours = await getInviteExpiryHours(ctx.prisma as unknown as import('@prisma/client').PrismaClient)
|
||||||
const expiryMs = expiryHours * 60 * 60 * 1000
|
const expiryMs = expiryHours * 60 * 60 * 1000
|
||||||
|
|
||||||
let invited = 0
|
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
let failed = 0
|
let failed = 0
|
||||||
|
|
||||||
|
// Phase 1: Batch all DB writes — generate tokens and update users
|
||||||
|
const toInvite: Array<{ id: string; email: string; name: string | null; role: string; token: string }> = []
|
||||||
for (const [, user] of users) {
|
for (const [, user] of users) {
|
||||||
if (user.status === 'ACTIVE' || user.status === 'INVITED') {
|
if (user.status === 'ACTIVE' || user.status === 'INVITED') {
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
toInvite.push({ ...user, token: generateInviteToken() })
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
if (toInvite.length > 0) {
|
||||||
const token = generateInviteToken()
|
await ctx.prisma.$transaction(
|
||||||
await ctx.prisma.user.update({
|
toInvite.map((u) =>
|
||||||
where: { id: user.id },
|
ctx.prisma.user.update({
|
||||||
|
where: { id: u.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'INVITED',
|
status: 'INVITED',
|
||||||
inviteToken: token,
|
inviteToken: u.token,
|
||||||
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
// Phase 2: Send emails with concurrency pool of 10
|
||||||
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
|
const CONCURRENCY = 10
|
||||||
|
let invited = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < toInvite.length; i += CONCURRENCY) {
|
||||||
|
const batch = toInvite.slice(i, i + CONCURRENCY)
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
batch.map((u) => {
|
||||||
|
const inviteUrl = `${baseUrl}/accept-invite?token=${u.token}`
|
||||||
|
return sendInvitationEmail(u.email, u.name, inviteUrl, u.role, expiryHours)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
invited++
|
invited++
|
||||||
} catch (err) {
|
} else {
|
||||||
console.error(`[bulkInviteTeamMembers] Failed for ${user.email}:`, err)
|
console.error('[bulkInviteTeamMembers] Email send failed:', result.reason)
|
||||||
failed++
|
failed++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
prisma: ctx.prisma,
|
prisma: ctx.prisma,
|
||||||
|
|||||||
@@ -284,9 +284,9 @@ export async function processEligibilityJob(
|
|||||||
eligibilityJobError: errorMessage,
|
eligibilityJobError: errorMessage,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (updateErr) {
|
||||||
// If we can't even update the status, log and give up
|
// If we can't even update the status, log and give up
|
||||||
console.error('Failed to update eligibility job status:', error)
|
console.error('Failed to update eligibility job status:', updateErr, 'Original error:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user