Files
gooseek/backend/pkg/email/templates.go
home 52134df4d1
Some checks failed
Build and Deploy GooSeek / build-and-deploy (push) Failing after 8m22s
feat: add email notification service with SMTP support
- Create pkg/email package (sender, templates, types)
- SMTP client with TLS, rate limiting, async sending
- HTML email templates with GooSeek branding
- Integrate welcome + password reset emails in auth-svc
- Add limit warning emails (80%/100%) in llm-svc middleware
- Add space invite endpoint with email notification in thread-svc
- Add GetUserEmail helper in JWT middleware
- Add SMTP config to .env, config.go, K8s configmap

Made-with: Cursor
2026-03-03 02:50:17 +03:00

210 lines
9.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package email
import (
"fmt"
"strings"
)
func renderTemplate(notifType NotificationType, data interface{}, siteName, siteURL string) string {
var content string
switch notifType {
case TypeWelcome:
d := data.(WelcomeData)
content = renderWelcome(d)
case TypeEmailVerify:
d := data.(EmailVerifyData)
content = renderEmailVerify(d)
case TypePasswordReset:
d := data.(PasswordResetData)
content = renderPasswordReset(d)
case TypeLimitWarning:
d := data.(LimitWarningData)
content = renderLimitWarning(d)
case TypeSpaceInvite:
d := data.(SpaceInviteData)
content = renderSpaceInvite(d)
case TypeSystemAlert:
d := data.(SystemAlertData)
content = renderSystemAlert(d)
default:
content = "<p>Уведомление</p>"
}
return wrapBase(content, siteName, siteURL)
}
func wrapBase(content, siteName, siteURL string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f4f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7;">
<tr><td align="center" style="padding:40px 20px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<!-- Header -->
<tr><td style="background:linear-gradient(135deg,#6366f1,#8b5cf6);padding:32px 40px;text-align:center;">
<a href="%s" style="text-decoration:none;color:#ffffff;font-size:28px;font-weight:700;letter-spacing:-0.5px;">%s</a>
</td></tr>
<!-- Content -->
<tr><td style="padding:40px;">
%s
</td></tr>
<!-- Footer -->
<tr><td style="padding:24px 40px;background-color:#f9fafb;border-top:1px solid #e5e7eb;text-align:center;">
<p style="margin:0 0 8px;font-size:13px;color:#9ca3af;">
Вы получили это письмо, потому что зарегистрированы на <a href="%s" style="color:#6366f1;text-decoration:none;">%s</a>
</p>
<p style="margin:0;font-size:12px;color:#d1d5db;">
<a href="%s/settings/notifications" style="color:#9ca3af;text-decoration:underline;">Настроить уведомления</a>
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`, siteName, siteURL, siteName, content, siteURL, siteName, siteURL)
}
func renderWelcome(d WelcomeData) string {
return fmt.Sprintf(`
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#111827;">Добро пожаловать, %s!</h1>
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#4b5563;">
Мы рады, что вы присоединились. GooSeek — это ваш AI-помощник для поиска, анализа и работы с информацией.
</p>
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#4b5563;">
Вот что вы можете делать:
</p>
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
<tr><td style="padding:12px 16px;background:#f0fdf4;border-radius:8px;margin-bottom:8px;">
<span style="font-size:20px;">&#x1F50D;</span>
<span style="font-size:15px;color:#166534;margin-left:8px;">Умный поиск с AI-ответами</span>
</td></tr>
<tr><td style="height:8px;"></td></tr>
<tr><td style="padding:12px 16px;background:#eff6ff;border-radius:8px;">
<span style="font-size:20px;">&#x1F4AC;</span>
<span style="font-size:15px;color:#1e40af;margin-left:8px;">Чат с нейросетью</span>
</td></tr>
<tr><td style="height:8px;"></td></tr>
<tr><td style="padding:12px 16px;background:#fdf4ff;border-radius:8px;">
<span style="font-size:20px;">&#x1F4DA;</span>
<span style="font-size:15px;color:#7e22ce;margin-left:8px;">Библиотека и коллекции</span>
</td></tr>
</table>
<a href="%s" style="display:inline-block;padding:14px 32px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#ffffff;text-decoration:none;border-radius:8px;font-size:16px;font-weight:600;">Начать работу</a>
`, escapeHTML(d.Name), d.SiteURL)
}
func renderEmailVerify(d EmailVerifyData) string {
return fmt.Sprintf(`
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#111827;">Подтвердите email</h1>
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#4b5563;">
Здравствуйте, %s! Для завершения регистрации подтвердите ваш email-адрес.
</p>
<a href="%s" style="display:inline-block;padding:14px 32px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#ffffff;text-decoration:none;border-radius:8px;font-size:16px;font-weight:600;">Подтвердить email</a>
<p style="margin:24px 0 0;font-size:13px;color:#9ca3af;">
Если кнопка не работает, скопируйте ссылку:<br>
<a href="%s" style="color:#6366f1;word-break:break-all;">%s</a>
</p>
<p style="margin:16px 0 0;font-size:13px;color:#9ca3af;">Ссылка действительна 24 часа.</p>
`, escapeHTML(d.Name), d.VerifyURL, d.VerifyURL, d.VerifyURL)
}
func renderPasswordReset(d PasswordResetData) string {
return fmt.Sprintf(`
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#111827;">Сброс пароля</h1>
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#4b5563;">
Здравствуйте, %s! Мы получили запрос на сброс пароля для вашего аккаунта.
</p>
<a href="%s" style="display:inline-block;padding:14px 32px;background:linear-gradient(135deg,#ef4444,#dc2626);color:#ffffff;text-decoration:none;border-radius:8px;font-size:16px;font-weight:600;">Сбросить пароль</a>
<p style="margin:24px 0 0;font-size:13px;color:#9ca3af;">
Если кнопка не работает, скопируйте ссылку:<br>
<a href="%s" style="color:#6366f1;word-break:break-all;">%s</a>
</p>
<p style="margin:16px 0 0;font-size:13px;color:#9ca3af;">Ссылка действительна 1 час. Если вы не запрашивали сброс — проигнорируйте это письмо.</p>
`, escapeHTML(d.Name), d.ResetURL, d.ResetURL, d.ResetURL)
}
func renderLimitWarning(d LimitWarningData) string {
barColor := "#f59e0b"
if d.Percentage >= 100 {
barColor = "#ef4444"
}
tierLabel := "Бесплатный"
switch d.Tier {
case "pro":
tierLabel = "Pro"
case "business":
tierLabel = "Business"
}
barWidth := d.Percentage
if barWidth > 100 {
barWidth = 100
}
return fmt.Sprintf(`
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#111827;">Лимит запросов</h1>
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#4b5563;">
Здравствуйте, %s! Вы использовали <strong>%d%%</strong> дневного лимита запросов на тарифе <strong>%s</strong>.
</p>
<!-- Progress bar -->
<div style="background:#e5e7eb;border-radius:8px;height:12px;margin-bottom:8px;overflow:hidden;">
<div style="background:%s;height:12px;width:%d%%;border-radius:8px;"></div>
</div>
<p style="margin:0 0 24px;font-size:14px;color:#6b7280;">%d из %d запросов</p>
<a href="%s" style="display:inline-block;padding:14px 32px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#ffffff;text-decoration:none;border-radius:8px;font-size:16px;font-weight:600;">Увеличить лимит</a>
`, escapeHTML(d.Name), d.Percentage, tierLabel, barColor, barWidth, d.UsageCount, d.LimitCount, d.UpgradeURL)
}
func renderSpaceInvite(d SpaceInviteData) string {
return fmt.Sprintf(`
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#111827;">Приглашение в Space</h1>
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#4b5563;">
<strong>%s</strong> приглашает вас присоединиться к пространству <strong>«%s»</strong>.
</p>
<a href="%s" style="display:inline-block;padding:14px 32px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#ffffff;text-decoration:none;border-radius:8px;font-size:16px;font-weight:600;">Принять приглашение</a>
<p style="margin:24px 0 0;font-size:13px;color:#9ca3af;">
Если кнопка не работает, скопируйте ссылку:<br>
<a href="%s" style="color:#6366f1;word-break:break-all;">%s</a>
</p>
`, escapeHTML(d.InviterName), escapeHTML(d.SpaceName), d.InviteURL, d.InviteURL, d.InviteURL)
}
func renderSystemAlert(d SystemAlertData) string {
detailsBlock := ""
if d.Details != "" {
detailsBlock = fmt.Sprintf(`
<pre style="background:#f3f4f6;padding:16px;border-radius:8px;font-size:13px;color:#374151;overflow-x:auto;white-space:pre-wrap;">%s</pre>
`, escapeHTML(d.Details))
}
return fmt.Sprintf(`
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#111827;">%s</h1>
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#4b5563;">%s</p>
%s
`, escapeHTML(d.Title), escapeHTML(d.Message), detailsBlock)
}
func escapeHTML(s string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&#39;",
)
return r.Replace(s)
}