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