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 } 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(fmt.Sprintf("Message-ID: %s\r\n", generateMessageID(req.From))) 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 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) }