Use DB settings for SMTP and unify email design to all-white
Build and Push Docker Image / build (push) Successful in 7m43s Details

- Read SMTP settings from database (admin panel) with env var fallback
- Cache transporter and rebuild when settings change
- Remove dark blue footer from emails; use single white content box
  with logo and tagline at the bottom separated by a subtle border

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-01-31 11:49:35 +01:00
parent 43680d4173
commit 5aedade41d
1 changed files with 85 additions and 39 deletions

View File

@ -1,17 +1,57 @@
import nodemailer from 'nodemailer'
import type { Transporter } from 'nodemailer'
import { prisma } from '@/lib/prisma'
// 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,
},
})
// Cached transporter and config hash to detect changes
let cachedTransporter: Transporter | null = null
let cachedConfigHash = ''
let cachedFrom = ''
// Default sender
/**
* 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: {
key: { in: ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'email_from'] },
},
select: { key: true, value: true },
})
const db: Record<string, string> = {}
for (const s of dbSettings) {
db[s.key] = s.value
}
// DB settings take priority, env vars as fallback
const host = db.smtp_host || process.env.SMTP_HOST || 'localhost'
const port = db.smtp_port || process.env.SMTP_PORT || '587'
const user = db.smtp_user || process.env.SMTP_USER || ''
const pass = db.smtp_password || process.env.SMTP_PASS || ''
const from = db.email_from || process.env.EMAIL_FROM || 'MOPC Platform <noreply@monaco-opc.com>'
// 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
const defaultFrom = process.env.EMAIL_FROM || 'MOPC Platform <noreply@monaco-opc.com>'
// =============================================================================
@ -69,7 +109,7 @@ function getEmailWrapper(content: string): string {
<!-- 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);">
<td style="background-color: ${BRAND.white}; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<!-- Small Logo Header -->
<tr>
@ -79,31 +119,30 @@ function getEmailWrapper(content: string): string {
</tr>
<!-- Email Content -->
<tr>
<td style="padding: 0 40px 40px 40px;">
<td style="padding: 0 40px 32px 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">
<!-- Footer -->
<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;">
&copy; ${new Date().getFullYear()} Monaco Ocean Protection Challenge
</p>
<td style="padding: 0 40px 32px 40px; border-top: 1px solid #e5e7eb;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" style="padding-top: 24px;">
<img src="${getBigLogoUrl()}" alt="Monaco Ocean Protection Challenge" width="180" height="auto" style="display: block; border: 0; max-width: 180px; margin-bottom: 12px;">
</td>
</tr>
<tr>
<td align="center">
<p style="color: ${BRAND.textMuted}; font-size: 13px; margin: 0; line-height: 1.5;">
Together for a healthier ocean
</p>
<p style="color: ${BRAND.textMuted}; font-size: 12px; margin: 8px 0 0 0;">
&copy; ${new Date().getFullYear()} Monaco Ocean Protection Challenge
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
@ -444,9 +483,10 @@ export async function sendMagicLinkEmail(
): Promise<void> {
const expiryMinutes = parseInt(process.env.MAGIC_LINK_EXPIRY || '900') / 60
const template = getMagicLinkTemplate(url, expiryMinutes)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from: defaultFrom,
from,
to: email,
subject: template.subject,
text: template.text,
@ -464,9 +504,10 @@ export async function sendInvitationEmail(
role: string
): Promise<void> {
const template = getGenericInvitationTemplate(name || '', url, role)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from: defaultFrom,
from,
to: email,
subject: template.subject,
text: template.text,
@ -484,9 +525,10 @@ export async function sendJuryInvitationEmail(
roundName: string
): Promise<void> {
const template = getJuryInvitationTemplate(name || '', url, roundName)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from: defaultFrom,
from,
to: email,
subject: template.subject,
text: template.text,
@ -512,9 +554,10 @@ export async function sendEvaluationReminderEmail(
deadline,
assignmentsUrl
)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from: defaultFrom,
from,
to: email,
subject: template.subject,
text: template.text,
@ -540,9 +583,10 @@ export async function sendAnnouncementEmail(
ctaText,
ctaUrl
)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from: defaultFrom,
from,
to: email,
subject: template.subject,
text: template.text,
@ -563,9 +607,10 @@ export async function sendTestEmail(toEmail: string): Promise<boolean> {
Sent at ${new Date().toISOString()}
</p>
`
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from: defaultFrom,
from,
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.',
@ -582,6 +627,7 @@ export async function sendTestEmail(toEmail: string): Promise<boolean> {
*/
export async function verifyEmailConnection(): Promise<boolean> {
try {
const { transporter } = await getTransporter()
await transporter.verify()
return true
} catch {