package smtp import ( "context" "crypto/rand" "encoding/hex" "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 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) } 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 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) 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) }