fix: localise auth emails, repair verify link, recover from expired
Wave-3 user feedback (points 1–3): - Email templates: extract de/en/fr/es/it copy into emailTemplates.ts and pick the user's language. Signup forwards navigator.language and the API also respects Accept-Language; resend/reset reuse user.language. - Verify link: the email button now points at the client page instead of the API GET handler, so corporate inbox scanners (M365 Safe Links etc.) can no longer pre-fetch and silently consume the one-shot token. The client page requires a real click before calling PATCH /api/auth/ verify-email. The legacy GET handler now just redirects to the client page so old in-flight emails keep working. - Code expired UX: when users land via an expired-link redirect we no longer leave them with a blank email field — the page exposes an email input so the resend button has something to act on. The verify-email redirect also forwards the email param. Misc: tsconfig migrated to moduleResolution=bundler and dropped downlevelIteration to clear the TS 7.0 deprecation errors. v1.101.0 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
ffe2f46c64
commit
d3a40e32a0
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.100.0",
|
||||
"version": "1.101.0",
|
||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@ -20,6 +20,7 @@ export async function POST(request: NextRequest) {
|
||||
id: true,
|
||||
emailVerified: true,
|
||||
emailVerificationExpires: true,
|
||||
language: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -57,7 +58,7 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
try {
|
||||
await sendVerificationEmail(email, code, token);
|
||||
await sendVerificationEmail(email, code, token, user.language);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to resend verification email:', emailError);
|
||||
return NextResponse.json(
|
||||
|
||||
@ -30,7 +30,7 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
await sendPasswordResetEmail(email, resetToken);
|
||||
await sendPasswordResetEmail(email, resetToken, (user as any).language);
|
||||
|
||||
return NextResponse.json({ message: 'If an account exists, a reset link has been sent.' });
|
||||
}
|
||||
|
||||
@ -3,12 +3,18 @@ export const dynamic = 'force-dynamic';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { hash } from 'bcryptjs';
|
||||
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
|
||||
import { languageFromAcceptLanguage } from '@/lib/emailTemplates';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { name, email, password } = await request.json();
|
||||
const body = await request.json();
|
||||
const { name, email, password } = body;
|
||||
// Prefer the explicit language from the form (filled in by the signup
|
||||
// page using navigator.language) and fall back to Accept-Language.
|
||||
const language = body.language
|
||||
|| languageFromAcceptLanguage(request.headers.get('accept-language'));
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
@ -42,11 +48,12 @@ export async function POST(request: NextRequest) {
|
||||
emailVerificationCode: code,
|
||||
emailVerificationToken: token,
|
||||
emailVerificationExpires: expires,
|
||||
language,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await sendVerificationEmail(email, code, token);
|
||||
await sendVerificationEmail(email, code, token, language);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send verification email:', emailError);
|
||||
}
|
||||
@ -77,6 +84,7 @@ export async function POST(request: NextRequest) {
|
||||
name: name || undefined,
|
||||
email,
|
||||
passwordHash,
|
||||
language,
|
||||
emailVerificationCode: code,
|
||||
emailVerificationToken: token,
|
||||
emailVerificationExpires: expires,
|
||||
@ -92,7 +100,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Send verification email
|
||||
try {
|
||||
await sendVerificationEmail(email, code, token);
|
||||
await sendVerificationEmail(email, code, token, language);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send verification email:', emailError);
|
||||
// User is created but email failed — they can use "resend" later
|
||||
|
||||
@ -78,31 +78,58 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET: Verify via one-click link
|
||||
// GET: Legacy one-click link.
|
||||
//
|
||||
// This used to verify-and-redirect on a single GET, but corporate inbox
|
||||
// scanners (M365 Safe Links, etc.) pre-fetch URLs to scan them — which
|
||||
// silently consumed the one-shot token before the recipient ever saw the
|
||||
// email (the bug behind point #2). New emails point straight at the client
|
||||
// page; this handler only redirects there for backward compatibility with
|
||||
// emails already in transit.
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const token = request.nextUrl.searchParams.get('token');
|
||||
const token = request.nextUrl.searchParams.get('token');
|
||||
const target = new URL('/auth/verify-email', request.url);
|
||||
if (token) target.searchParams.set('token', token);
|
||||
return NextResponse.redirect(target);
|
||||
}
|
||||
|
||||
// PATCH: Verify via token from the email link (called by the client page after
|
||||
// the user actually clicks the button — survives scanner pre-fetches).
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const { token } = await request.json();
|
||||
if (!token) {
|
||||
return NextResponse.redirect(
|
||||
new URL('/auth/verify-email?error=missing_token', request.url)
|
||||
);
|
||||
return NextResponse.json({ error: 'Token is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
emailVerificationToken: token,
|
||||
emailVerificationExpires: { gt: new Date() },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
emailVerificationExpires: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Friendly path: token already consumed but the email is verified — treat as success.
|
||||
if (!user) {
|
||||
return NextResponse.redirect(
|
||||
new URL('/auth/verify-email?error=invalid_token', request.url)
|
||||
return NextResponse.json({ error: 'Invalid token. It may have already been used.' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
return NextResponse.json({ message: 'Email already verified', email: user.email });
|
||||
}
|
||||
|
||||
if (!user.emailVerificationExpires || new Date() > user.emailVerificationExpires) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Verification link expired. Please request a new code.', email: user.email },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the user
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
@ -114,13 +141,9 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL('/auth/login?verified=true', request.url)
|
||||
);
|
||||
return NextResponse.json({ message: 'Email verified successfully', email: user.email });
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
return NextResponse.redirect(
|
||||
new URL('/auth/verify-email?error=server_error', request.url)
|
||||
);
|
||||
return NextResponse.json({ error: 'Failed to verify email' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -34,10 +34,11 @@ export default function SignupPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const language = (typeof navigator !== 'undefined' ? navigator.language : 'en').slice(0, 2).toLowerCase();
|
||||
const response = await fetch('/api/auth/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
body: JSON.stringify({ name, email, password, language }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@ -7,13 +7,16 @@ import Link from 'next/link';
|
||||
function VerifyEmailContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams.get('email') || '';
|
||||
const initialEmail = searchParams.get('email') || '';
|
||||
const tokenFromUrl = searchParams.get('token') || '';
|
||||
const error = searchParams.get('error');
|
||||
const [email, setEmail] = useState(initialEmail);
|
||||
const [code, setCode] = useState(['', '', '', '', '', '']);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [isTokenVerifying, setIsTokenVerifying] = useState(false);
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -26,10 +29,38 @@ function VerifyEmailContent() {
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// Auto-focus first input
|
||||
// Auto-focus first input (only if we're not in token-link mode)
|
||||
useEffect(() => {
|
||||
inputRefs.current[0]?.focus();
|
||||
}, []);
|
||||
if (!tokenFromUrl) inputRefs.current[0]?.focus();
|
||||
}, [tokenFromUrl]);
|
||||
|
||||
// Token-from-link flow: user landed here from the email button; we wait
|
||||
// for them to click "Verify Email" rather than firing automatically, so
|
||||
// inbox scanners that pre-fetch the URL never consume the token.
|
||||
const verifyByToken = async () => {
|
||||
setIsTokenVerifying(true);
|
||||
setErrorMsg(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
const response = await fetch('/api/auth/verify-email', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: tokenFromUrl }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.email && !email) setEmail(data.email);
|
||||
if (!response.ok) {
|
||||
setErrorMsg(data.error || 'Verification failed');
|
||||
setIsTokenVerifying(false);
|
||||
return;
|
||||
}
|
||||
setMessage('Email verified! Redirecting to login...');
|
||||
setTimeout(() => router.push('/auth/login?verified=true'), 1500);
|
||||
} catch {
|
||||
setErrorMsg('Something went wrong. Please try again.');
|
||||
setIsTokenVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = (index: number, value: string) => {
|
||||
// Handle paste of full code
|
||||
@ -140,9 +171,15 @@ function VerifyEmailContent() {
|
||||
<h2 style={{ color: '#333', fontSize: '1.25rem', fontWeight: 600, textAlign: 'center', margin: '0 0 8px' }}>
|
||||
Verify your email
|
||||
</h2>
|
||||
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', margin: '0 0 24px' }}>
|
||||
We sent a 6-digit code to <strong style={{ color: '#667eea' }}>{email}</strong>
|
||||
</p>
|
||||
{tokenFromUrl ? (
|
||||
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', margin: '0 0 24px' }}>
|
||||
Click the button below to confirm{email ? <> <strong style={{ color: '#667eea' }}>{email}</strong></> : ' your email'}.
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', margin: '0 0 24px' }}>
|
||||
We sent a 6-digit code to <strong style={{ color: '#667eea' }}>{email || 'your email'}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
{errorMsg && (
|
||||
@ -165,6 +202,38 @@ function VerifyEmailContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email recovery field when we don't know it (e.g. landed via expired-link redirect).
|
||||
Without this, the resend button has nothing to send to — that was the actual UX
|
||||
gap behind point #1. */}
|
||||
{!email && (
|
||||
<div className="weekly-auth-field" style={{ marginBottom: '16px' }}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your email address"
|
||||
autoComplete="email"
|
||||
className="weekly-auth-input"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token-link flow: prominent verify button */}
|
||||
{tokenFromUrl && !message && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={verifyByToken}
|
||||
disabled={isTokenVerifying}
|
||||
className="weekly-auth-button primary"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{isTokenVerifying ? 'Verifying...' : 'Verify Email'}
|
||||
</button>
|
||||
<p style={{ color: '#888', fontSize: '0.8rem', textAlign: 'center', margin: '12px 0 0' }}>
|
||||
Or enter the 6-digit code instead:
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Code Input */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { getVerificationCopy, getResetCopy } from './emailTemplates';
|
||||
|
||||
let _transporter: nodemailer.Transporter | null = null;
|
||||
|
||||
@ -26,10 +27,16 @@ function getTransporter() {
|
||||
export async function sendVerificationEmail(
|
||||
email: string,
|
||||
code: string,
|
||||
token: string
|
||||
token: string,
|
||||
language?: string | null,
|
||||
) {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
||||
const verifyLink = `${baseUrl}/api/auth/verify-email?token=${token}`;
|
||||
// Point the button at the client verify page (not the API GET handler).
|
||||
// This stops corporate email scanners (M365 Safe Links, etc.) from
|
||||
// pre-fetching the URL and silently consuming the one-shot token before
|
||||
// the user ever sees the email — which is the bug behind point #2.
|
||||
const verifyLink = `${baseUrl}/auth/verify-email?token=${token}&email=${encodeURIComponent(email)}`;
|
||||
const t = getVerificationCopy(language);
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
@ -43,59 +50,32 @@ export async function sendVerificationEmail(
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="480" cellpadding="0" cellspacing="0" style="background: #ffffff; border-radius: 16px; overflow: hidden; border: 1px solid #e2e8f0; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="padding: 32px 40px 16px; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">
|
||||
My Weekly ToDo's
|
||||
</h1>
|
||||
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">My Weekly ToDo's</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding: 16px 40px;">
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">
|
||||
Welcome! Please verify your email address to complete your registration.
|
||||
</p>
|
||||
|
||||
<!-- Code Box -->
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">${t.welcome}</p>
|
||||
<div style="background: #f8fafc; border: 2px dashed #cbd5e1; border-radius: 12px; padding: 24px; text-align: center; margin: 0 0 24px;">
|
||||
<p style="color: #64748b; font-size: 12px; font-weight: 600; margin: 0 0 8px; text-transform: uppercase; letter-spacing: 1px;">
|
||||
Your verification code
|
||||
</p>
|
||||
<p style="color: #0ea5e9; font-size: 40px; font-weight: 800; letter-spacing: 6px; margin: 0; font-family: 'Courier New', monospace;">
|
||||
${code}
|
||||
</p>
|
||||
<p style="color: #64748b; font-size: 12px; font-weight: 600; margin: 0 0 8px; text-transform: uppercase; letter-spacing: 1px;">${t.yourCode}</p>
|
||||
<p style="color: #0ea5e9; font-size: 40px; font-weight: 800; letter-spacing: 6px; margin: 0; font-family: 'Courier New', monospace;">${code}</p>
|
||||
</div>
|
||||
|
||||
<p style="color: #64748b; font-size: 14px; text-align: center; margin: 0 0 24px;">
|
||||
Or click the button below to verify instantly:
|
||||
</p>
|
||||
|
||||
<!-- Button -->
|
||||
<p style="color: #64748b; font-size: 14px; text-align: center; margin: 0 0 24px;">${t.orClick}</p>
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 0 24px;">
|
||||
<a href="${verifyLink}" style="display: inline-block; background-color: #0ea5e9; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);">
|
||||
✓ Verify Email
|
||||
</a>
|
||||
<a href="${verifyLink}" style="display: inline-block; background-color: #0ea5e9; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);">${t.button}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
|
||||
This code expires in 15 minutes. If you didn't create an account, you can safely ignore this email.
|
||||
</p>
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.expiresNote}</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding: 24px 40px; border-top: 1px solid #f1f5f9; background-color: #fafafa;">
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
|
||||
Simple. Beautiful. Yours.
|
||||
</p>
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.footer}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@ -113,18 +93,15 @@ export async function sendVerificationEmail(
|
||||
console.log(`======================================================\n`);
|
||||
|
||||
try {
|
||||
// 15 second timeout for SMTP
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SMTP timeout')), 15000)
|
||||
);
|
||||
|
||||
const mailPromise = getTransporter().sendMail({
|
||||
from,
|
||||
to: email,
|
||||
subject: `${code} – Verify your email for My Weekly ToDo's`,
|
||||
subject: t.subject(code),
|
||||
html,
|
||||
});
|
||||
|
||||
const result = await Promise.race([mailPromise, timeoutPromise]) as any;
|
||||
console.log(`[EMAIL] Email sent successfully: ${result.messageId}`);
|
||||
} catch (err: any) {
|
||||
@ -132,9 +109,10 @@ export async function sendVerificationEmail(
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(email: string, token: string) {
|
||||
export async function sendPasswordResetEmail(email: string, token: string, language?: string | null) {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
||||
const resetLink = `${baseUrl}/auth/reset-password?token=${token}`;
|
||||
const t = getResetCopy(language);
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
@ -150,35 +128,25 @@ export async function sendPasswordResetEmail(email: string, token: string) {
|
||||
<table width="480" cellpadding="0" cellspacing="0" style="background: #ffffff; border-radius: 16px; overflow: hidden; border: 1px solid #e2e8f0; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);">
|
||||
<tr>
|
||||
<td style="padding: 32px 40px 16px; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">
|
||||
My Weekly ToDo's
|
||||
</h1>
|
||||
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #1e293b;">My Weekly ToDo's</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 16px 40px;">
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">
|
||||
We received a request to reset your password. Click the button below to choose a new one.
|
||||
</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin: 0 0 24px; text-align: center;">${t.intro}</p>
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 0 24px;">
|
||||
<a href="${resetLink}" style="display: inline-block; background-color: #0ea5e9; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);">
|
||||
Reset Password
|
||||
</a>
|
||||
<a href="${resetLink}" style="display: inline-block; background-color: #0ea5e9; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 8px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);">${t.button}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
|
||||
This link expires in 1 hour. If you didn't request a password reset, you can safely ignore this email.
|
||||
</p>
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.expiresNote}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 24px 40px; border-top: 1px solid #f1f5f9; background-color: #fafafa;">
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">
|
||||
Simple. Beautiful. Yours.
|
||||
</p>
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center; margin: 0;">${t.footer}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@ -199,14 +167,12 @@ export async function sendPasswordResetEmail(email: string, token: string) {
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SMTP timeout')), 15000)
|
||||
);
|
||||
|
||||
const mailPromise = getTransporter().sendMail({
|
||||
from,
|
||||
to: email,
|
||||
subject: `Password Reset – My Weekly ToDo's`,
|
||||
subject: t.subject,
|
||||
html,
|
||||
});
|
||||
|
||||
const result = await Promise.race([mailPromise, timeoutPromise]) as any;
|
||||
console.log(`[EMAIL] Password reset email sent successfully: ${result.messageId}`);
|
||||
} catch (err: any) {
|
||||
|
||||
135
src/lib/emailTemplates.ts
Normal file
135
src/lib/emailTemplates.ts
Normal file
@ -0,0 +1,135 @@
|
||||
// Localised email copy. Keep keys stable across languages so the template
|
||||
// builders can swap them by language code without dynamic imports.
|
||||
|
||||
type Lang = "en" | "de" | "fr" | "es" | "it";
|
||||
|
||||
interface VerificationCopy {
|
||||
subject: (code: string) => string;
|
||||
welcome: string;
|
||||
yourCode: string;
|
||||
orClick: string;
|
||||
button: string;
|
||||
expiresNote: string;
|
||||
footer: string;
|
||||
}
|
||||
|
||||
interface ResetCopy {
|
||||
subject: string;
|
||||
intro: string;
|
||||
button: string;
|
||||
expiresNote: string;
|
||||
footer: string;
|
||||
}
|
||||
|
||||
const VERIFICATION: Record<Lang, VerificationCopy> = {
|
||||
en: {
|
||||
subject: (c) => `${c} – Verify your email for My Weekly ToDo's`,
|
||||
welcome: "Welcome! Please verify your email address to complete your registration.",
|
||||
yourCode: "Your verification code",
|
||||
orClick: "Or click the button below to verify instantly:",
|
||||
button: "✓ Verify Email",
|
||||
expiresNote: "This code expires in 15 minutes. If you didn't create an account, you can safely ignore this email.",
|
||||
footer: "Simple. Beautiful. Yours.",
|
||||
},
|
||||
de: {
|
||||
subject: (c) => `${c} – Bestätige deine E-Mail-Adresse für My Weekly ToDo's`,
|
||||
welcome: "Willkommen! Bitte bestätige deine E-Mail-Adresse, um deine Registrierung abzuschließen.",
|
||||
yourCode: "Dein Bestätigungscode",
|
||||
orClick: "Oder klicke unten, um sofort zu bestätigen:",
|
||||
button: "✓ E-Mail bestätigen",
|
||||
expiresNote: "Dieser Code läuft in 15 Minuten ab. Falls du diesen Account nicht erstellt hast, kannst du diese E-Mail einfach ignorieren.",
|
||||
footer: "Einfach. Schön. Deins.",
|
||||
},
|
||||
fr: {
|
||||
subject: (c) => `${c} – Vérifiez votre adresse e-mail pour My Weekly ToDo's`,
|
||||
welcome: "Bienvenue ! Veuillez vérifier votre adresse e-mail pour terminer votre inscription.",
|
||||
yourCode: "Votre code de vérification",
|
||||
orClick: "Ou cliquez sur le bouton ci-dessous pour vérifier instantanément :",
|
||||
button: "✓ Vérifier l'e-mail",
|
||||
expiresNote: "Ce code expire dans 15 minutes. Si vous n'avez pas créé de compte, vous pouvez ignorer cet e-mail.",
|
||||
footer: "Simple. Beau. À vous.",
|
||||
},
|
||||
es: {
|
||||
subject: (c) => `${c} – Verifica tu correo para My Weekly ToDo's`,
|
||||
welcome: "¡Bienvenido! Por favor verifica tu dirección de correo para completar tu registro.",
|
||||
yourCode: "Tu código de verificación",
|
||||
orClick: "O haz clic en el botón de abajo para verificar al instante:",
|
||||
button: "✓ Verificar correo",
|
||||
expiresNote: "Este código expira en 15 minutos. Si no creaste una cuenta, puedes ignorar este correo.",
|
||||
footer: "Simple. Hermoso. Tuyo.",
|
||||
},
|
||||
it: {
|
||||
subject: (c) => `${c} – Verifica la tua email per My Weekly ToDo's`,
|
||||
welcome: "Benvenuto! Verifica il tuo indirizzo email per completare la registrazione.",
|
||||
yourCode: "Il tuo codice di verifica",
|
||||
orClick: "Oppure clicca sul pulsante qui sotto per verificare subito:",
|
||||
button: "✓ Verifica email",
|
||||
expiresNote: "Questo codice scade tra 15 minuti. Se non hai creato un account, puoi ignorare questa email.",
|
||||
footer: "Semplice. Bello. Tuo.",
|
||||
},
|
||||
};
|
||||
|
||||
const RESET: Record<Lang, ResetCopy> = {
|
||||
en: {
|
||||
subject: "Password Reset – My Weekly ToDo's",
|
||||
intro: "We received a request to reset your password. Click the button below to choose a new one.",
|
||||
button: "Reset Password",
|
||||
expiresNote: "This link expires in 1 hour. If you didn't request a password reset, you can safely ignore this email.",
|
||||
footer: "Simple. Beautiful. Yours.",
|
||||
},
|
||||
de: {
|
||||
subject: "Passwort zurücksetzen – My Weekly ToDo's",
|
||||
intro: "Wir haben eine Anfrage zum Zurücksetzen deines Passworts erhalten. Klicke auf den Button unten, um ein neues Passwort zu wählen.",
|
||||
button: "Passwort zurücksetzen",
|
||||
expiresNote: "Dieser Link läuft in 1 Stunde ab. Falls du kein neues Passwort angefordert hast, kannst du diese E-Mail einfach ignorieren.",
|
||||
footer: "Einfach. Schön. Deins.",
|
||||
},
|
||||
fr: {
|
||||
subject: "Réinitialisation du mot de passe – My Weekly ToDo's",
|
||||
intro: "Nous avons reçu une demande de réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour en choisir un nouveau.",
|
||||
button: "Réinitialiser le mot de passe",
|
||||
expiresNote: "Ce lien expire dans 1 heure. Si vous n'avez pas demandé de réinitialisation, vous pouvez ignorer cet e-mail.",
|
||||
footer: "Simple. Beau. À vous.",
|
||||
},
|
||||
es: {
|
||||
subject: "Restablecimiento de contraseña – My Weekly ToDo's",
|
||||
intro: "Recibimos una solicitud para restablecer tu contraseña. Haz clic en el botón de abajo para elegir una nueva.",
|
||||
button: "Restablecer contraseña",
|
||||
expiresNote: "Este enlace expira en 1 hora. Si no solicitaste un restablecimiento, puedes ignorar este correo.",
|
||||
footer: "Simple. Hermoso. Tuyo.",
|
||||
},
|
||||
it: {
|
||||
subject: "Reimpostazione password – My Weekly ToDo's",
|
||||
intro: "Abbiamo ricevuto una richiesta di reimpostazione della password. Clicca sul pulsante qui sotto per sceglierne una nuova.",
|
||||
button: "Reimposta password",
|
||||
expiresNote: "Questo link scade tra 1 ora. Se non hai richiesto la reimpostazione, puoi ignorare questa email.",
|
||||
footer: "Semplice. Bello. Tuo.",
|
||||
},
|
||||
};
|
||||
|
||||
function pickLang(input?: string | null): Lang {
|
||||
if (!input) return "en";
|
||||
const code = input.toLowerCase().slice(0, 2);
|
||||
if (code === "de" || code === "fr" || code === "es" || code === "it" || code === "en") return code;
|
||||
return "en";
|
||||
}
|
||||
|
||||
// Parse the first acceptable locale out of a raw Accept-Language header so we
|
||||
// can pre-fill a user's UI language at signup.
|
||||
export function languageFromAcceptLanguage(header?: string | null): Lang {
|
||||
if (!header) return "en";
|
||||
const tags = header.split(",").map((t) => t.trim().split(";")[0]);
|
||||
for (const t of tags) {
|
||||
const code = t.toLowerCase().slice(0, 2);
|
||||
if (code === "de" || code === "fr" || code === "es" || code === "it" || code === "en") return code;
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
export function getVerificationCopy(language?: string | null): VerificationCopy {
|
||||
return VERIFICATION[pickLang(language)];
|
||||
}
|
||||
|
||||
export function getResetCopy(language?: string | null): ResetCopy {
|
||||
return RESET[pickLang(language)];
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"downlevelIteration": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
@ -14,7 +13,7 @@
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user