package usecase import ( "context" "crypto/tls" "fmt" "net" "net/smtp" "strings" "time" "apps/backend/internal/module/notification/domain" ) // SMTPConfig for generic SMTP (Mailgun, SES SMTP, Mailpit, etc.). // Mailpit local: Host=127.0.0.1 Port=1025 User/Password empty (or any if MP_SMTP_AUTH_ACCEPT_ANY). // Mailgun: Host=smtp.mailgun.org Port=587 User=postmaster@domain Password=key. type SMTPConfig struct { Host string Port int User string Password string } // SMTPDelivery sends HTML mail via SMTP (optional AUTH). type SMTPDelivery struct { cfg SMTPConfig } func NewSMTPDelivery(cfg SMTPConfig) *SMTPDelivery { if cfg.Port <= 0 { // Mailpit default is 1025; production SMTP usually 587 cfg.Port = 587 } return &SMTPDelivery{cfg: cfg} } func (d *SMTPDelivery) Name() string { return "smtp" } func (d *SMTPDelivery) SendEmail(ctx context.Context, req domain.EmailRequest) error { if d.cfg.Host == "" { return fmt.Errorf("smtp: empty host") } fromAddr := extractEmail(req.SenderEmail) if fromAddr == "" { return fmt.Errorf("smtp: invalid sender") } to := strings.TrimSpace(req.RecipientEmail) if to == "" { return fmt.Errorf("smtp: empty recipient") } msg := buildMIME(req.SenderEmail, to, req.Subject, req.HTMLBody) addr := fmt.Sprintf("%s:%d", d.cfg.Host, d.cfg.Port) // Prefer STARTTLS on 587; plain TLS on 465. dialer := &net.Dialer{Timeout: 15 * time.Second} conn, err := dialer.DialContext(ctx, "tcp", addr) if err != nil { return fmt.Errorf("smtp dial: %w", err) } var client *smtp.Client if d.cfg.Port == 465 { tlsConn := tls.Client(conn, &tls.Config{ServerName: d.cfg.Host, MinVersion: tls.VersionTLS12}) client, err = smtp.NewClient(tlsConn, d.cfg.Host) if err != nil { _ = conn.Close() return fmt.Errorf("smtp tls client: %w", err) } } else { client, err = smtp.NewClient(conn, d.cfg.Host) if err != nil { _ = conn.Close() return fmt.Errorf("smtp client: %w", err) } if ok, _ := client.Extension("STARTTLS"); ok { if err := client.StartTLS(&tls.Config{ServerName: d.cfg.Host, MinVersion: tls.VersionTLS12}); err != nil { _ = client.Close() return fmt.Errorf("smtp starttls: %w", err) } } } defer client.Close() if d.cfg.User != "" { auth := smtp.PlainAuth("", d.cfg.User, d.cfg.Password, d.cfg.Host) if err := client.Auth(auth); err != nil { return fmt.Errorf("smtp auth: %w", err) } } if err := client.Mail(fromAddr); err != nil { return fmt.Errorf("smtp mail: %w", err) } if err := client.Rcpt(to); err != nil { return fmt.Errorf("smtp rcpt: %w", err) } w, err := client.Data() if err != nil { return fmt.Errorf("smtp data: %w", err) } if _, err := w.Write(msg); err != nil { _ = w.Close() return fmt.Errorf("smtp write: %w", err) } if err := w.Close(); err != nil { return fmt.Errorf("smtp close: %w", err) } return client.Quit() } func extractEmail(from string) string { from = strings.TrimSpace(from) if i := strings.LastIndex(from, "<"); i >= 0 { j := strings.LastIndex(from, ">") if j > i { return strings.TrimSpace(from[i+1 : j]) } } return from } func buildMIME(from, to, subject, html string) []byte { var b strings.Builder b.WriteString("From: " + from + "\r\n") b.WriteString("To: " + to + "\r\n") b.WriteString("Subject: " + encodeSubject(subject) + "\r\n") b.WriteString("MIME-Version: 1.0\r\n") b.WriteString("Content-Type: text/html; charset=UTF-8\r\n") b.WriteString("\r\n") b.WriteString(html) return []byte(b.String()) } // encodeSubject keeps ASCII subjects as-is; non-ASCII uses a simple UTF-8 Q-encoding header. func encodeSubject(s string) string { for _, r := range s { if r > 127 { // RFC 2047 return "=?UTF-8?B?" + b64(s) + "?=" } } return s } func b64(s string) string { const table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" src := []byte(s) var out strings.Builder for i := 0; i < len(src); i += 3 { var n uint32 remain := len(src) - i switch { case remain >= 3: n = uint32(src[i])<<16 | uint32(src[i+1])<<8 | uint32(src[i+2]) out.WriteByte(table[(n>>18)&63]) out.WriteByte(table[(n>>12)&63]) out.WriteByte(table[(n>>6)&63]) out.WriteByte(table[n&63]) case remain == 2: n = uint32(src[i])<<16 | uint32(src[i+1])<<8 out.WriteByte(table[(n>>18)&63]) out.WriteByte(table[(n>>12)&63]) out.WriteByte(table[(n>>6)&63]) out.WriteByte('=') case remain == 1: n = uint32(src[i]) << 16 out.WriteByte(table[(n>>18)&63]) out.WriteByte(table[(n>>12)&63]) out.WriteByte('=') out.WriteByte('=') } } return out.String() }