Files
gooseek/backend/pkg/email/sender.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

215 lines
5.5 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 (
"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)
}
}()
}