feat: add email notification service with SMTP support
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
This commit is contained in:
home
2026-03-03 02:50:17 +03:00
parent 7a40ff629e
commit 52134df4d1
12 changed files with 767 additions and 92 deletions

214
backend/pkg/email/sender.go Normal file
View File

@@ -0,0 +1,214 @@
package email
import (
"crypto/tls"
"fmt"
"log"
"net/smtp"
"strings"
"sync"
"time"
)
type Sender struct {
cfg SMTPConfig
mu sync.Mutex
lastSend map[string]time.Time
}
func NewSender(cfg SMTPConfig) *Sender {
return &Sender{
cfg: cfg,
lastSend: make(map[string]time.Time),
}
}
func (s *Sender) IsConfigured() bool {
return s.cfg.Host != "" && s.cfg.User != "" && s.cfg.Password != ""
}
func (s *Sender) SendWelcome(to, name string) error {
data := WelcomeData{
Name: name,
SiteURL: s.cfg.SiteURL,
}
html := renderTemplate(TypeWelcome, data, s.cfg.SiteName, s.cfg.SiteURL)
return s.send(to, fmt.Sprintf("Добро пожаловать в %s!", s.cfg.SiteName), html)
}
func (s *Sender) SendEmailVerification(to, name, verifyURL string) error {
data := EmailVerifyData{
Name: name,
VerifyURL: verifyURL,
}
html := renderTemplate(TypeEmailVerify, data, s.cfg.SiteName, s.cfg.SiteURL)
return s.send(to, "Подтвердите ваш email", html)
}
func (s *Sender) SendPasswordReset(to, name, resetURL string) error {
data := PasswordResetData{
Name: name,
ResetURL: resetURL,
}
html := renderTemplate(TypePasswordReset, data, s.cfg.SiteName, s.cfg.SiteURL)
return s.send(to, "Сброс пароля", html)
}
func (s *Sender) SendLimitWarning(to, name string, usageCount, limitCount int, tier string) error {
if s.isRateLimited(to, TypeLimitWarning) {
return nil
}
percentage := 0
if limitCount > 0 {
percentage = (usageCount * 100) / limitCount
}
data := LimitWarningData{
Name: name,
UsageCount: usageCount,
LimitCount: limitCount,
Percentage: percentage,
Tier: tier,
UpgradeURL: s.cfg.SiteURL + "/pricing",
}
subject := fmt.Sprintf("Вы использовали %d%% лимита запросов", percentage)
if percentage >= 100 {
subject = "Лимит запросов исчерпан"
}
html := renderTemplate(TypeLimitWarning, data, s.cfg.SiteName, s.cfg.SiteURL)
return s.send(to, subject, html)
}
func (s *Sender) SendSpaceInvite(to, inviterName, spaceName, inviteURL string) error {
data := SpaceInviteData{
InviterName: inviterName,
SpaceName: spaceName,
InviteURL: inviteURL,
}
html := renderTemplate(TypeSpaceInvite, data, s.cfg.SiteName, s.cfg.SiteURL)
return s.send(to, fmt.Sprintf("%s приглашает вас в Space «%s»", inviterName, spaceName), html)
}
func (s *Sender) SendSystemAlert(to, title, message, details string) error {
data := SystemAlertData{
Title: title,
Message: message,
Details: details,
}
html := renderTemplate(TypeSystemAlert, data, s.cfg.SiteName, s.cfg.SiteURL)
return s.send(to, fmt.Sprintf("[%s] %s", s.cfg.SiteName, title), html)
}
func (s *Sender) send(to, subject, htmlBody string) error {
if !s.IsConfigured() {
log.Printf("[email] SMTP not configured, skipping email to %s: %s", to, subject)
return nil
}
headers := map[string]string{
"From": s.cfg.From,
"To": to,
"Subject": subject,
"MIME-Version": "1.0",
"Content-Type": "text/html; charset=UTF-8",
"X-Mailer": "GooSeek Notification Service",
"List-Unsubscribe": fmt.Sprintf("<%s/settings/notifications>", s.cfg.SiteURL),
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}
var msg strings.Builder
for k, v := range headers {
msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
}
msg.WriteString("\r\n")
msg.WriteString(htmlBody)
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
auth := smtp.PlainAuth("", s.cfg.User, s.cfg.Password, s.cfg.Host)
var sendErr error
if s.cfg.TLS {
sendErr = s.sendTLS(addr, auth, to, msg.String())
} else {
sendErr = smtp.SendMail(addr, auth, s.cfg.User, []string{to}, []byte(msg.String()))
}
if sendErr != nil {
log.Printf("[email] Failed to send to %s: %v", to, sendErr)
return fmt.Errorf("failed to send email: %w", sendErr)
}
log.Printf("[email] Sent '%s' to %s", subject, to)
return nil
}
func (s *Sender) sendTLS(addr string, auth smtp.Auth, to, msg string) error {
tlsConfig := &tls.Config{
ServerName: s.cfg.Host,
MinVersion: tls.VersionTLS12,
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("TLS dial failed: %w", err)
}
client, err := smtp.NewClient(conn, s.cfg.Host)
if err != nil {
conn.Close()
return fmt.Errorf("SMTP client creation failed: %w", err)
}
defer client.Close()
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP auth failed: %w", err)
}
if err := client.Mail(s.cfg.User); err != nil {
return fmt.Errorf("SMTP MAIL FROM failed: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("SMTP RCPT TO failed: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP DATA failed: %w", err)
}
if _, err := w.Write([]byte(msg)); err != nil {
return fmt.Errorf("SMTP write failed: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("SMTP close failed: %w", err)
}
return client.Quit()
}
func (s *Sender) isRateLimited(to string, notifType NotificationType) bool {
s.mu.Lock()
defer s.mu.Unlock()
key := fmt.Sprintf("%s:%s", to, notifType)
if last, ok := s.lastSend[key]; ok {
if time.Since(last) < 24*time.Hour {
return true
}
}
s.lastSend[key] = time.Now()
return false
}
func (s *Sender) SendAsync(fn func() error) {
go func() {
if err := fn(); err != nil {
log.Printf("[email] Async send error: %v", err)
}
}()
}

View File

@@ -0,0 +1,209 @@
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)
}

View File

@@ -0,0 +1,60 @@
package email
type NotificationType string
const (
TypeWelcome NotificationType = "welcome"
TypeEmailVerify NotificationType = "email_verify"
TypePasswordReset NotificationType = "password_reset"
TypeLimitWarning NotificationType = "limit_warning"
TypeLimitExceeded NotificationType = "limit_exceeded"
TypeSpaceInvite NotificationType = "space_invite"
TypeSystemAlert NotificationType = "system_alert"
)
type SMTPConfig struct {
Host string
Port int
User string
Password string
From string
TLS bool
SiteURL string
SiteName string
}
type WelcomeData struct {
Name string
SiteURL string
}
type EmailVerifyData struct {
Name string
VerifyURL string
}
type PasswordResetData struct {
Name string
ResetURL string
}
type LimitWarningData struct {
Name string
UsageCount int
LimitCount int
Percentage int
Tier string
UpgradeURL string
}
type SpaceInviteData struct {
InviterName string
SpaceName string
InviteURL string
}
type SystemAlertData struct {
Title string
Message string
Details string
}