194 lines
8.8 KiB
TypeScript
194 lines
8.8 KiB
TypeScript
import nodemailer from 'nodemailer';
|
|
import { getVerificationCopy, getResetCopy } from './emailTemplates';
|
|
|
|
let _transporter: nodemailer.Transporter | null = null;
|
|
|
|
function getTransporter() {
|
|
if (!_transporter) {
|
|
const host = process.env.SMTP_HOST || 'smtp.gmail.com';
|
|
const port = parseInt(process.env.SMTP_PORT || '587');
|
|
const secure = port === 465; // Port 465 = TLS, Port 587 = STARTTLS
|
|
const user = process.env.SMTP_USERNAME || process.env.SMTP_USER || '';
|
|
const pass = process.env.SMTP_PASSWORD || process.env.SMTP_PASS || '';
|
|
|
|
console.log(`[EMAIL] Creating SMTP transport: ${host}:${port} (secure=${secure}, user=${user})`);
|
|
|
|
_transporter = nodemailer.createTransport({
|
|
host,
|
|
port,
|
|
secure,
|
|
auth: { user, pass },
|
|
tls: { rejectUnauthorized: false },
|
|
});
|
|
}
|
|
return _transporter;
|
|
}
|
|
|
|
export async function sendVerificationEmail(
|
|
email: string,
|
|
code: string,
|
|
token: string,
|
|
language?: string | null,
|
|
) {
|
|
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
|
// 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>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
</head>
|
|
<body style="margin: 0; padding: 0; background-color: #f7f9fc; font-family: 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f7f9fc; padding: 40px 20px;">
|
|
<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);">
|
|
<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>
|
|
</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;">${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;">${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;">${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);">${t.button}</a>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
<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;">${t.footer}</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
const from = process.env.SMTP_FROM || '"My Weekly ToDo\'s" <mail@carrylight.de>';
|
|
console.log(`[EMAIL] Sending verification email to ${email} from ${from}`);
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.log(`\n======================================================`);
|
|
console.log(`[DEV VERIFICATION LINK]:\n${verifyLink}`);
|
|
console.log(`======================================================\n`);
|
|
}
|
|
|
|
try {
|
|
const timeoutPromise = new Promise((_, reject) =>
|
|
setTimeout(() => reject(new Error('SMTP timeout')), 15000)
|
|
);
|
|
const mailPromise = getTransporter().sendMail({
|
|
from,
|
|
to: email,
|
|
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) {
|
|
console.error(`[EMAIL] Failed to send email (Error: ${err.message}). The verification link is printed above for manual use.`);
|
|
}
|
|
}
|
|
|
|
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>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
</head>
|
|
<body style="margin: 0; padding: 0; background-color: #f7f9fc; font-family: 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f7f9fc; padding: 40px 20px;">
|
|
<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);">
|
|
<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>
|
|
</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;">${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);">${t.button}</a>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
<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;">${t.footer}</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
const from = process.env.SMTP_FROM || '"My Weekly ToDo\'s" <mail@carrylight.de>';
|
|
console.log(`[EMAIL] Sending password reset email to ${email} from ${from}`);
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.log(`\n======================================================`);
|
|
console.log(`[DEV RESET LINK]:\n${resetLink}`);
|
|
console.log(`======================================================\n`);
|
|
}
|
|
|
|
try {
|
|
const timeoutPromise = new Promise((_, reject) =>
|
|
setTimeout(() => reject(new Error('SMTP timeout')), 15000)
|
|
);
|
|
const mailPromise = getTransporter().sendMail({
|
|
from,
|
|
to: email,
|
|
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) {
|
|
console.error(`[EMAIL] Failed to send reset email (Error: ${err.message}). The reset link is printed above for manual use.`);
|
|
}
|
|
}
|
|
|
|
export function generateVerificationCode(): string {
|
|
return Math.floor(100000 + Math.random() * 900000).toString();
|
|
}
|
|
|
|
export function generateVerificationToken(): string {
|
|
return crypto.randomUUID();
|
|
}
|