Fix email links broken in Outlook and standardize all email URLs
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m33s

- Rewrite ctaButton to use td-background pattern (works in all clients
  including Outlook, Gmail, Yahoo, Apple Mail) instead of VML/conditional
  comments that broke link clicking in Outlook desktop
- Add plaintext fallback URL below every CTA button so users always have
  a working link even if the button fails
- Add getBaseUrl() and ensureAbsoluteUrl() helpers in email.ts to
  guarantee all email links are absolute https:// URLs
- Apply ensureAbsoluteUrl safety net in sendStyledNotificationEmail and
  sendNotificationEmail so relative paths can never reach email templates
- Standardize all NEXTAUTH_URL fallbacks to https://portal.monaco-opc.com
  (was inconsistently http://localhost:3000 or https://monaco-opc.com)
- Fix legacy notification.ts: wrong argument order in
  sendJuryInvitationEmail (URL was passed as name parameter)
- Fix legacy notification.ts: missing NEXTAUTH_URL fallback for
  evaluation reminder URL construction
- Change tooltip styling from red bg to white bg with black text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 14:27:58 +01:00
parent f26ee3f076
commit c1b3a6ade3
12 changed files with 71 additions and 33 deletions

View File

@@ -62,6 +62,29 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
// Legacy references for backward compat — default sender from env
const defaultFrom = process.env.EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.com>'
// =============================================================================
// Helpers
// =============================================================================
/**
* Get the base URL for links in emails.
* Uses NEXTAUTH_URL with a safe production fallback.
*/
export function getBaseUrl(): string {
return process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
}
/**
* Ensure a URL is absolute (has protocol + host).
* Converts relative paths like "/jury/competitions" to full URLs.
*/
export function ensureAbsoluteUrl(url: string | undefined): string | undefined {
if (!url) return undefined
if (url.startsWith('http://') || url.startsWith('https://')) return url
if (url.startsWith('/')) return `${getBaseUrl()}${url}`
return `${getBaseUrl()}/${url}`
}
// =============================================================================
// Brand Colors & Logo URLs
// =============================================================================
@@ -170,19 +193,28 @@ function getEmailWrapper(content: string): string {
* Generate a styled CTA button
*/
function ctaButton(url: string, text: string): string {
// Ensure URL is always absolute for email clients
const safeUrl = ensureAbsoluteUrl(url) || url
return `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 24px 0;">
<tr>
<td align="center">
<a href="${url}" target="_blank" style="display: inline-block; background-color: ${BRAND.red}; color: #ffffff; text-decoration: none; padding: 16px 40px; border-radius: 8px; font-weight: 600; font-size: 16px; mso-padding-alt: 0;">
<!--[if mso]>
<i style="letter-spacing: 40px; mso-font-width: -100%; mso-text-raise: 30pt;">&nbsp;</i>
<![endif]-->
<span style="mso-text-raise: 15pt;">${text}</span>
<!--[if mso]>
<i style="letter-spacing: 40px; mso-font-width: -100%;">&nbsp;</i>
<![endif]-->
</a>
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center" bgcolor="${BRAND.red}" style="background-color: ${BRAND.red}; border-radius: 8px; mso-border-alt: none;">
<a href="${safeUrl}" target="_blank" style="display: inline-block; padding: 16px 40px; color: #ffffff; text-decoration: none; font-weight: 600; font-size: 16px; font-family: Helvetica, Arial, sans-serif; mso-line-height-rule: exactly; line-height: 20px;">
${text}
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding-top: 8px;">
<p style="margin: 0; font-size: 12px; color: ${BRAND.textMuted};">
If the button doesn't work: <a href="${safeUrl}" target="_blank" style="color: ${BRAND.teal}; word-break: break-all;">${safeUrl}</a>
</p>
</td>
</tr>
</table>
@@ -1588,13 +1620,18 @@ export async function sendStyledNotificationEmail(
context: NotificationEmailContext,
subjectOverride?: string
): Promise<void> {
// Safety net: always ensure linkUrl is absolute before passing to templates
const safeContext = {
...context,
linkUrl: ensureAbsoluteUrl(context.linkUrl),
}
const templateGenerator = NOTIFICATION_EMAIL_TEMPLATES[type]
let template: EmailTemplate
if (templateGenerator) {
// Use styled template
template = templateGenerator({ ...context, name })
template = templateGenerator({ ...safeContext, name })
// Apply subject override if provided
if (subjectOverride) {
template.subject = subjectOverride
@@ -1603,9 +1640,9 @@ export async function sendStyledNotificationEmail(
// Fall back to generic template
template = getNotificationEmailTemplate(
name,
subjectOverride || context.title,
context.message,
context.linkUrl
subjectOverride || safeContext.title,
safeContext.message,
safeContext.linkUrl
)
}
@@ -1896,7 +1933,7 @@ export async function sendNotificationEmail(
body: string,
linkUrl?: string
): Promise<void> {
const template = getNotificationEmailTemplate(name, subject, body, linkUrl)
const template = getNotificationEmailTemplate(name, subject, body, ensureAbsoluteUrl(linkUrl))
const { transporter, from } = await getTransporter()
await transporter.sendMail({