Improve email deliverability: plain text, contrast fix, Content-Language, List-Unsubscribe

- Add plain text (text:) version to all emails — helps SpamAssassin scoring
- Add lang="ru" to <html> and Content-Language: ru header — signals intentional Cyrillic
- Fix HTML_FONT_LOW_CONTRAST: change #94a3b8 to #475569 / #334155 for note text
- Add List-Unsubscribe header (reduces spam score)
- Refactor: extract baseHtml() helper to avoid duplication

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 20:01:25 +03:00
parent 5e0c388b3d
commit bda0cbb7d5

View File

@@ -10,93 +10,118 @@ const transporter = nodemailer.createTransport({
},
})
export async function sendPasswordResetEmail(to: string, name: string, token: string): Promise<void> {
const appUrl = process.env.APP_URL ?? 'https://app.hotelsync.ru'
const resetUrl = `${appUrl}/reset-password?token=${token}`
const fromAddr = process.env.SMTP_USER ?? 'noreply@hotelsync.ru'
const fromAddr = () => process.env.SMTP_USER ?? 'noreply@hotelsync.ru'
const appUrl = () => process.env.APP_URL ?? 'https://app.hotelsync.ru'
const apiUrl = () => process.env.API_URL ?? 'https://api.hotelsync.ru'
await transporter.sendMail({
from: `"HotelSync" <${fromAddr}>`,
to,
subject: 'Сброс пароля — HotelSync',
html: `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
const unsubscribeHeader = `<mailto:noreply@hotelsync.ru?subject=unsubscribe>`
function baseHtml(headerColor: string, icon: string, title: string, body: string): string {
return `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body style="font-family:Arial,sans-serif;background:#f1f5f9;margin:0;padding:40px 16px;">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
<div style="background:linear-gradient(135deg,#4f46e5,#6366f1);padding:32px 40px;text-align:center;">
<div style="display:inline-block;width:48px;height:48px;background:rgba(255,255,255,0.2);border-radius:12px;line-height:48px;font-size:24px;margin-bottom:12px;">🔑</div>
<h1 style="color:#fff;margin:0;font-size:26px;font-weight:700;letter-spacing:-0.5px;">HotelSync</h1>
<div style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
<div style="background:${headerColor};padding:32px 40px;text-align:center;">
<div style="display:inline-block;width:48px;height:48px;background:rgba(255,255,255,0.2);border-radius:12px;line-height:48px;font-size:24px;margin-bottom:12px;">${icon}</div>
<h1 style="color:#ffffff;margin:0;font-size:26px;font-weight:700;letter-spacing:-0.5px;">HotelSync</h1>
<p style="color:#c7d2fe;margin:6px 0 0;font-size:14px;">Современная PMS-система</p>
</div>
<div style="padding:40px;">
<h2 style="color:#1e293b;font-size:20px;margin:0 0 12px;font-weight:600;">Сброс пароля</h2>
<p style="color:#475569;line-height:1.7;margin:0 0 8px;font-size:15px;">Привет, ${name}!</p>
<p style="color:#475569;line-height:1.7;margin:0 0 28px;font-size:15px;">
Мы получили запрос на сброс пароля для вашего аккаунта HotelSync. Нажмите на кнопку ниже, чтобы создать новый пароль.
</p>
<div style="text-align:center;margin:0 0 28px;">
<a href="${resetUrl}" style="display:inline-block;background:#4f46e5;color:#fff;text-decoration:none;padding:14px 36px;border-radius:10px;font-weight:600;font-size:16px;letter-spacing:0.2px;">
Создать новый пароль
</a>
</div>
<p style="color:#94a3b8;font-size:13px;margin:0 0 16px;line-height:1.6;background:#f8fafc;padding:12px 16px;border-radius:8px;border-left:3px solid #e2e8f0;">
⏱ Ссылка действительна <strong>1 час</strong>.<br>
Если вы не запрашивали сброс пароля — просто проигнорируйте это письмо.
</p>
${body}
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0;">
<p style="color:#94a3b8;font-size:12px;margin:0;line-height:1.6;">
Кнопка не работает? Скопируйте ссылку в браузер:<br>
<a href="${resetUrl}" style="color:#6366f1;word-break:break-all;font-size:11px;">${resetUrl}</a>
<p style="color:#64748b;font-size:11px;margin:0;line-height:1.6;text-align:center;">
© 2026 HotelSync · <a href="${appUrl()}" style="color:#6366f1;text-decoration:none;">app.hotelsync.ru</a>
</p>
</div>
</div>
</body>
</html>`,
</html>`
}
export async function sendPasswordResetEmail(to: string, name: string, token: string): Promise<void> {
const resetUrl = `${appUrl()}/reset-password?token=${token}`
const html = baseHtml(
'linear-gradient(135deg,#4f46e5,#6366f1)',
'🔑',
'Сброс пароля',
`<h2 style="color:#1e293b;font-size:20px;margin:0 0 12px;font-weight:600;">Сброс пароля</h2>
<p style="color:#334155;line-height:1.7;margin:0 0 8px;font-size:15px;">Привет, ${name}!</p>
<p style="color:#334155;line-height:1.7;margin:0 0 28px;font-size:15px;">
Мы получили запрос на сброс пароля для вашего аккаунта HotelSync. Нажмите на кнопку ниже, чтобы создать новый пароль.
</p>
<div style="text-align:center;margin:0 0 28px;">
<a href="${resetUrl}" style="display:inline-block;background:#4f46e5;color:#ffffff;text-decoration:none;padding:14px 36px;border-radius:10px;font-weight:600;font-size:16px;letter-spacing:0.2px;">
Создать новый пароль
</a>
</div>
<p style="color:#475569;font-size:13px;margin:0 0 16px;line-height:1.6;background:#f8fafc;padding:12px 16px;border-radius:8px;border-left:3px solid #e2e8f0;">
Ссылка действительна 1 час.<br>
Если вы не запрашивали сброс пароля — просто проигнорируйте это письмо.
</p>
<p style="color:#475569;font-size:12px;margin:0;line-height:1.6;">
Кнопка не работает? Скопируйте ссылку в браузер:<br>
<a href="${resetUrl}" style="color:#6366f1;word-break:break-all;font-size:11px;">${resetUrl}</a>
</p>`,
)
const text = `Сброс пароля — HotelSync\n\ривет, ${name}!\n\nМы получили запрос на сброс пароля для вашего аккаунта HotelSync.\n\nСсылка для сброса пароля (действительна 1 час):\n${resetUrl}\n\nЕсли вы не запрашивали сброс пароля — проигнорируйте это письмо.\n\n© 2026 HotelSync`
await transporter.sendMail({
from: `"HotelSync" <${fromAddr()}>`,
to,
subject: 'Сброс пароля — HotelSync',
html,
text,
headers: {
'List-Unsubscribe': unsubscribeHeader,
'Content-Language': 'ru',
},
})
}
export async function sendConfirmationEmail(to: string, name: string, token: string): Promise<void> {
const apiUrl = process.env.API_URL ?? 'https://api.hotelsync.ru'
const confirmUrl = `${apiUrl}/api/auth/confirm-email?token=${token}`
const fromAddr = process.env.SMTP_USER ?? 'noreply@hotelsync.ru'
const confirmUrl = `${apiUrl()}/api/auth/confirm-email?token=${token}`
await transporter.sendMail({
from: `"HotelSync" <${fromAddr}>`,
to,
subject: 'Подтвердите email — HotelSync',
html: `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="font-family:Arial,sans-serif;background:#f1f5f9;margin:0;padding:40px 16px;">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
<div style="background:linear-gradient(135deg,#4f46e5,#6366f1);padding:32px 40px;text-align:center;">
<div style="display:inline-block;width:48px;height:48px;background:rgba(255,255,255,0.2);border-radius:12px;line-height:48px;font-size:24px;margin-bottom:12px;">🏨</div>
<h1 style="color:#fff;margin:0;font-size:26px;font-weight:700;letter-spacing:-0.5px;">HotelSync</h1>
<p style="color:#c7d2fe;margin:6px 0 0;font-size:14px;">Современная PMS-система</p>
</div>
<div style="padding:40px;">
<h2 style="color:#1e293b;font-size:20px;margin:0 0 12px;font-weight:600;">Добро пожаловать, ${name}!</h2>
<p style="color:#475569;line-height:1.7;margin:0 0 28px;font-size:15px;">
const html = baseHtml(
'linear-gradient(135deg,#4f46e5,#6366f1)',
'🏨',
'Подтверждение email',
`<h2 style="color:#1e293b;font-size:20px;margin:0 0 12px;font-weight:600;">Добро пожаловать, ${name}!</h2>
<p style="color:#334155;line-height:1.7;margin:0 0 28px;font-size:15px;">
Спасибо за регистрацию в HotelSync. Для активации аккаунта нажмите на кнопку ниже.
</p>
<div style="text-align:center;margin:0 0 28px;">
<a href="${confirmUrl}" style="display:inline-block;background:#4f46e5;color:#fff;text-decoration:none;padding:14px 36px;border-radius:10px;font-weight:600;font-size:16px;letter-spacing:0.2px;">
Подтвердить email
<a href="${confirmUrl}" style="display:inline-block;background:#4f46e5;color:#ffffff;text-decoration:none;padding:14px 36px;border-radius:10px;font-weight:600;font-size:16px;letter-spacing:0.2px;">
Подтвердить email
</a>
</div>
<p style="color:#94a3b8;font-size:13px;margin:0 0 16px;line-height:1.6;background:#f8fafc;padding:12px 16px;border-radius:8px;border-left:3px solid #e2e8f0;">
Ссылка действительна <strong>24 часа</strong>.<br>
<p style="color:#475569;font-size:13px;margin:0 0 16px;line-height:1.6;background:#f8fafc;padding:12px 16px;border-radius:8px;border-left:3px solid #e2e8f0;">
Ссылка действительна 24 часа.<br>
Если вы не регистрировались — просто проигнорируйте это письмо.
</p>
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0;">
<p style="color:#94a3b8;font-size:12px;margin:0;line-height:1.6;">
<p style="color:#475569;font-size:12px;margin:0;line-height:1.6;">
Кнопка не работает? Скопируйте ссылку в браузер:<br>
<a href="${confirmUrl}" style="color:#6366f1;word-break:break-all;font-size:11px;">${confirmUrl}</a>
</p>
</div>
</div>
</body>
</html>`,
</p>`,
)
const text = `Подтверждение email — HotelSync\n\обро пожаловать, ${name}!\n\nСпасибо за регистрацию в HotelSync.\n\nДля активации аккаунта перейдите по ссылке (действительна 24 часа):\n${confirmUrl}\n\nЕсли вы не регистрировались — проигнорируйте это письмо.\n\n© 2026 HotelSync`
await transporter.sendMail({
from: `"HotelSync" <${fromAddr()}>`,
to,
subject: 'Подтвердите email — HotelSync',
html,
text,
headers: {
'List-Unsubscribe': unsubscribeHeader,
'Content-Language': 'ru',
},
})
}