2790a7d24b
requireEmailVerification was enabled but no sendVerificationEmail was configured — new users saw "check your email", never received anything, and couldn't log in (403 shown as "wrong email or password"). - email.ts: add verification email template; welcome email no longer claims the account is "confirmed" - auth.ts: configure emailVerification (send on signup, resend on unverified login attempt, auto sign-in after verification, 24h TTL) - register route: callbackURL=/dashboard so the verify link lands in the cabinet - login form: distinguish 403 (unverified — tell user a fresh link was sent) and 429 (rate limit) from wrong credentials Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
331 lines
14 KiB
TypeScript
331 lines
14 KiB
TypeScript
import { Resend } from "resend";
|
|
import { getSetting } from "./settings";
|
|
|
|
function getResend() {
|
|
return new Resend(process.env.RESEND_API_KEY);
|
|
}
|
|
const FROM = process.env.EMAIL_FROM ?? "noreply@mailsend.second-brain.ru";
|
|
const BASE_URL = process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru";
|
|
|
|
async function getSchoolName() {
|
|
return getSetting("schoolName");
|
|
}
|
|
|
|
// ── HTML template (inline styles for maximum email client compatibility) ───────
|
|
|
|
function base(content: string, schoolName = "Second Brain") {
|
|
return `<!DOCTYPE html>
|
|
<html lang="ru" xmlns="http://www.w3.org/1999/xhtml">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
|
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
|
<title>Second Brain</title>
|
|
</head>
|
|
<body style="margin:0;padding:0;background-color:#FFFFFF;font-family:Arial,Helvetica,sans-serif;color:#323232;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
|
|
|
|
<!-- Outer wrapper -->
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#FFFFFF;">
|
|
<tr>
|
|
<td align="center" style="padding:32px 16px;">
|
|
|
|
<!-- Shadow wrapper: gray bg + padding creates border+shadow illusion -->
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;background-color:#AAAAAA;">
|
|
<tr>
|
|
<td style="padding:2px 6px 6px 2px;">
|
|
|
|
<!-- Card -->
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#F5F5F0;">
|
|
|
|
<!-- Header -->
|
|
<tr>
|
|
<td style="padding:20px 28px;border-bottom:2px solid #AAAAAA;background-color:#F5F5F0;">
|
|
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#323232;">${schoolName} · Образовательная платформа</p>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Body -->
|
|
<tr>
|
|
<td style="padding:28px 28px 24px;">
|
|
${content}
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Footer -->
|
|
<tr>
|
|
<td style="padding:14px 28px;border-top:2px solid #AAAAAA;background-color:#F5F5F0;">
|
|
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:11px;color:#AAAAAA;">Это автоматическое письмо, не отвечайте на него.</p>
|
|
</td>
|
|
</tr>
|
|
|
|
</table>
|
|
<!-- /Card -->
|
|
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
<!-- /Shadow wrapper -->
|
|
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
// Reusable inline styles
|
|
const p = `style="margin:0 0 14px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.65;color:#323232;"`;
|
|
const pLast = `style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.65;color:#323232;"`;
|
|
|
|
function btn(href: string, label: string) {
|
|
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin-top:24px;">
|
|
<tr>
|
|
<td style="background-color:#E8F0D8;border:2px solid #323232;border-right:4px solid #323232;border-bottom:4px solid #323232;">
|
|
<a href="${href}" target="_blank" style="display:inline-block;padding:10px 22px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;color:#323232;text-decoration:none;">${label} →</a>
|
|
</td>
|
|
</tr>
|
|
</table>`;
|
|
}
|
|
|
|
function quote(text: string) {
|
|
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;">
|
|
<tr>
|
|
<td style="border-left:3px solid #323232;padding:10px 14px;background-color:#E8F0D8;">
|
|
<p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.6;color:#323232;">${text.replace(/\n/g, "<br/>")}</p>
|
|
</td>
|
|
</tr>
|
|
</table>`;
|
|
}
|
|
|
|
// ── Email senders ─────────────────────────────────────────────────────────────
|
|
|
|
export async function sendCourseAccessEmail(to: string, name: string, courseTitle: string) {
|
|
const school = await getSchoolName();
|
|
await getResend().emails.send({
|
|
from: FROM,
|
|
to,
|
|
subject: `Вам открыт доступ к курсу «${courseTitle}»`,
|
|
html: base(`
|
|
<p ${p}>Привет, ${name}!</p>
|
|
<p ${p}>Вам открыт доступ к курсу <strong>«${courseTitle}»</strong>.</p>
|
|
<p ${pLast}>Перейдите на платформу чтобы начать обучение:</p>
|
|
${btn(`${BASE_URL}/dashboard`, "Перейти к курсам")}
|
|
`, school),
|
|
}).catch((e) => console.error("[email] sendCourseAccessEmail:", e));
|
|
}
|
|
|
|
export async function sendHomeworkSubmittedEmail(
|
|
to: string,
|
|
curatorName: string,
|
|
studentName: string,
|
|
lessonTitle: string,
|
|
submissionId: string
|
|
) {
|
|
const school = await getSchoolName();
|
|
await getResend().emails.send({
|
|
from: FROM,
|
|
to,
|
|
subject: `Новая работа на проверку — ${lessonTitle}`,
|
|
html: base(`
|
|
<p ${p}>Привет, ${curatorName}!</p>
|
|
<p ${p}>Студент <strong>${studentName}</strong> сдал работу по уроку <strong>«${lessonTitle}»</strong>.</p>
|
|
<p ${pLast}>Откройте работу чтобы проверить и оставить фидбек:</p>
|
|
${btn(`${BASE_URL}/curator/homework/${submissionId}`, "Проверить работу")}
|
|
`, school),
|
|
}).catch((e) => console.error("[email] sendHomeworkSubmittedEmail:", e));
|
|
}
|
|
|
|
export async function sendFeedbackReceivedEmail(
|
|
to: string,
|
|
studentName: string,
|
|
lessonTitle: string,
|
|
feedbackText: string,
|
|
lessonUrl: string
|
|
) {
|
|
const school = await getSchoolName();
|
|
await getResend().emails.send({
|
|
from: FROM,
|
|
to,
|
|
subject: `Получен фидбек по уроку «${lessonTitle}»`,
|
|
html: base(`
|
|
<p ${p}>Привет, ${studentName}!</p>
|
|
<p ${p}>Куратор проверил вашу работу по уроку <strong>«${lessonTitle}»</strong> и оставил обратную связь:</p>
|
|
${quote(feedbackText)}
|
|
<p ${pLast}>Вернитесь к уроку чтобы увидеть полный фидбек:</p>
|
|
${btn(lessonUrl, "Открыть урок")}
|
|
`, school),
|
|
}).catch((e) => console.error("[email] sendFeedbackReceivedEmail:", e));
|
|
}
|
|
|
|
export async function sendWelcomeEmail(to: string, name: string) {
|
|
const school = await getSchoolName();
|
|
await getResend().emails.send({
|
|
from: FROM,
|
|
to,
|
|
subject: `Добро пожаловать в ${school}`,
|
|
html: base(`
|
|
<p ${p}>Привет, ${name}!</p>
|
|
<p ${p}>Ваш аккаунт на образовательной платформе <strong>${school}</strong> создан.</p>
|
|
<p ${pLast}>После того как администратор откроет вам доступ к курсу, вы получите письмо и сможете начать обучение.</p>
|
|
${btn(`${BASE_URL}/dashboard`, "Перейти на платформу")}
|
|
`, school),
|
|
}).catch((e) => console.error("[email] sendWelcomeEmail:", e));
|
|
}
|
|
|
|
export async function sendTestEmail(to: string) {
|
|
const school = await getSchoolName();
|
|
await getResend().emails.send({
|
|
from: FROM,
|
|
to,
|
|
subject: `Тест — ${school} LMS`,
|
|
html: base(`
|
|
<p ${p}>Привет!</p>
|
|
<p ${p}>Это тестовое письмо от платформы <strong>${school} LMS</strong>.</p>
|
|
<p ${p}>Так будут выглядеть все уведомления — доступ к курсу, фидбек по ДЗ и другие.</p>
|
|
<p ${pLast}>Если письмо отображается корректно, значит email-уведомления настроены и работают.</p>
|
|
${btn(`${BASE_URL}/dashboard`, "Открыть платформу")}
|
|
`, school),
|
|
}).catch((e) => console.error("[email] sendTestEmail:", e));
|
|
}
|
|
|
|
export async function sendAccessEmail(
|
|
to: string,
|
|
name: string,
|
|
courseTitle: string,
|
|
tempPassword: string
|
|
) {
|
|
const school = await getSchoolName();
|
|
await getResend().emails.send({
|
|
from: FROM,
|
|
to,
|
|
subject: `Доступ к курсу «${courseTitle}» открыт`,
|
|
html: base(`
|
|
<p ${p}>Привет!</p>
|
|
<p ${p}>Вам открыт доступ к курсу <strong>«${courseTitle}»</strong> на образовательной платформе <strong>${school}</strong>.</p>
|
|
<p ${p}>Ваши данные для входа:</p>
|
|
${quote(`Логин: ${to}\nПароль: ${tempPassword}`)}
|
|
<p ${p}>После входа рекомендуем сразу сменить пароль в разделе «Профиль».</p>
|
|
<p ${pLast}>Если пароль не подходит — воспользуйтесь ссылкой «Забыли пароль?» на странице входа.</p>
|
|
${btn(`${BASE_URL}/login`, "Войти на платформу")}
|
|
`, school),
|
|
}).catch((e) => console.error("[email] sendAccessEmail:", e));
|
|
}
|
|
|
|
export async function sendPasswordResetEmail(to: string, name: string, resetUrl: string) {
|
|
const school = await getSchoolName();
|
|
await getResend().emails.send({
|
|
from: FROM,
|
|
to,
|
|
subject: `Сброс пароля — ${school}`,
|
|
html: base(`
|
|
<p ${p}>Привет, ${name}!</p>
|
|
<p ${p}>Вы запросили сброс пароля для вашего аккаунта на платформе <strong>${school}</strong>.</p>
|
|
<p ${p}>Нажмите на кнопку ниже чтобы задать новый пароль. Ссылка действительна <strong>1 час</strong>.</p>
|
|
<p ${pLast}>Если вы не запрашивали сброс — просто проигнорируйте это письмо.</p>
|
|
${btn(resetUrl, "Задать новый пароль")}
|
|
`, school),
|
|
}).catch((e) => console.error("[email] sendPasswordResetEmail:", e));
|
|
}
|
|
|
|
export async function sendVerificationEmail(to: string, name: string, verifyUrl: string) {
|
|
const school = await getSchoolName();
|
|
await getResend().emails.send({
|
|
from: FROM,
|
|
to,
|
|
subject: `Подтвердите email — ${school}`,
|
|
html: base(`
|
|
<p ${p}>Привет, ${name}!</p>
|
|
<p ${p}>Вы зарегистрировались на образовательной платформе <strong>${school}</strong>.</p>
|
|
<p ${p}>Нажмите на кнопку ниже, чтобы подтвердить email и активировать аккаунт. Ссылка действительна <strong>24 часа</strong>.</p>
|
|
<p ${pLast}>Если вы не регистрировались — просто проигнорируйте это письмо.</p>
|
|
${btn(verifyUrl, "Подтвердить email")}
|
|
`, school),
|
|
}).catch((e) => console.error("[email] sendVerificationEmail:", e));
|
|
}
|
|
|
|
export async function sendQuestionCreatedEmail(
|
|
to: string,
|
|
recipientName: string,
|
|
studentName: string,
|
|
questionTitle: string,
|
|
) {
|
|
const school = await getSchoolName();
|
|
await getResend().emails.send({
|
|
from: FROM,
|
|
to,
|
|
subject: `Новый вопрос от ${studentName}`,
|
|
html: base(`
|
|
<p ${p}>Привет, ${recipientName}!</p>
|
|
<p ${p}>Студент <strong>${studentName}</strong> задал новый вопрос:</p>
|
|
${quote(questionTitle)}
|
|
<p ${pLast}>Откройте панель, чтобы ответить:</p>
|
|
${btn(`${BASE_URL}/admin/questions`, "Перейти к вопросам")}
|
|
`, school),
|
|
}).catch((e) => console.error("[email] sendQuestionCreatedEmail:", e));
|
|
}
|
|
|
|
export async function sendQuestionReplyEmail(
|
|
to: string,
|
|
studentName: string,
|
|
questionTitle: string,
|
|
questionId: string,
|
|
) {
|
|
const school = await getSchoolName();
|
|
await getResend().emails.send({
|
|
from: FROM,
|
|
to,
|
|
subject: `Ответ на ваш вопрос`,
|
|
html: base(`
|
|
<p ${p}>Привет, ${studentName}!</p>
|
|
<p ${p}>Школа ответила на ваш вопрос:</p>
|
|
${quote(questionTitle)}
|
|
<p ${pLast}>Откройте тред чтобы прочитать ответ:</p>
|
|
${btn(`${BASE_URL}/questions/${questionId}`, "Читать ответ")}
|
|
`, school),
|
|
}).catch((e) => console.error("[email] sendQuestionReplyEmail:", e));
|
|
}
|
|
|
|
export async function sendQuestionFollowUpEmail(
|
|
to: string,
|
|
recipientName: string,
|
|
studentName: string,
|
|
questionTitle: string,
|
|
) {
|
|
const school = await getSchoolName();
|
|
await getResend().emails.send({
|
|
from: FROM,
|
|
to,
|
|
subject: `Новый ответ от студента — ${questionTitle}`,
|
|
html: base(`
|
|
<p ${p}>Привет, ${recipientName}!</p>
|
|
<p ${p}>Студент <strong>${studentName}</strong> добавил сообщение в вопрос:</p>
|
|
${quote(questionTitle)}
|
|
<p ${pLast}>Откройте панель, чтобы ответить:</p>
|
|
${btn(`${BASE_URL}/admin/questions`, "Перейти к вопросам")}
|
|
`, school),
|
|
}).catch((e) => console.error("[email] sendQuestionFollowUpEmail:", e));
|
|
}
|
|
|
|
export async function sendHomeworkUpdatedEmail(
|
|
to: string,
|
|
recipientName: string,
|
|
studentName: string,
|
|
lessonTitle: string,
|
|
submissionId: string,
|
|
) {
|
|
const school = await getSchoolName();
|
|
await getResend().emails.send({
|
|
from: FROM,
|
|
to,
|
|
subject: `Студент обновил работу — ${lessonTitle}`,
|
|
html: base(`
|
|
<p ${p}>Привет, ${recipientName}!</p>
|
|
<p ${p}>Студент <strong>${studentName}</strong> обновил работу по уроку <strong>«${lessonTitle}»</strong>.</p>
|
|
<p ${pLast}>Откройте работу чтобы проверить изменения:</p>
|
|
${btn(`${BASE_URL}/curator/homework/${submissionId}`, "Открыть работу")}
|
|
`, school),
|
|
}).catch((e) => console.error("[email] sendHomeworkUpdatedEmail:", e));
|
|
}
|