From c1b3a6ade3672aece7cd4402771566d580cfed2d Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 23 Feb 2026 14:27:58 +0100 Subject: [PATCH] Fix email links broken in Outlook and standardize all email URLs - 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 --- src/components/ui/tooltip.tsx | 2 +- src/lib/email.ts | 65 ++++++++++++++++----- src/server/routers/applicant.ts | 2 +- src/server/routers/message.ts | 2 +- src/server/routers/project-pool.ts | 2 +- src/server/routers/project.ts | 2 +- src/server/routers/round.ts | 2 +- src/server/routers/user.ts | 8 +-- src/server/services/email-digest.ts | 2 +- src/server/services/evaluation-reminders.ts | 4 +- src/server/services/in-app-notification.ts | 5 +- src/server/services/notification.ts | 8 ++- 12 files changed, 71 insertions(+), 33 deletions(-) diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 9f22028..489be8c 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - 'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', + 'z-50 overflow-hidden rounded-md border bg-white px-3 py-1.5 text-xs text-gray-900 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', className )} {...props} diff --git a/src/lib/email.ts b/src/lib/email.ts index 36809bc..d3b6fc6 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -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 ' +// ============================================================================= +// 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 ` + + +
- - - ${text} - - + + + + +
+ + ${text} + +
+
+

+ If the button doesn't work: ${safeUrl} +

@@ -1588,13 +1620,18 @@ export async function sendStyledNotificationEmail( context: NotificationEmailContext, subjectOverride?: string ): Promise { + // 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 { - const template = getNotificationEmailTemplate(name, subject, body, linkUrl) + const template = getNotificationEmailTemplate(name, subject, body, ensureAbsoluteUrl(linkUrl)) const { transporter, from } = await getTransporter() await transporter.sendMail({ diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index 271575c..b314784 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -826,7 +826,7 @@ export const applicantRouter = router({ } const teamLeadName = ctx.user.name?.trim() || 'A team lead' - const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' + const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com' const requiresAccountSetup = user.status !== 'ACTIVE' try { diff --git a/src/server/routers/message.ts b/src/server/routers/message.ts index 8471c3a..f595e97 100644 --- a/src/server/routers/message.ts +++ b/src/server/routers/message.ts @@ -75,7 +75,7 @@ export const messageRouter = router({ select: { id: true, name: true, email: true }, }) - const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com' + const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com' for (const user of users) { try { diff --git a/src/server/routers/project-pool.ts b/src/server/routers/project-pool.ts index b2d2594..112f8f4 100644 --- a/src/server/routers/project-pool.ts +++ b/src/server/routers/project-pool.ts @@ -55,7 +55,7 @@ async function sendRoundEntryEmails( `Your project has entered: ${roundName}`, `Your project "${project.title}" has been added to the round "${roundName}" in the Monaco Ocean Protection Challenge. You will receive further instructions as the round progresses.`, 'View Your Dashboard', - `${process.env.NEXTAUTH_URL || 'https://monaco-opc.com'}/dashboard`, + `${process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'}/dashboard`, ).catch((err) => { console.error(`[round-entry-email] Failed to send to ${email}:`, err) }), diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index e4bfcda..13e41b6 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -623,7 +623,7 @@ export const projectRouter = router({ // Send invite emails outside the transaction (never fail project creation) if (membersToInvite.length > 0) { - const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com' + const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com' for (const member of membersToInvite) { try { const token = crypto.randomBytes(32).toString('hex') diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts index e5e4433..bad921b 100644 --- a/src/server/routers/round.ts +++ b/src/server/routers/round.ts @@ -495,7 +495,7 @@ export const roundRouter = router({ `Your project has advanced to: ${targetRound.name}`, `Congratulations! Your project "${project.title}" has advanced from "${currentRound.name}" to "${targetRound.name}" in the Monaco Ocean Protection Challenge.`, 'View Your Dashboard', - `${process.env.NEXTAUTH_URL || 'https://monaco-opc.com'}/dashboard`, + `${process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'}/dashboard`, ).catch((err) => { console.error(`[advanceProjects] notifyOnAdvance email failed for ${email}:`, err) }) diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index 6ce205d..5e98a2b 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -870,7 +870,7 @@ export const userRouter = router({ const emailErrors: string[] = [] if (input.sendInvitation) { - const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' + const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com' const expiryHours = await getInviteExpiryHours(ctx.prisma) const expiryMs = expiryHours * 60 * 60 * 1000 @@ -1025,7 +1025,7 @@ export const userRouter = router({ }, }) - const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' + const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com' const inviteUrl = `${baseUrl}/accept-invite?token=${token}` // Send invitation email @@ -1074,7 +1074,7 @@ export const userRouter = router({ return { sent: 0, skipped: input.userIds.length } } - const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' + const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com' const expiryHours = await getInviteExpiryHours(ctx.prisma) const expiryMs = expiryHours * 60 * 60 * 1000 let sent = 0 @@ -1478,7 +1478,7 @@ export const userRouter = router({ }) // Generate a callback URL for the magic link - const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' + const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com' const callbackUrl = `${baseUrl}/set-password` // We don't send the email here - the user will use the magic link form diff --git a/src/server/services/email-digest.ts b/src/server/services/email-digest.ts index 3a017bc..e6dba0d 100644 --- a/src/server/services/email-digest.ts +++ b/src/server/services/email-digest.ts @@ -54,7 +54,7 @@ export async function processDigests( ? JSON.parse(sectionsSetting.value) : ['pending_evaluations', 'upcoming_deadlines', 'new_assignments', 'unread_notifications'] - const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com' + const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com' for (const user of users) { try { diff --git a/src/server/services/evaluation-reminders.ts b/src/server/services/evaluation-reminders.ts index 260bba3..fa7d2cb 100644 --- a/src/server/services/evaluation-reminders.ts +++ b/src/server/services/evaluation-reminders.ts @@ -60,7 +60,7 @@ export async function sendManualReminders(roundId: string): Promise