thread-master/apps/backend/internal/module/notification/usecase/smtp_delivery.go

176 lines
4.5 KiB
Go

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