package services import ( "bytes" "crypto/tls" "errors" "fmt" "html/template" "net/mail" "net/smtp" "regexp" "strings" "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/models" "gorm.io/gorm" ) // emailHeaderSanitizer removes CR, LF, and other control characters that could // enable header injection attacks (CWE-93: Improper Neutralization of CRLF). var emailHeaderSanitizer = regexp.MustCompile(`[\r\n\x00-\x1f\x7f]`) // SMTPConfig holds the SMTP server configuration. type SMTPConfig struct { Host string `json:"host"` Port int `json:"port"` Username string `json:"username"` Password string `json:"password"` FromAddress string `json:"from_address"` Encryption string `json:"encryption"` // "none", "ssl", "starttls" } // MailService handles sending emails via SMTP. type MailService struct { db *gorm.DB } // NewMailService creates a new mail service instance. func NewMailService(db *gorm.DB) *MailService { return &MailService{db: db} } // GetSMTPConfig retrieves SMTP settings from the database. func (s *MailService) GetSMTPConfig() (*SMTPConfig, error) { var settings []models.Setting if err := s.db.Where("category = ?", "smtp").Find(&settings).Error; err != nil { return nil, fmt.Errorf("failed to load SMTP settings: %w", err) } config := &SMTPConfig{ Port: 587, // Default port Encryption: "starttls", } for _, setting := range settings { switch setting.Key { case "smtp_host": config.Host = setting.Value case "smtp_port": if _, err := fmt.Sscanf(setting.Value, "%d", &config.Port); err != nil { config.Port = 587 } case "smtp_username": config.Username = setting.Value case "smtp_password": config.Password = setting.Value case "smtp_from_address": config.FromAddress = setting.Value case "smtp_encryption": config.Encryption = setting.Value } } return config, nil } // SaveSMTPConfig saves SMTP settings to the database. func (s *MailService) SaveSMTPConfig(config *SMTPConfig) error { settings := map[string]string{ "smtp_host": config.Host, "smtp_port": fmt.Sprintf("%d", config.Port), "smtp_username": config.Username, "smtp_password": config.Password, "smtp_from_address": config.FromAddress, "smtp_encryption": config.Encryption, } for key, value := range settings { setting := models.Setting{ Key: key, Value: value, Type: "string", Category: "smtp", } // Upsert: update if exists, create if not result := s.db.Where("key = ?", key).First(&models.Setting{}) if result.Error == gorm.ErrRecordNotFound { if err := s.db.Create(&setting).Error; err != nil { return fmt.Errorf("failed to create setting %s: %w", key, err) } } else { if err := s.db.Model(&models.Setting{}).Where("key = ?", key).Updates(map[string]interface{}{ "value": value, "category": "smtp", }).Error; err != nil { return fmt.Errorf("failed to update setting %s: %w", key, err) } } } return nil } // IsConfigured returns true if SMTP is properly configured. func (s *MailService) IsConfigured() bool { config, err := s.GetSMTPConfig() if err != nil { return false } return config.Host != "" && config.FromAddress != "" } // TestConnection tests the SMTP connection without sending an email. func (s *MailService) TestConnection() error { config, err := s.GetSMTPConfig() if err != nil { return err } if config.Host == "" { return errors.New("SMTP host not configured") } addr := fmt.Sprintf("%s:%d", config.Host, config.Port) // Try to connect based on encryption type switch config.Encryption { case "ssl": tlsConfig := &tls.Config{ ServerName: config.Host, MinVersion: tls.VersionTLS12, } conn, err := tls.Dial("tcp", addr, tlsConfig) if err != nil { return fmt.Errorf("SSL connection failed: %w", err) } defer conn.Close() case "starttls", "none", "": client, err := smtp.Dial(addr) if err != nil { return fmt.Errorf("SMTP connection failed: %w", err) } defer client.Close() if config.Encryption == "starttls" { tlsConfig := &tls.Config{ ServerName: config.Host, MinVersion: tls.VersionTLS12, } if err := client.StartTLS(tlsConfig); err != nil { return fmt.Errorf("STARTTLS failed: %w", err) } } // Try authentication if credentials are provided if config.Username != "" && config.Password != "" { auth := smtp.PlainAuth("", config.Username, config.Password, config.Host) if err := client.Auth(auth); err != nil { return fmt.Errorf("authentication failed: %w", err) } } } return nil } // SendEmail sends an email using the configured SMTP settings. // The to address and subject are sanitized to prevent header injection. func (s *MailService) SendEmail(to, subject, htmlBody string) error { config, err := s.GetSMTPConfig() if err != nil { return err } if config.Host == "" { return errors.New("SMTP not configured") } // Validate email addresses to prevent injection attacks if err := validateEmailAddress(to); err != nil { return fmt.Errorf("invalid recipient address: %w", err) } if err := validateEmailAddress(config.FromAddress); err != nil { return fmt.Errorf("invalid from address: %w", err) } // Build the email message (headers are sanitized in buildEmail) msg := s.buildEmail(config.FromAddress, to, subject, htmlBody) addr := fmt.Sprintf("%s:%d", config.Host, config.Port) var auth smtp.Auth if config.Username != "" && config.Password != "" { auth = smtp.PlainAuth("", config.Username, config.Password, config.Host) } switch config.Encryption { case "ssl": return s.sendSSL(addr, config, auth, to, msg) case "starttls": return s.sendSTARTTLS(addr, config, auth, to, msg) default: return smtp.SendMail(addr, auth, config.FromAddress, []string{to}, msg) } } // buildEmail constructs a properly formatted email message with sanitized headers. // All header values are sanitized to prevent email header injection (CWE-93). func (s *MailService) buildEmail(from, to, subject, htmlBody string) []byte { // Sanitize all header values to prevent CRLF injection sanitizedFrom := sanitizeEmailHeader(from) sanitizedTo := sanitizeEmailHeader(to) sanitizedSubject := sanitizeEmailHeader(subject) headers := make(map[string]string) headers["From"] = sanitizedFrom headers["To"] = sanitizedTo headers["Subject"] = sanitizedSubject headers["MIME-Version"] = "1.0" headers["Content-Type"] = "text/html; charset=UTF-8" var msg bytes.Buffer for key, value := range headers { msg.WriteString(fmt.Sprintf("%s: %s\r\n", key, value)) } msg.WriteString("\r\n") msg.WriteString(htmlBody) return msg.Bytes() } // sanitizeEmailHeader removes CR, LF, and control characters from email header // values to prevent email header injection attacks (CWE-93). func sanitizeEmailHeader(value string) string { return emailHeaderSanitizer.ReplaceAllString(value, "") } // validateEmailAddress validates that an email address is well-formed. // Returns an error if the address is invalid. func validateEmailAddress(email string) error { if email == "" { return errors.New("email address is empty") } _, err := mail.ParseAddress(email) if err != nil { return fmt.Errorf("invalid email address: %w", err) } return nil } // sendSSL sends email using direct SSL/TLS connection. func (s *MailService) sendSSL(addr string, config *SMTPConfig, auth smtp.Auth, to string, msg []byte) error { tlsConfig := &tls.Config{ ServerName: config.Host, MinVersion: tls.VersionTLS12, } conn, err := tls.Dial("tcp", addr, tlsConfig) if err != nil { return fmt.Errorf("SSL connection failed: %w", err) } defer conn.Close() client, err := smtp.NewClient(conn, config.Host) if err != nil { return fmt.Errorf("failed to create SMTP client: %w", err) } defer client.Close() if auth != nil { if err := client.Auth(auth); err != nil { return fmt.Errorf("authentication failed: %w", err) } } if err := client.Mail(config.FromAddress); err != nil { return fmt.Errorf("MAIL FROM failed: %w", err) } if err := client.Rcpt(to); err != nil { return fmt.Errorf("RCPT TO failed: %w", err) } w, err := client.Data() if err != nil { return fmt.Errorf("DATA failed: %w", err) } if _, err := w.Write(msg); err != nil { return fmt.Errorf("failed to write message: %w", err) } if err := w.Close(); err != nil { return fmt.Errorf("failed to close data writer: %w", err) } return client.Quit() } // sendSTARTTLS sends email using STARTTLS. func (s *MailService) sendSTARTTLS(addr string, config *SMTPConfig, auth smtp.Auth, to string, msg []byte) error { client, err := smtp.Dial(addr) if err != nil { return fmt.Errorf("SMTP connection failed: %w", err) } defer client.Close() tlsConfig := &tls.Config{ ServerName: config.Host, MinVersion: tls.VersionTLS12, } if err := client.StartTLS(tlsConfig); err != nil { return fmt.Errorf("STARTTLS failed: %w", err) } if auth != nil { if err := client.Auth(auth); err != nil { return fmt.Errorf("authentication failed: %w", err) } } if err := client.Mail(config.FromAddress); err != nil { return fmt.Errorf("MAIL FROM failed: %w", err) } if err := client.Rcpt(to); err != nil { return fmt.Errorf("RCPT TO failed: %w", err) } w, err := client.Data() if err != nil { return fmt.Errorf("DATA failed: %w", err) } if _, err := w.Write(msg); err != nil { return fmt.Errorf("failed to write message: %w", err) } if err := w.Close(); err != nil { return fmt.Errorf("failed to close data writer: %w", err) } return client.Quit() } // SendInvite sends an invitation email to a new user. func (s *MailService) SendInvite(email, inviteToken, appName, baseURL string) error { inviteURL := fmt.Sprintf("%s/accept-invite?token=%s", strings.TrimSuffix(baseURL, "/"), inviteToken) tmpl := ` You've been invited to {{.AppName}}

{{.AppName}}

You've Been Invited!

You've been invited to join {{.AppName}}. Click the button below to set up your account:

Accept Invitation

This invitation link will expire in 48 hours.

If you didn't expect this invitation, you can safely ignore this email.


If the button doesn't work, copy and paste this link into your browser:
{{.InviteURL}}

` t, err := template.New("invite").Parse(tmpl) if err != nil { return fmt.Errorf("failed to parse email template: %w", err) } var body bytes.Buffer data := map[string]string{ "AppName": appName, "InviteURL": inviteURL, } if err := t.Execute(&body, data); err != nil { return fmt.Errorf("failed to execute email template: %w", err) } subject := fmt.Sprintf("You've been invited to %s", appName) logger.Log().WithField("email", email).Info("Sending invite email") return s.SendEmail(email, subject, body.String()) }