// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: MIT //go:build linux || freebsd package contactform import ( "fmt" "net/mail" "slices" "strings" "unicode/utf8" ) // MinNameRunes is the default minimum allowed length of a name. const MinNameRunes = 2 // MaxNameRunes is the default maximum allowed length of a name. const MaxNameRunes = 100 // MinMessageRunes is the default minimum allowed length of a message body. const MinMessageRunes = 10 // MaxMessageRunes is the default maximum allowed length of a message body. const MaxMessageRunes = 5000 // ServiceAny is the services entry that accepts any service value. const ServiceAny = "*" // DefaultServices is the built-in service allow-list the contact preset // applies when a form does not define its own list. The empty value is // always accepted on top of whatever this list holds, because a form that // offers no choice never sends the field at all. var DefaultServices = []string{ "architecture", "ai", "infrastructure", "software", "unix", "other", } // Policy is the declarative validation rule set for one form. The server // builds it from the form's configuration; a library caller writes it // directly. The zero value accepts any name and message, so every limit // that matters must be set explicitly. type Policy struct { // RequireName and RequireMessage switch the length checks for the two // free-text fields on and off. The email address is always required // and always checked: every form delivers mail and needs a reply-to. RequireName bool RequireMessage bool MinNameRunes int MaxNameRunes int MinMessageRunes int MaxMessageRunes int // Services is the allow-list for the optional service field. A nil // list means the field is not validated at all; a non-nil list holds // the accepted values, with the empty value always accepted and the // ServiceAny entry lifting the restriction entirely. Services []string } // Preset returns the built-in policy for a form type. An empty or unknown // type falls back to the contact preset, which is also how the server // treats an unconfigured type. Every preset carries the default length // limits; the newsletter preset simply leaves both free-text fields out // of the checks, so switching them on later starts from sane limits. func Preset(formType string) Policy { switch formType { case "newsletter": return Policy{ MinNameRunes: MinNameRunes, MaxNameRunes: MaxNameRunes, MinMessageRunes: MinMessageRunes, MaxMessageRunes: MaxMessageRunes, } case "feedback", "generic": return Policy{ RequireName: true, RequireMessage: true, MinNameRunes: MinNameRunes, MaxNameRunes: MaxNameRunes, MinMessageRunes: MinMessageRunes, MaxMessageRunes: MaxMessageRunes, } default: // contact, and the fallback for every unknown type return Policy{ RequireName: true, RequireMessage: true, MinNameRunes: MinNameRunes, MaxNameRunes: MaxNameRunes, MinMessageRunes: MinMessageRunes, MaxMessageRunes: MaxMessageRunes, Services: slices.Clone(DefaultServices), } } } // Validate normalises the request in place (trims whitespace from every // text field) and checks it against p. It returns one entry per failed // field; a nil slice means the request is valid. func Validate(r *Request, p Policy) []FieldError { r.Name = strings.TrimSpace(r.Name) r.Email = strings.TrimSpace(r.Email) r.Service = strings.TrimSpace(r.Service) r.Message = strings.TrimSpace(r.Message) var errs []FieldError if p.RequireName { errs = append(errs, checkRunes("name", r.Name, p.MinNameRunes, p.MaxNameRunes)...) } if _, err := mail.ParseAddress(r.Email); err != nil { errs = append(errs, FieldError{Field: "email", Message: "email is invalid"}) } if p.Services != nil && !serviceAllowed(p.Services, r.Service) { errs = append(errs, FieldError{Field: "service", Message: "service is not a recognised value"}) } if p.RequireMessage { errs = append(errs, checkRunes("message", r.Message, p.MinMessageRunes, p.MaxMessageRunes)...) } return errs } // NormalizeAndValidate trims whitespace from all text fields and then // checks the request against the built-in preset for the form type. // Supported types: contact, feedback, newsletter, generic; an empty or // unknown type falls back to contact. Callers that need their own limits // or their own service allow-list build a Policy and call Validate. func NormalizeAndValidate(r *Request, formType string) []FieldError { return Validate(r, Preset(formType)) } // checkRunes reports the length errors for one field in rune counts. func checkRunes(field, value string, minRunes, maxRunes int) []FieldError { n := utf8.RuneCountInString(value) var errs []FieldError if n < minRunes { errs = append(errs, FieldError{ Field: field, Message: fmt.Sprintf("%s must be at least %d characters", field, minRunes), }) } else if n > maxRunes { errs = append(errs, FieldError{ Field: field, Message: fmt.Sprintf("%s must be at most %d characters", field, maxRunes), }) } return errs } // serviceAllowed reports whether s passes the allow-list. The empty value // is always legitimate: petrbalvin.org and every other frontend is free to // post a payload without a service field. The ServiceAny entry accepts any // non-empty value. func serviceAllowed(list []string, s string) bool { if s == "" { return true } if slices.Contains(list, ServiceAny) { return true } return slices.Contains(list, s) }