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
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:
@@ -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}
|
||||
|
||||
@@ -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;"> </i>
|
||||
<![endif]-->
|
||||
<span style="mso-text-raise: 15pt;">${text}</span>
|
||||
<!--[if mso]>
|
||||
<i style="letter-spacing: 40px; mso-font-width: -100%;"> </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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user