ultisuite-backend/internal/mail/smtp/sender.go
2026-05-24 00:03:36 +02:00

137 lines
3.6 KiB
Go

package smtp
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"strings"
"time"
gosmtp "github.com/emersion/go-smtp"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/mail/connect"
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth"
)
type Sender struct {
db *pgxpool.Pool
logger *slog.Logger
credentials *credentials.Manager
oauth *mailoauth.Service
}
func NewSender(db *pgxpool.Pool, credManager *credentials.Manager, oauthSvc *mailoauth.Service) *Sender {
return &Sender{
db: db,
logger: slog.Default().With("component", "smtp-sender"),
credentials: credManager,
oauth: oauthSvc,
}
}
type SendRequest struct {
AccountID string
From string
To []string
Cc []string
Bcc []string
Subject string
BodyText string
BodyHTML string
InReplyTo string
References []string
Attachments []SendAttachment
}
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)
}
cred, err := s.resolveCredential(ctx, req.AccountID, 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, err := connect.SMTPClient(cred)
if err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
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 generateMessageID(from string) string {
domain := "ultimail.local"
if i := strings.LastIndex(from, "@"); i >= 0 && i < len(from)-1 {
domain = from[i+1:]
}
token := make([]byte, 16)
if _, err := rand.Read(token); err != nil {
token = []byte(fmt.Sprintf("%d", time.Now().UnixNano()))
}
return fmt.Sprintf("<%s@%s>", hex.EncodeToString(token), domain)
}
func (s *Sender) resolveCredential(ctx context.Context, accountID string, creds []byte) (credentials.Credential, error) {
if len(creds) == 0 {
return credentials.Credential{}, errors.New("missing credentials")
}
if !credentials.IsEncrypted(creds) {
return credentials.Credential{}, errors.New("plaintext credentials forbidden")
}
if s.credentials == nil {
return credentials.Credential{}, errors.New("credential manager not configured")
}
cred, err := s.credentials.DecryptCredential(creds)
if err != nil {
return cred, err
}
if s.oauth != nil && cred.IsOAuth() {
return mailoauth.RefreshAccountCredential(ctx, s.db, s.credentials, s.oauth, accountID, cred)
}
return cred, nil
}
// kept for tests that referenced parseCredentials
func (s *Sender) parseCredentials(creds []byte) (string, string, error) {
if s.credentials == nil {
return "", "", errors.New("credential manager not configured")
}
return s.credentials.Decrypt(creds)
}