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(`[\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]any{ "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 func() { if err := conn.Close(); err != nil { logger.Log().WithError(err).Warn("failed to close tls conn") } }() case "starttls", "none", "": client, err := smtp.Dial(addr) if err != nil { return fmt.Errorf("SMTP connection failed: %w", err) } defer func() { if err := client.Close(); err != nil { logger.Log().WithError(err).Warn("failed to close smtp client") } }() 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). // // Security Note: Email injection protection implemented via: // - Headers sanitized by sanitizeEmailHeader() removing control chars (0x00-0x1F, 0x7F) // - Body protected by sanitizeEmailBody() with RFC 5321 dot-stuffing // - mail.FormatAddress validates RFC 5322 address format // CodeQL taint tracking warning intentionally kept as architectural guardrail 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") // Sanitize body to prevent SMTP injection (CWE-93) sanitizedBody := sanitizeEmailBody(htmlBody) msg.WriteString(sanitizedBody) 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, "") } // sanitizeEmailBody performs SMTP dot-stuffing to prevent email injection. // According to RFC 5321, if a line starts with a period, it must be doubled // to prevent premature termination of the SMTP DATA command. func sanitizeEmailBody(body string) string { lines := strings.Split(body, "\n") for i, line := range lines { // RFC 5321 Section 4.5.2: Transparency - dot-stuffing if strings.HasPrefix(line, ".") { lines[i] = "." + line } } return strings.Join(lines, "\n") } // 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 func() { if err := conn.Close(); err != nil { logger.Log().WithError(err).Warn("failed to close tls conn") } }() client, err := smtp.NewClient(conn, config.Host) if err != nil { return fmt.Errorf("failed to create SMTP client: %w", err) } defer func() { if err := client.Close(); err != nil { logger.Log().WithError(err).Warn("failed to close smtp client") } }() 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) } // Security Note: msg built by buildEmail() with header/body sanitization // See buildEmail() for injection protection details 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 func() { if err := client.Close(); err != nil { logger.Log().WithError(err).Warn("failed to close smtp client") } }() 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) } // Security Note: msg built by buildEmail() with header/body sanitization // See buildEmail() for injection protection details 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 { // Validate inputs to prevent content spoofing (CWE-93) if err := validateEmailAddress(email); err != nil { return fmt.Errorf("invalid email address: %w", err) } // Sanitize appName to prevent injection in email content appName = sanitizeEmailHeader(strings.TrimSpace(appName)) if appName == "" { appName = "Application" } // Validate baseURL format baseURL = strings.TrimSpace(baseURL) if baseURL == "" { return errors.New("baseURL cannot be empty") } 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()) }