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 fromAddr = () => process.env.SMTP_USER ?? 'noreply@hotelsync.ru'
const appUrl = process.env.APP_URL ?? 'https://app.hotelsync.ru' const appUrl = () => process.env.APP_URL ?? 'https://app.hotelsync.ru'
const resetUrl = `${appUrl}/reset-password?token=${token}` const apiUrl = () => process.env.API_URL ?? 'https://api.hotelsync.ru'
const fromAddr = process.env.SMTP_USER ?? 'noreply@hotelsync.ru'
await transporter.sendMail({ const unsubscribeHeader = `<mailto:noreply@hotelsync.ru?subject=unsubscribe>`
from: `"HotelSync" <${fromAddr}>`,
to, function baseHtml(headerColor: string, icon: string, title: string, body: string): string {
subject: 'Сброс пароля — HotelSync', return `<!DOCTYPE html>
html: `<!DOCTYPE html> <html lang="ru">
<html> <head>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></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;"> <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="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:linear-gradient(135deg,#4f46e5,#6366f1);padding:32px 40px;text-align:center;"> <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;">🔑</div> <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:#fff;margin:0;font-size:26px;font-weight:700;letter-spacing:-0.5px;">HotelSync</h1> <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> <p style="color:#c7d2fe;margin:6px 0 0;font-size:14px;">Современная PMS-система</p>
</div> </div>
<div style="padding:40px;"> <div style="padding:40px;">
<h2 style="color:#1e293b;font-size:20px;margin:0 0 12px;font-weight:600;">Сброс пароля</h2> ${body}
<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>
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0;"> <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:#64748b;font-size:11px;margin:0;line-height:1.6;text-align:center;">
Кнопка не работает? Скопируйте ссылку в браузер:<br> © 2026 HotelSync · <a href="${appUrl()}" style="color:#6366f1;text-decoration:none;">app.hotelsync.ru</a>
<a href="${resetUrl}" style="color:#6366f1;word-break:break-all;font-size:11px;">${resetUrl}</a>
</p> </p>
</div> </div>
</div> </div>
</body> </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> { 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 confirmUrl = `${apiUrl}/api/auth/confirm-email?token=${token}`
const fromAddr = process.env.SMTP_USER ?? 'noreply@hotelsync.ru'
await transporter.sendMail({ const html = baseHtml(
from: `"HotelSync" <${fromAddr}>`, 'linear-gradient(135deg,#4f46e5,#6366f1)',
to, '🏨',
subject: 'Подтвердите email — HotelSync', 'Подтверждение email',
html: `<!DOCTYPE html> `<h2 style="color:#1e293b;font-size:20px;margin:0 0 12px;font-weight:600;">Добро пожаловать, ${name}!</h2>
<html> <p style="color:#334155;line-height:1.7;margin:0 0 28px;font-size:15px;">
<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;">
Спасибо за регистрацию в HotelSync. Для активации аккаунта нажмите на кнопку ниже. Спасибо за регистрацию в HotelSync. Для активации аккаунта нажмите на кнопку ниже.
</p> </p>
<div style="text-align:center;margin:0 0 28px;"> <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;"> <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 Подтвердить email
</a> </a>
</div> </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;"> <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;">
Ссылка действительна <strong>24 часа</strong>.<br> Ссылка действительна 24 часа.<br>
Если вы не регистрировались — просто проигнорируйте это письмо. Если вы не регистрировались — просто проигнорируйте это письмо.
</p> </p>
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0;"> <p style="color:#475569;font-size:12px;margin:0;line-height:1.6;">
<p style="color:#94a3b8;font-size:12px;margin:0;line-height:1.6;">
Кнопка не работает? Скопируйте ссылку в браузер:<br> Кнопка не работает? Скопируйте ссылку в браузер:<br>
<a href="${confirmUrl}" style="color:#6366f1;word-break:break-all;font-size:11px;">${confirmUrl}</a> <a href="${confirmUrl}" style="color:#6366f1;word-break:break-all;font-size:11px;">${confirmUrl}</a>
</p> </p>`,
</div> )
</div>
</body> const text = `Подтверждение email — HotelSync\n\обро пожаловать, ${name}!\n\nСпасибо за регистрацию в HotelSync.\n\nДля активации аккаунта перейдите по ссылке (действительна 24 часа):\n${confirmUrl}\n\nЕсли вы не регистрировались — проигнорируйте это письмо.\n\n© 2026 HotelSync`
</html>`,
await transporter.sendMail({
from: `"HotelSync" <${fromAddr()}>`,
to,
subject: 'Подтвердите email — HotelSync',
html,
text,
headers: {
'List-Unsubscribe': unsubscribeHeader,
'Content-Language': 'ru',
},
}) })
} }