591 lines
17 KiB
TypeScript
591 lines
17 KiB
TypeScript
|
|
import nodemailer from 'nodemailer'
|
||
|
|
|
||
|
|
// Create reusable transporter
|
||
|
|
const transporter = nodemailer.createTransport({
|
||
|
|
host: process.env.SMTP_HOST || 'localhost',
|
||
|
|
port: parseInt(process.env.SMTP_PORT || '587'),
|
||
|
|
secure: process.env.SMTP_PORT === '465', // true for 465, false for other ports
|
||
|
|
auth: {
|
||
|
|
user: process.env.SMTP_USER,
|
||
|
|
pass: process.env.SMTP_PASS,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
// Default sender
|
||
|
|
const defaultFrom = process.env.EMAIL_FROM || 'MOPC Platform <noreply@monaco-opc.com>'
|
||
|
|
|
||
|
|
// =============================================================================
|
||
|
|
// Brand Colors & Logo URLs
|
||
|
|
// =============================================================================
|
||
|
|
|
||
|
|
const BRAND = {
|
||
|
|
red: '#de0f1e',
|
||
|
|
redHover: '#b91c1c',
|
||
|
|
darkBlue: '#053d57',
|
||
|
|
teal: '#557f8c',
|
||
|
|
white: '#fefefe',
|
||
|
|
lightGray: '#f5f5f5',
|
||
|
|
textDark: '#1f2937',
|
||
|
|
textMuted: '#6b7280',
|
||
|
|
}
|
||
|
|
|
||
|
|
const getSmallLogoUrl = () => `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/images/MOPC-blue-small.png`
|
||
|
|
const getBigLogoUrl = () => `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/images/MOPC-blue-long.png`
|
||
|
|
|
||
|
|
// =============================================================================
|
||
|
|
// Email Template Wrapper & Helpers
|
||
|
|
// =============================================================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Wrap email content with consistent branding:
|
||
|
|
* - Small logo at top of white content box
|
||
|
|
* - Big logo in dark blue footer
|
||
|
|
*/
|
||
|
|
function getEmailWrapper(content: string): string {
|
||
|
|
return `
|
||
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="utf-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||
|
|
<title>MOPC</title>
|
||
|
|
<!--[if mso]>
|
||
|
|
<noscript>
|
||
|
|
<xml>
|
||
|
|
<o:OfficeDocumentSettings>
|
||
|
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||
|
|
</o:OfficeDocumentSettings>
|
||
|
|
</xml>
|
||
|
|
</noscript>
|
||
|
|
<![endif]-->
|
||
|
|
</head>
|
||
|
|
<body style="margin: 0; padding: 0; background-color: ${BRAND.lightGray}; 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-color: ${BRAND.lightGray};">
|
||
|
|
<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 12px 0 0; 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 40px 40px;">
|
||
|
|
${content}
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
</table>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
|
||
|
|
<!-- Footer with Big Logo -->
|
||
|
|
<tr>
|
||
|
|
<td style="background-color: ${BRAND.darkBlue}; border-radius: 0 0 12px 12px; padding: 32px 40px;">
|
||
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||
|
|
<tr>
|
||
|
|
<td align="center">
|
||
|
|
<img src="${getBigLogoUrl()}" alt="Monaco Ocean Protection Challenge" width="200" height="auto" style="display: block; border: 0; max-width: 200px; margin-bottom: 16px;">
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
<tr>
|
||
|
|
<td align="center">
|
||
|
|
<p style="color: #8aa3ad; font-size: 13px; margin: 0; line-height: 1.5;">
|
||
|
|
Together for a healthier ocean
|
||
|
|
</p>
|
||
|
|
<p style="color: #6b8a94; font-size: 12px; margin: 12px 0 0 0;">
|
||
|
|
© ${new Date().getFullYear()} Monaco Ocean Protection Challenge
|
||
|
|
</p>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
</table>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
|
||
|
|
</table>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
</table>
|
||
|
|
</body>
|
||
|
|
</html>
|
||
|
|
`
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate a styled CTA button
|
||
|
|
*/
|
||
|
|
function ctaButton(url: string, text: string): string {
|
||
|
|
return `
|
||
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 24px 0;">
|
||
|
|
<tr>
|
||
|
|
<td align="center">
|
||
|
|
<a href="${url}" target="_blank" style="display: inline-block; background-color: ${BRAND.red}; color: #ffffff; text-decoration: none; padding: 16px 40px; border-radius: 8px; font-weight: 600; font-size: 16px; mso-padding-alt: 0;">
|
||
|
|
<!--[if mso]>
|
||
|
|
<i style="letter-spacing: 40px; mso-font-width: -100%; mso-text-raise: 30pt;"> </i>
|
||
|
|
<![endif]-->
|
||
|
|
<span style="mso-text-raise: 15pt;">${text}</span>
|
||
|
|
<!--[if mso]>
|
||
|
|
<i style="letter-spacing: 40px; mso-font-width: -100%;"> </i>
|
||
|
|
<![endif]-->
|
||
|
|
</a>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
</table>
|
||
|
|
`
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate styled section title
|
||
|
|
*/
|
||
|
|
function sectionTitle(text: string): string {
|
||
|
|
return `<h2 style="color: ${BRAND.darkBlue}; margin: 0 0 16px 0; font-size: 22px; font-weight: 600; line-height: 1.3;">${text}</h2>`
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate styled paragraph
|
||
|
|
*/
|
||
|
|
function paragraph(text: string): string {
|
||
|
|
return `<p style="color: ${BRAND.textDark}; margin: 0 0 16px 0; font-size: 15px; line-height: 1.6;">${text}</p>`
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate styled info box
|
||
|
|
*/
|
||
|
|
function infoBox(content: string, variant: 'warning' | 'info' | 'success' = 'info'): string {
|
||
|
|
const colors = {
|
||
|
|
warning: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' },
|
||
|
|
info: { bg: '#e0f2fe', border: '#0ea5e9', text: '#0369a1' },
|
||
|
|
success: { bg: '#dcfce7', border: '#22c55e', text: '#166534' },
|
||
|
|
}
|
||
|
|
const c = colors[variant]
|
||
|
|
return `
|
||
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||
|
|
<tr>
|
||
|
|
<td style="background-color: ${c.bg}; border-left: 4px solid ${c.border}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||
|
|
<p style="color: ${c.text}; margin: 0; font-size: 14px; font-weight: 500;">${content}</p>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
</table>
|
||
|
|
`
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate styled stat card
|
||
|
|
*/
|
||
|
|
function statCard(label: string, value: string | number): string {
|
||
|
|
return `
|
||
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||
|
|
<tr>
|
||
|
|
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px; text-align: center;">
|
||
|
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">${label}</p>
|
||
|
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 42px; font-weight: 700; line-height: 1;">${value}</p>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
</table>
|
||
|
|
`
|
||
|
|
}
|
||
|
|
|
||
|
|
// =============================================================================
|
||
|
|
// Email Templates
|
||
|
|
// =============================================================================
|
||
|
|
|
||
|
|
interface EmailTemplate {
|
||
|
|
subject: string
|
||
|
|
html: string
|
||
|
|
text: string
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate magic link email template
|
||
|
|
*/
|
||
|
|
function getMagicLinkTemplate(url: string, expiryMinutes: number = 15): EmailTemplate {
|
||
|
|
const content = `
|
||
|
|
${sectionTitle('Sign in to your account')}
|
||
|
|
${paragraph('Click the button below to securely sign in to the MOPC Platform.')}
|
||
|
|
${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 Platform',
|
||
|
|
html: getEmailWrapper(content),
|
||
|
|
text: `
|
||
|
|
Sign in to MOPC Platform
|
||
|
|
=========================
|
||
|
|
|
||
|
|
Click the link below to sign in to your account:
|
||
|
|
|
||
|
|
${url}
|
||
|
|
|
||
|
|
This link will expire in ${expiryMinutes} minutes.
|
||
|
|
|
||
|
|
If you didn't request this email, you can safely ignore it.
|
||
|
|
|
||
|
|
---
|
||
|
|
Monaco Ocean Protection Challenge
|
||
|
|
Together for a healthier ocean.
|
||
|
|
`,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate generic invitation email template (not round-specific)
|
||
|
|
*/
|
||
|
|
function getGenericInvitationTemplate(
|
||
|
|
name: string,
|
||
|
|
url: string,
|
||
|
|
role: string
|
||
|
|
): EmailTemplate {
|
||
|
|
const roleLabel = role === 'JURY_MEMBER' ? 'jury member' : role.toLowerCase().replace('_', ' ')
|
||
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||
|
|
|
||
|
|
const content = `
|
||
|
|
${sectionTitle(greeting)}
|
||
|
|
${paragraph(`You've been invited to join the Monaco Ocean Protection Challenge platform as a <strong>${roleLabel}</strong>.`)}
|
||
|
|
${paragraph('Click the button below to set up your account and get started.')}
|
||
|
|
${ctaButton(url, 'Accept Invitation')}
|
||
|
|
${infoBox('This link will expire in 24 hours.', 'info')}
|
||
|
|
`
|
||
|
|
|
||
|
|
return {
|
||
|
|
subject: "You're invited to join the MOPC Platform",
|
||
|
|
html: getEmailWrapper(content),
|
||
|
|
text: `
|
||
|
|
${greeting}
|
||
|
|
|
||
|
|
You've been invited to join the Monaco Ocean Protection Challenge platform as a ${roleLabel}.
|
||
|
|
|
||
|
|
Click the link below to set up your account and get started:
|
||
|
|
|
||
|
|
${url}
|
||
|
|
|
||
|
|
This link will expire in 24 hours.
|
||
|
|
|
||
|
|
---
|
||
|
|
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.
|
||
|
|
`,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// =============================================================================
|
||
|
|
// 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)
|
||
|
|
|
||
|
|
await transporter.sendMail({
|
||
|
|
from: defaultFrom,
|
||
|
|
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)
|
||
|
|
|
||
|
|
await transporter.sendMail({
|
||
|
|
from: defaultFrom,
|
||
|
|
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)
|
||
|
|
|
||
|
|
await transporter.sendMail({
|
||
|
|
from: defaultFrom,
|
||
|
|
to: email,
|
||
|
|
subject: template.subject,
|
||
|
|
text: template.text,
|
||
|
|
html: template.html,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Send evaluation reminder email
|
||
|
|
*/
|
||
|
|
export async function sendEvaluationReminderEmail(
|
||
|
|
email: string,
|
||
|
|
name: string | null,
|
||
|
|
pendingCount: number,
|
||
|
|
roundName: string,
|
||
|
|
deadline: string,
|
||
|
|
assignmentsUrl: string
|
||
|
|
): Promise<void> {
|
||
|
|
const template = getEvaluationReminderTemplate(
|
||
|
|
name || '',
|
||
|
|
pendingCount,
|
||
|
|
roundName,
|
||
|
|
deadline,
|
||
|
|
assignmentsUrl
|
||
|
|
)
|
||
|
|
|
||
|
|
await transporter.sendMail({
|
||
|
|
from: defaultFrom,
|
||
|
|
to: email,
|
||
|
|
subject: template.subject,
|
||
|
|
text: template.text,
|
||
|
|
html: template.html,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Send announcement email
|
||
|
|
*/
|
||
|
|
export async function sendAnnouncementEmail(
|
||
|
|
email: string,
|
||
|
|
name: string | null,
|
||
|
|
title: string,
|
||
|
|
message: string,
|
||
|
|
ctaText?: string,
|
||
|
|
ctaUrl?: string
|
||
|
|
): Promise<void> {
|
||
|
|
const template = getAnnouncementTemplate(
|
||
|
|
name || '',
|
||
|
|
title,
|
||
|
|
message,
|
||
|
|
ctaText,
|
||
|
|
ctaUrl
|
||
|
|
)
|
||
|
|
|
||
|
|
await transporter.sendMail({
|
||
|
|
from: defaultFrom,
|
||
|
|
to: email,
|
||
|
|
subject: template.subject,
|
||
|
|
text: template.text,
|
||
|
|
html: template.html,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Send a test email to verify SMTP configuration
|
||
|
|
*/
|
||
|
|
export async function sendTestEmail(toEmail: string): Promise<boolean> {
|
||
|
|
try {
|
||
|
|
const content = `
|
||
|
|
${sectionTitle('Test Email')}
|
||
|
|
${paragraph('This is a test email from the MOPC Platform.')}
|
||
|
|
${infoBox('If you received this, your email configuration is working correctly!', 'success')}
|
||
|
|
<p style="color: ${BRAND.textMuted}; margin: 20px 0 0 0; font-size: 13px; text-align: center;">
|
||
|
|
Sent at ${new Date().toISOString()}
|
||
|
|
</p>
|
||
|
|
`
|
||
|
|
|
||
|
|
await transporter.sendMail({
|
||
|
|
from: defaultFrom,
|
||
|
|
to: toEmail,
|
||
|
|
subject: 'MOPC Platform - Test Email',
|
||
|
|
text: 'This is a test email from the MOPC Platform. 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 {
|
||
|
|
await transporter.verify()
|
||
|
|
return true
|
||
|
|
} catch {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
}
|