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

@@ -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 {

View File

@@ -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 {

View File

@@ -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)
}),

View File

@@ -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')

View File

@@ -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)
})

View File

@@ -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

View File

@@ -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 {

View File

@@ -60,7 +60,7 @@ export async function sendManualReminders(roundId: string): Promise<ReminderResu
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'
const deadlineStr = round.windowCloseAt
? round.windowCloseAt.toLocaleDateString('en-US', {
weekday: 'long',
@@ -217,7 +217,7 @@ async function sendRemindersForRound(
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'
const deadlineStr = round.windowCloseAt.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',

View File

@@ -6,7 +6,7 @@
*/
import { prisma } from '@/lib/prisma'
import { sendStyledNotificationEmail } from '@/lib/email'
import { sendStyledNotificationEmail, ensureAbsoluteUrl } from '@/lib/email'
// Notification priority levels
export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent'
@@ -427,8 +427,7 @@ async function maybeSendEmailWithSetting(
}
// Ensure linkUrl is absolute for emails (relative paths break in email clients)
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
const absoluteLinkUrl = linkUrl && linkUrl.startsWith('/') ? `${baseUrl}${linkUrl}` : linkUrl
const absoluteLinkUrl = ensureAbsoluteUrl(linkUrl)
await sendStyledNotificationEmail(
user.email,

View File

@@ -128,22 +128,24 @@ async function sendEmailNotification(
case 'JURY_INVITATION':
await sendJuryInvitationEmail(
email,
name,
data.inviteUrl,
data.programName,
data.roundName
)
return { success: true }
case 'EVALUATION_REMINDER':
case 'EVALUATION_REMINDER': {
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
await sendEvaluationReminderEmail(
email,
name,
parseInt(data.pendingCount || '0'),
data.roundName || 'Current Round',
data.deadline || 'Soon',
data.assignmentsUrl || `${process.env.NEXTAUTH_URL}/jury/assignments`
data.assignmentsUrl || `${baseUrl}/jury/assignments`
)
return { success: true }
}
case 'ANNOUNCEMENT':
await sendAnnouncementEmail(