package email import ( "encoding/json" "regexp" "sort" "strings" "unicode" ) const maxErrorBody = 16 << 10 const maxErrorMessage = 512 var ( errorName = regexp.MustCompile(`^[a-zA-Z0-9_.-]{1,64}$`) addresses = regexp.MustCompile(`[^\s<>"'(),;:]+@[^\s<>"'(),;:]+`) urls = regexp.MustCompile(`(?i)https?://[^\s<>"']+`) tokens = regexp.MustCompile(`[a-zA-Z0-9_+/-]{32,}={0,2}`) numbers = regexp.MustCompile(`[0-9]{4,}`) ) // Only the documented human-readable error fields cross the adapter boundary. // Full bodies, headers and request data must never reach logs or public failures. func providerFailure(body, request []byte, secrets ...string) (string, string) { var dto struct { Name string `json:"name"` Message string `json:"message"` Errors []struct { Message string `json:"message"` } `json:"errors"` } if err := json.Unmarshal(body, &dto); err != nil { return "", "Provider error details unavailable: invalid JSON response" } if dto.Message == "" && len(dto.Errors) > 0 { dto.Message = dto.Errors[0].Message } // Providers can echo any scalar string from their request DTOs. Strip those // values before truncation, in addition to credentials and embedded tokens. var payload any if json.Unmarshal(request, &payload) == nil { for _, value := range requestStrings(payload) { secrets = append(secrets, value) // Rendered login/verification codes and numeric account references // can be echoed as fragments of an otherwise longer email body. secrets = append(secrets, numbers.FindAllString(value, -1)...) } } name := safeErrorText(dto.Name, secrets) if !errorName.MatchString(name) { name = "" } message := safeErrorText(dto.Message, secrets) if message == "" { message = "Provider did not include an error message" } return name, message } func requestStrings(value any) []string { var values []string switch value := value.(type) { case string: values = append(values, value) case []any: for _, item := range value { values = append(values, requestStrings(item)...) } case map[string]any: for _, item := range value { values = append(values, requestStrings(item)...) } } return values } func safeErrorText(text string, secrets []string) string { sort.Slice(secrets, func(i, j int) bool { return len(secrets[i]) > len(secrets[j]) }) var replacements []string for _, secret := range secrets { if secret != "" { replacements = append(replacements, secret, "[redacted]") } } text = strings.NewReplacer(replacements...).Replace(text) text = addresses.ReplaceAllString(text, "[redacted-address]") text = urls.ReplaceAllString(text, "[redacted-url]") text = tokens.ReplaceAllString(text, "[redacted-token]") text = strings.Join(strings.FieldsFunc(text, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) }), " ") runes := []rune(text) if len(runes) > maxErrorMessage { text = string(runes[:maxErrorMessage]) + "…" } return text }