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:
@@ -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\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\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',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user