My-Weekly-ToDo-List/src/lib/email.ts
mARTin-B78 a040ba128b Merge branch 'main' into dev
# Conflicts:
#	package.json
#	tsconfig.tsbuildinfo
2026-05-02 01:01:44 +02:00

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();
}