feat: add email notification service with SMTP support
Some checks failed
Build and Deploy GooSeek / build-and-deploy (push) Failing after 8m22s
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:
214
backend/pkg/email/sender.go
Normal file
214
backend/pkg/email/sender.go
Normal 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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user