Use DB settings for SMTP and unify email design to all-white
Build and Push Docker Image / build (push) Successful in 7m43s
Details
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:
parent
43680d4173
commit
5aedade41d
124
src/lib/email.ts
124
src/lib/email.ts
|
|
@ -1,17 +1,57 @@
|
||||||
import nodemailer from 'nodemailer'
|
import nodemailer from 'nodemailer'
|
||||||
|
import type { Transporter } from 'nodemailer'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
// Create reusable transporter
|
// Cached transporter and config hash to detect changes
|
||||||
const transporter = nodemailer.createTransport({
|
let cachedTransporter: Transporter | null = null
|
||||||
host: process.env.SMTP_HOST || 'localhost',
|
let cachedConfigHash = ''
|
||||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
let cachedFrom = ''
|
||||||
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
|
/**
|
||||||
|
* 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>'
|
const defaultFrom = process.env.EMAIL_FROM || 'MOPC Platform <noreply@monaco-opc.com>'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -69,7 +109,7 @@ function getEmailWrapper(content: string): string {
|
||||||
|
|
||||||
<!-- Content Box -->
|
<!-- Content Box -->
|
||||||
<tr>
|
<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">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
<!-- Small Logo Header -->
|
<!-- Small Logo Header -->
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -79,31 +119,30 @@ function getEmailWrapper(content: string): string {
|
||||||
</tr>
|
</tr>
|
||||||
<!-- Email Content -->
|
<!-- Email Content -->
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 0 40px 40px 40px;">
|
<td style="padding: 0 40px 32px 40px;">
|
||||||
${content}
|
${content}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
<!-- Footer -->
|
||||||
</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>
|
<tr>
|
||||||
<td align="center">
|
<td style="padding: 0 40px 32px 40px; border-top: 1px solid #e5e7eb;">
|
||||||
<img src="${getBigLogoUrl()}" alt="Monaco Ocean Protection Challenge" width="200" height="auto" style="display: block; border: 0; max-width: 200px; margin-bottom: 16px;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
</td>
|
<tr>
|
||||||
</tr>
|
<td align="center" style="padding-top: 24px;">
|
||||||
<tr>
|
<img src="${getBigLogoUrl()}" alt="Monaco Ocean Protection Challenge" width="180" height="auto" style="display: block; border: 0; max-width: 180px; margin-bottom: 12px;">
|
||||||
<td align="center">
|
</td>
|
||||||
<p style="color: #8aa3ad; font-size: 13px; margin: 0; line-height: 1.5;">
|
</tr>
|
||||||
Together for a healthier ocean
|
<tr>
|
||||||
</p>
|
<td align="center">
|
||||||
<p style="color: #6b8a94; font-size: 12px; margin: 12px 0 0 0;">
|
<p style="color: ${BRAND.textMuted}; font-size: 13px; margin: 0; line-height: 1.5;">
|
||||||
© ${new Date().getFullYear()} Monaco Ocean Protection Challenge
|
Together for a healthier ocean
|
||||||
</p>
|
</p>
|
||||||
|
<p style="color: ${BRAND.textMuted}; font-size: 12px; margin: 8px 0 0 0;">
|
||||||
|
© ${new Date().getFullYear()} Monaco Ocean Protection Challenge
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -444,9 +483,10 @@ export async function sendMagicLinkEmail(
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const expiryMinutes = parseInt(process.env.MAGIC_LINK_EXPIRY || '900') / 60
|
const expiryMinutes = parseInt(process.env.MAGIC_LINK_EXPIRY || '900') / 60
|
||||||
const template = getMagicLinkTemplate(url, expiryMinutes)
|
const template = getMagicLinkTemplate(url, expiryMinutes)
|
||||||
|
const { transporter, from } = await getTransporter()
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: defaultFrom,
|
from,
|
||||||
to: email,
|
to: email,
|
||||||
subject: template.subject,
|
subject: template.subject,
|
||||||
text: template.text,
|
text: template.text,
|
||||||
|
|
@ -464,9 +504,10 @@ export async function sendInvitationEmail(
|
||||||
role: string
|
role: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const template = getGenericInvitationTemplate(name || '', url, role)
|
const template = getGenericInvitationTemplate(name || '', url, role)
|
||||||
|
const { transporter, from } = await getTransporter()
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: defaultFrom,
|
from,
|
||||||
to: email,
|
to: email,
|
||||||
subject: template.subject,
|
subject: template.subject,
|
||||||
text: template.text,
|
text: template.text,
|
||||||
|
|
@ -484,9 +525,10 @@ export async function sendJuryInvitationEmail(
|
||||||
roundName: string
|
roundName: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const template = getJuryInvitationTemplate(name || '', url, roundName)
|
const template = getJuryInvitationTemplate(name || '', url, roundName)
|
||||||
|
const { transporter, from } = await getTransporter()
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: defaultFrom,
|
from,
|
||||||
to: email,
|
to: email,
|
||||||
subject: template.subject,
|
subject: template.subject,
|
||||||
text: template.text,
|
text: template.text,
|
||||||
|
|
@ -512,9 +554,10 @@ export async function sendEvaluationReminderEmail(
|
||||||
deadline,
|
deadline,
|
||||||
assignmentsUrl
|
assignmentsUrl
|
||||||
)
|
)
|
||||||
|
const { transporter, from } = await getTransporter()
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: defaultFrom,
|
from,
|
||||||
to: email,
|
to: email,
|
||||||
subject: template.subject,
|
subject: template.subject,
|
||||||
text: template.text,
|
text: template.text,
|
||||||
|
|
@ -540,9 +583,10 @@ export async function sendAnnouncementEmail(
|
||||||
ctaText,
|
ctaText,
|
||||||
ctaUrl
|
ctaUrl
|
||||||
)
|
)
|
||||||
|
const { transporter, from } = await getTransporter()
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: defaultFrom,
|
from,
|
||||||
to: email,
|
to: email,
|
||||||
subject: template.subject,
|
subject: template.subject,
|
||||||
text: template.text,
|
text: template.text,
|
||||||
|
|
@ -563,9 +607,10 @@ export async function sendTestEmail(toEmail: string): Promise<boolean> {
|
||||||
Sent at ${new Date().toISOString()}
|
Sent at ${new Date().toISOString()}
|
||||||
</p>
|
</p>
|
||||||
`
|
`
|
||||||
|
const { transporter, from } = await getTransporter()
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: defaultFrom,
|
from,
|
||||||
to: toEmail,
|
to: toEmail,
|
||||||
subject: 'MOPC Platform - Test Email',
|
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.',
|
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> {
|
export async function verifyEmailConnection(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
const { transporter } = await getTransporter()
|
||||||
await transporter.verify()
|
await transporter.verify()
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue