ultisuite-backend/internal/mail/smtp/sender.go

138 lines
3.7 KiB
Go

package smtp
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/emersion/go-sasl"
gosmtp "github.com/emersion/go-smtp"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
)
type Sender struct {
db *pgxpool.Pool
logger *slog.Logger
credentials *credentials.Manager
}
func NewSender(db *pgxpool.Pool, credManager *credentials.Manager) *Sender {
return &Sender{
db: db,
logger: slog.Default().With("component", "smtp-sender"),
credentials: credManager,
}
}
type SendRequest struct {
AccountID string
From string
To []string
Cc []string
Bcc []string
Subject string
BodyText string
BodyHTML string
InReplyTo string
References []string
}
func (s *Sender) Send(ctx context.Context, req *SendRequest) error {
var host string
var port int
var useTLS bool
var creds []byte
err := s.db.QueryRow(ctx, `
SELECT smtp_host, smtp_port, smtp_tls, credentials
FROM mail_accounts WHERE id = $1
`, req.AccountID).Scan(&host, &port, &useTLS, &creds)
if err != nil {
return fmt.Errorf("query account: %w", err)
}
username, password, err := s.parseCredentials(creds)
if err != nil {
return fmt.Errorf("decrypt credentials: %w", err)
}
addr := fmt.Sprintf("%s:%d", host, port)
msg := buildMessage(req)
allRecipients := make([]string, 0, len(req.To)+len(req.Cc)+len(req.Bcc))
allRecipients = append(allRecipients, req.To...)
allRecipients = append(allRecipients, req.Cc...)
allRecipients = append(allRecipients, req.Bcc...)
auth := sasl.NewPlainClient("", username, password)
var sendErr error
if useTLS {
sendErr = gosmtp.SendMailTLS(addr, auth, req.From, allRecipients, strings.NewReader(msg))
} else {
sendErr = gosmtp.SendMail(addr, auth, req.From, allRecipients, strings.NewReader(msg))
}
if sendErr != nil {
return fmt.Errorf("send: %w", sendErr)
}
s.logger.Info("email sent", "from", req.From, "to", req.To, "subject", req.Subject)
return nil
}
func buildMessage(req *SendRequest) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("From: %s\r\n", req.From))
b.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(req.To, ", ")))
if len(req.Cc) > 0 {
b.WriteString(fmt.Sprintf("Cc: %s\r\n", strings.Join(req.Cc, ", ")))
}
b.WriteString(fmt.Sprintf("Subject: %s\r\n", req.Subject))
b.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().Format(time.RFC1123Z)))
b.WriteString("MIME-Version: 1.0\r\n")
if req.InReplyTo != "" {
b.WriteString(fmt.Sprintf("In-Reply-To: %s\r\n", req.InReplyTo))
}
if len(req.References) > 0 {
b.WriteString(fmt.Sprintf("References: %s\r\n", strings.Join(req.References, " ")))
}
if req.BodyHTML != "" {
boundary := fmt.Sprintf("----=_Part_%d", time.Now().UnixNano())
b.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
b.WriteString("\r\n")
b.WriteString(fmt.Sprintf("--%s\r\n", boundary))
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
b.WriteString(req.BodyText)
b.WriteString(fmt.Sprintf("\r\n--%s\r\n", boundary))
b.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n")
b.WriteString(req.BodyHTML)
b.WriteString(fmt.Sprintf("\r\n--%s--\r\n", boundary))
} else {
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
b.WriteString(req.BodyText)
}
return b.String()
}
func (s *Sender) parseCredentials(creds []byte) (string, string, error) {
if len(creds) == 0 {
return "", "", errors.New("missing credentials")
}
if !credentials.IsEncrypted(creds) {
return "", "", errors.New("plaintext credentials forbidden")
}
if s.credentials == nil {
return "", "", errors.New("credential manager not configured")
}
return s.credentials.Decrypt(creds)
}