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