// Package stringutil provides string utility functions. package stringutil import ( "crypto/rand" "fmt" "net/mail" "path/filepath" "regexp" "strings" "time" "github.com/jaytaylor/html2text" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/renderer/html" ) const ( PasswordDummy = "•" ) var ( regexpNonAlNum = regexp.MustCompile(`[^a-zA-Z0-9\-_\.]+`) regexpSpaces = regexp.MustCompile(`[\s]+`) uuidV4Regex = regexp.MustCompile(`[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[89abAB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}`) regexpRefNumber = regexp.MustCompile(`#(\d+)`) regexpConvUUID = regexp.MustCompile(`(?i)\+conv-[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}@`) // markdownRenderer escapes raw HTML in the input; single newlines render as
. markdownRenderer = goldmark.New( goldmark.WithExtensions(extension.GFM), goldmark.WithRendererOptions(html.WithHardWraps()), ) ) // SanitizeUTF8 removes NUL bytes and replaces invalid UTF-8 byte sequences with the Unicode replacement character. func SanitizeUTF8(s string) string { if s == "" { return s } s = strings.ReplaceAll(s, "\x00", "") return strings.ToValidUTF8(s, "�") } // HTML2Text converts HTML to plain text, dropping link URLs. func HTML2Text(html string) string { return htmlToText(html, html2text.Options{TextOnly: true}) } // Markdown2HTML converts markdown to HTML, falling back to the input on error. func Markdown2HTML(md string) string { var b strings.Builder if err := markdownRenderer.Convert([]byte(md), &b); err != nil { return md } return b.String() } // SanitizeFilename sanitizes the provided filename. func SanitizeFilename(fName string) string { // Trim whitespace. name := strings.TrimSpace(fName) // Replace whitespace and "/" with "-" name = regexpSpaces.ReplaceAllString(name, "-") // Remove or replace any non-alphanumeric characters name = regexpNonAlNum.ReplaceAllString(name, "") // Convert to lowercase name = strings.ToLower(name) return filepath.Base(name) } // RandomAlphanumeric generates a random alphanumeric string of length n. func RandomAlphanumeric(n int) (string, error) { const dictionary = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" bytes := make([]byte, n) if _, err := rand.Read(bytes); err != nil { return "", err } for k, v := range bytes { bytes[k] = dictionary[v%byte(len(dictionary))] } return string(bytes), nil } // RandomNumeric generates a random numeric string of length n. func RandomNumeric(n int) (string, error) { const dictionary = "0123456789" bytes := make([]byte, n) if _, err := rand.Read(bytes); err != nil { return "", err } for k, v := range bytes { bytes[k] = dictionary[v%byte(len(dictionary))] } return string(bytes), nil } // RemoveEmpty removes empty strings from a slice of strings. func RemoveEmpty(s []string) []string { var r []string for _, str := range s { if str != "" { r = append(r, str) } } return r } // GenerateEmailMessageID generates an RFC-compliant Message-ID for an email without angle brackets. func GenerateEmailMessageID(uuid string, fromAddress string) (string, error) { if uuid == "" { return "", fmt.Errorf("uuid cannot be empty") } // Parse from address addr, err := mail.ParseAddress(fromAddress) if err != nil { return "", fmt.Errorf("invalid from address: %w", err) } // Extract domain with validation parts := strings.Split(addr.Address, "@") if len(parts) != 2 || parts[1] == "" { return "", fmt.Errorf("invalid domain in from address") } domain := parts[1] // Random component randomStr, err := RandomAlphanumeric(11) if err != nil { return "", fmt.Errorf("failed to generate random string: %w", err) } return fmt.Sprintf("%s-%d-%s@%s", uuid, time.Now().UnixNano(), randomStr, domain, ), nil } // RemoveItemByValue removes all instances of a value from a slice of strings. func RemoveItemByValue(slice []string, value string) []string { result := []string{} for _, v := range slice { if v != value { result = append(result, v) } } return result } // FormatDuration formats a duration as a string. func FormatDuration(d time.Duration, includeSeconds bool) string { d = d.Round(time.Second) h := int64(d.Hours()) d -= time.Duration(h) * time.Hour m := int64(d.Minutes()) d -= time.Duration(m) * time.Minute s := int64(d.Seconds()) var parts []string if h > 0 { parts = append(parts, fmt.Sprintf("%d hours", h)) } if m >= 0 { parts = append(parts, fmt.Sprintf("%d minutes", m)) } if s > 0 && includeSeconds { parts = append(parts, fmt.Sprintf("%d seconds", s)) } return strings.Join(parts, " ") } // ValidEmail returns true if it's a valid email else return false. func ValidEmail(email string) bool { addr, err := mail.ParseAddress(email) if err != nil { return false } return addr.Name == "" && addr.Address == email } // ExtractEmail extracts the email address from a string. // E.g. "Name " -> "john@example.com", "john@example.com" -> "john@example.com". func ExtractEmail(s string) (string, error) { addr, err := mail.ParseAddress(s) if err != nil { return "", err } return addr.Address, nil } // DedupAndExcludeString returns a deduplicated []string excluding empty and a specific value. func DedupAndExcludeString(list []string, exclude string) []string { seen := make(map[string]struct{}, len(list)) cleaned := make([]string, 0, len(list)) for _, s := range list { if s == "" || s == exclude { continue } if _, ok := seen[s]; !ok { seen[s] = struct{}{} cleaned = append(cleaned, s) } } return cleaned } // ExtractConvUUID extracts the conversation UUID from a plus-addressed email. // e.g., support+conv-abc12345-1234-4123-1234-123456789abc@domain.com -> abc12345-1234-4123-1234-123456789abc // Returns empty string if no valid UUIDv4 found. func ExtractConvUUID(email string) string { match := regexpConvUUID.FindString(email) if match == "" { return "" } // match is "+conv-{uuid}@", extract just the UUID (skip "+conv-" prefix and "@" suffix) return match[6 : len(match)-1] } // ExtractUUID finds and returns the first valid UUID v4 in the given text. // Returns empty string if no valid UUID is found. func ExtractUUID(text string) string { return uuidV4Regex.FindString(text) } // ExtractReferenceNumber extracts the last reference number from a subject line. // For example, "RE: Test - #392" returns "392". // If multiple numbers exist (e.g., "Order #123 - #392"), returns the last one ("392"). func ExtractReferenceNumber(subject string) string { matches := regexpRefNumber.FindAllStringSubmatch(subject, -1) if len(matches) > 0 { // Return the last match's captured group. lastMatch := matches[len(matches)-1] if len(lastMatch) >= 2 { return lastMatch[1] } } return "" } // SplitName splits a full name; the first word is the first name, the rest is the last name. func SplitName(name string) (string, string) { fields := strings.Fields(name) if len(fields) == 0 { return "", "" } if len(fields) == 1 { return fields[0], "" } return fields[0], strings.Join(fields[1:], " ") } func htmlToText(html string, opts html2text.Options) string { out, err := html2text.FromString(html, opts) if err != nil { return "" } return strings.TrimSpace(out) }