- NextAuth session/jwt callbacks no longer return null (the client useSession() hook chokes on a null session, crashing the app on sign-out for deactivated/refreshed sessions) — fixes #57 - UserMenu/Header sign-out handlers close the Manage Users overlay and popover before calling signOut(), and no longer let a rejected signOut() promise go unhandled - Added an app-level error boundary as a safety net for any remaining client render errors - Registration and forgot-password flows now tell the user honestly when the confirmation/reset email failed to send instead of always claiming success — related to #56 - lib/email.ts throws a clear, actionable error when neither SMTP nor Resend is configured, instead of a cryptic SDK error - Documented required SMTP/Resend env vars in DEPLOYMENT_CHECKLIST.md (were missing entirely, which is why no confirmation/reset emails were ever sent in deployed environments) — fixes #56
286 lines
9.9 KiB
TypeScript
286 lines
9.9 KiB
TypeScript
import crypto from 'crypto';
|
|
import nodemailer from 'nodemailer';
|
|
import { Resend } from 'resend';
|
|
|
|
type EmailPayload = {
|
|
to: string;
|
|
subject: string;
|
|
html: string;
|
|
text?: string;
|
|
};
|
|
|
|
const resend = new Resend(process.env.RESEND_API_KEY);
|
|
|
|
function getBaseUrl(): string {
|
|
return process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001';
|
|
}
|
|
|
|
function formatFromAddress(fromEmail: string, fromName?: string): string {
|
|
if (!fromName) {
|
|
return fromEmail;
|
|
}
|
|
return `${fromName} <${fromEmail}>`;
|
|
}
|
|
|
|
function getSmtpPort(): number {
|
|
const port = Number(process.env.SMTP_PORT || '465');
|
|
return Number.isNaN(port) ? 465 : port;
|
|
}
|
|
|
|
function getSmtpSecure(): boolean {
|
|
return (process.env.SMTP_SECURE || 'true').toLowerCase() === 'true';
|
|
}
|
|
|
|
function hasSmtpConfig(): boolean {
|
|
return Boolean(
|
|
process.env.SMTP_HOST &&
|
|
process.env.SMTP_USER &&
|
|
process.env.SMTP_PASS
|
|
);
|
|
}
|
|
|
|
async function sendViaSmtp(payload: EmailPayload): Promise<void> {
|
|
const smtpHost = process.env.SMTP_HOST;
|
|
const smtpUser = process.env.SMTP_USER;
|
|
const smtpPass = process.env.SMTP_PASS;
|
|
|
|
if (!smtpHost || !smtpUser || !smtpPass) {
|
|
throw new Error('Missing SMTP configuration (SMTP_HOST, SMTP_USER, SMTP_PASS)');
|
|
}
|
|
|
|
const smtpFromEmail = process.env.SMTP_FROM_EMAIL || smtpUser;
|
|
const smtpFromName = process.env.SMTP_FROM_NAME;
|
|
const smtpReplyTo = process.env.SMTP_REPLY_TO || smtpFromEmail;
|
|
|
|
const transporter = nodemailer.createTransport({
|
|
host: smtpHost,
|
|
port: getSmtpPort(),
|
|
secure: getSmtpSecure(),
|
|
auth: {
|
|
user: smtpUser,
|
|
pass: smtpPass,
|
|
},
|
|
});
|
|
|
|
await transporter.sendMail({
|
|
from: formatFromAddress(smtpFromEmail, smtpFromName),
|
|
to: payload.to,
|
|
replyTo: smtpReplyTo,
|
|
subject: payload.subject,
|
|
text: payload.text,
|
|
html: payload.html,
|
|
});
|
|
}
|
|
|
|
async function sendViaResend(payload: EmailPayload): Promise<void> {
|
|
if (!process.env.RESEND_API_KEY) {
|
|
throw new Error(
|
|
'Missing Resend configuration (RESEND_API_KEY). Set SMTP_HOST/SMTP_USER/SMTP_PASS ' +
|
|
'or RESEND_API_KEY so password reset / email verification can actually send mail.'
|
|
);
|
|
}
|
|
|
|
const fromEmail = process.env.RESEND_FROM_EMAIL || process.env.SMTP_FROM_EMAIL || 'onboarding@resend.dev';
|
|
const fromName = process.env.RESEND_FROM_NAME || process.env.SMTP_FROM_NAME;
|
|
const replyTo = process.env.RESEND_REPLY_TO || process.env.SMTP_REPLY_TO || fromEmail;
|
|
|
|
const result = await resend.emails.send({
|
|
from: formatFromAddress(fromEmail, fromName),
|
|
to: payload.to,
|
|
replyTo,
|
|
subject: payload.subject,
|
|
text: payload.text,
|
|
html: payload.html,
|
|
});
|
|
|
|
if (result.error) {
|
|
throw new Error(`Resend API error: ${result.error.message || 'Unknown error'}`);
|
|
}
|
|
}
|
|
|
|
async function sendEmailWithFallback(payload: EmailPayload): Promise<void> {
|
|
const provider = (process.env.EMAIL_PROVIDER || 'smtp').toLowerCase();
|
|
|
|
if (provider == 'resend') {
|
|
await sendViaResend(payload);
|
|
return;
|
|
}
|
|
|
|
if (!hasSmtpConfig()) {
|
|
await sendViaResend(payload);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await sendViaSmtp(payload);
|
|
} catch (smtpError) {
|
|
console.error('[EMAIL] SMTP send failed, trying Resend fallback:', smtpError);
|
|
await sendViaResend(payload);
|
|
}
|
|
}
|
|
|
|
export function generateEmailConfirmationToken(): string {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
export async function sendEmailConfirmation(
|
|
email: string,
|
|
name: string,
|
|
token: string
|
|
): Promise<void> {
|
|
const confirmationUrl = `${getBaseUrl()}/api/auth/verify-email?token=${token}`;
|
|
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Confirm your email</title>
|
|
</head>
|
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
<div style="background-color: #f8f9fa; padding: 30px; border-radius: 8px;">
|
|
<h1 style="color: #2563eb; margin-top: 0;">Confirm your email address</h1>
|
|
<p>Hi ${name},</p>
|
|
<p>Thank you for signing up! Please confirm your email address by clicking the button below:</p>
|
|
<div style="text-align: center; margin: 30px 0;">
|
|
<a href="${confirmationUrl}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: bold;">Confirm Email Address</a>
|
|
</div>
|
|
<p>Or copy and paste this link into your browser:</p>
|
|
<p style="word-break: break-all; color: #666; font-size: 14px;">${confirmationUrl}</p>
|
|
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;">
|
|
If you didn't create an account, you can safely ignore this email.
|
|
</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
const text = `Hi ${name},
|
|
|
|
Thank you for signing up. Please confirm your email address:
|
|
${confirmationUrl}
|
|
|
|
If you did not create this account, you can safely ignore this email.`;
|
|
|
|
await sendEmailWithFallback({
|
|
to: email,
|
|
subject: 'Confirm your email address',
|
|
html,
|
|
text,
|
|
});
|
|
}
|
|
|
|
export async function sendEmailConfirmationResend(
|
|
email: string,
|
|
name: string,
|
|
token: string
|
|
): Promise<void> {
|
|
const confirmationUrl = `${getBaseUrl()}/api/auth/verify-email?token=${token}`;
|
|
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Confirm your email</title>
|
|
</head>
|
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
<div style="background-color: #f8f9fa; padding: 30px; border-radius: 8px;">
|
|
<h1 style="color: #2563eb; margin-top: 0;">Confirm your email address</h1>
|
|
<p>Hi ${name},</p>
|
|
<p>You requested a new confirmation email. Please confirm your email address by clicking the button below:</p>
|
|
<div style="text-align: center; margin: 30px 0;">
|
|
<a href="${confirmationUrl}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: bold;">Confirm Email Address</a>
|
|
</div>
|
|
<p>Or copy and paste this link into your browser:</p>
|
|
<p style="word-break: break-all; color: #666; font-size: 14px;">${confirmationUrl}</p>
|
|
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;">
|
|
This link will expire in 24 hours. If you didn't request this email, you can safely ignore it.
|
|
</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
const text = `Hi ${name},
|
|
|
|
You requested a new confirmation email. Please confirm your email address:
|
|
${confirmationUrl}
|
|
|
|
This link expires in 24 hours. If you did not request this, you can ignore this email.`;
|
|
|
|
await sendEmailWithFallback({
|
|
to: email,
|
|
subject: 'Confirm your email address',
|
|
html,
|
|
text,
|
|
});
|
|
}
|
|
|
|
export function generatePasswordResetToken(): string {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
export async function sendPasswordResetEmail(
|
|
email: string,
|
|
name: string,
|
|
token: string
|
|
): Promise<void> {
|
|
const resetUrl = `${getBaseUrl()}/reset-password?token=${token}`;
|
|
|
|
const text = `Hi ${name},
|
|
|
|
You requested to reset your password. Click the link below to create a new password:
|
|
|
|
${resetUrl}
|
|
|
|
This link will expire in 1 hour.
|
|
|
|
If you didn't request a password reset, you can safely ignore this email.
|
|
|
|
Best regards,
|
|
PunimTag Viewer Team`;
|
|
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
<title>Reset your password</title>
|
|
</head>
|
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #ffffff;">
|
|
<div style="background-color: #f8f9fa; padding: 30px; border-radius: 8px; border: 1px solid #e5e7eb;">
|
|
<h1 style="color: #2563eb; margin-top: 0; font-size: 24px;">Reset your password</h1>
|
|
<p style="font-size: 16px;">Hi ${name},</p>
|
|
<p style="font-size: 16px;">You requested to reset your password for your PunimTag Viewer account. Click the button below to create a new password:</p>
|
|
<div style="text-align: center; margin: 30px 0;">
|
|
<a href="${resetUrl}" style="background-color: #2563eb; color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: bold; font-size: 16px;">Reset Password</a>
|
|
</div>
|
|
<p style="font-size: 14px; color: #666;">Or copy and paste this link into your browser:</p>
|
|
<p style="word-break: break-all; color: #666; font-size: 14px; background-color: #ffffff; padding: 10px; border-radius: 4px; border: 1px solid #e5e7eb;">${resetUrl}</p>
|
|
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;">
|
|
<strong>Important:</strong> This link will expire in 1 hour for security reasons.
|
|
</p>
|
|
<p style="color: #6b7280; font-size: 14px;">
|
|
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
|
|
</p>
|
|
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #9ca3af; font-size: 12px;">
|
|
This is an automated message from PunimTag Viewer. Please do not reply to this email.
|
|
</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
await sendEmailWithFallback({
|
|
to: email,
|
|
subject: 'Reset your password - PunimTag Viewer',
|
|
text,
|
|
html,
|
|
});
|
|
}
|