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)
}
}()
}