Some checks failed
Build and Deploy GooSeek / build-and-deploy (push) Failing after 8m22s
- 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
210 lines
9.2 KiB
Go
210 lines
9.2 KiB
Go
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;">🔍</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;">💬</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;">📚</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(
|
||
"&", "&",
|
||
"<", "<",
|
||
">", ">",
|
||
`"`, """,
|
||
"'", "'",
|
||
)
|
||
return r.Replace(s)
|
||
}
|