package api import ( "errors" "fmt" "strings" "time" "unicode" "golang.org/x/net/idna" ) // Limits applied by the validators. const ( MaxEmailLen = 254 MaxTraceIDLen = 128 ) // FieldError names the offending field: "recipient: missing". type FieldError struct { Field string Reason string } func (e *FieldError) Error() string { return e.Field + ": " + e.Reason } // Address is one validated address side of a request. A side that is not // usable is logged (Raw, Reason) but never rejects the request on its own: // the other side is still counted. type Address struct { Value string // normalised address; "" when not usable Raw string // sanitised input, for the log, when not usable Reason string // "" when usable; "missing" or "invalid e-mail address" } // Usable reports whether the side can be counted. func (a Address) Usable() bool { return a.Value != "" } // ValidatedOut is an accepted /out body. type ValidatedOut struct { TraceID string AuthUser Address LoginSuffix string // "SUPPORTTOKEN" of a user@dom@SUPPORTTOKEN login MailFrom string // normalised, or the sanitised input when not an address MailFromUsable bool Recipient Address } // ValidatedIn is an accepted /in body. type ValidatedIn struct { TraceID string MailFrom string // normalised, or the sanitised input when not an address MailFromUsable bool Recipient string Junk bool } // ValidateOut checks an /out body. auth_user and recipient are validated // independently and the request is rejected only when neither is usable; // mail_from only feeds the exempt check and never rejects. func ValidateOut(r OutRequest) (ValidatedOut, error) { v := ValidatedOut{TraceID: SanitizeTraceID(r.TraceID)} v.AuthUser, v.LoginSuffix = validateAuthUser(r.AuthUser) v.Recipient = validateAddress(r.Recipient) v.MailFrom, v.MailFromUsable = lenient(r.MailFrom) if !v.AuthUser.Usable() && !v.Recipient.Usable() { return ValidatedOut{}, fmt.Errorf("%w; %w", &FieldError{"auth_user", v.AuthUser.Reason}, &FieldError{"recipient", v.Recipient.Reason}) } return v, nil } // ValidateIn checks an /in body: recipient is required and must be an // address; mail_from never rejects (it may be "" for a bounce sender). func ValidateIn(r InRequest) (ValidatedIn, error) { v := ValidatedIn{TraceID: SanitizeTraceID(r.TraceID), Junk: r.Junk} rcpt := validateAddress(r.Recipient) if !rcpt.Usable() { return ValidatedIn{}, &FieldError{"recipient", rcpt.Reason} } v.Recipient = rcpt.Value v.MailFrom, v.MailFromUsable = lenient(r.MailFrom) return v, nil } func validateAddress(s string) Address { if strings.TrimSpace(s) == "" { return Address{Reason: "missing"} } n, err := NormalizeEmail(s) if err != nil { return Address{Raw: SanitizeForLog(s), Reason: "invalid e-mail address"} } return Address{Value: n} } func validateAuthUser(s string) (Address, string) { if strings.TrimSpace(s) == "" { return Address{Reason: "missing"}, "" } n, suffix, err := NormalizeAuthUser(s) if err != nil { return Address{Raw: SanitizeForLog(s), Reason: "invalid e-mail address"}, "" } return Address{Value: n}, suffix } // lenient normalises when possible and otherwise keeps a sanitised copy for // the log; "" stays "" (bounce sender). func lenient(s string) (string, bool) { if strings.TrimSpace(s) == "" { return "", false } if n, err := NormalizeEmail(s); err == nil { return n, true } return SanitizeForLog(s), false } // NormalizeAuthUser normalises an SMTP AUTH login. mizu forwards the raw SASL // user name, and support/token logins have the form user@domain@SUFFIX: the // base address is everything up to the second "@", the rest is returned as // the suffix for the log. func NormalizeAuthUser(s string) (addr, suffix string, err error) { s = strings.TrimSpace(s) if first := strings.Index(s, "@"); first >= 0 { if second := strings.Index(s[first+1:], "@"); second >= 0 { base := s[:first+1+second] suffix = s[first+1+second+1:] s = base } } addr, err = NormalizeEmail(s) if err != nil { return "", "", err } return addr, suffix, nil } // NormalizeEmail trims, lowercases and syntax-checks an address. The check // is deliberately permissive — the domains CDB decides what is counted — it // only rejects garbage: no "@" (or more than one), empty parts, whitespace or // control characters, over-long input. Non-ASCII local parts are accepted // (SMTPUTF8); a Unicode domain is converted to its punycode form, which is // how the CDB keys it. A trailing dot on the domain is dropped. Address // literals ([192.0.2.1]) and underscores in the domain are rejected: such // domains are never hosted here. func NormalizeEmail(s string) (string, error) { s = strings.ToLower(strings.TrimSpace(s)) if s == "" || len(s) > MaxEmailLen { return "", errors.New("empty or longer than 254 characters") } at := strings.Index(s, "@") if at <= 0 || at != strings.LastIndex(s, "@") || at == len(s)-1 { return "", errors.New("must be local@domain with exactly one @") } local, domain := s[:at], s[at+1:] for _, r := range local { if r <= ' ' || r == 0x7f || unicode.IsSpace(r) || unicode.IsControl(r) { return "", errors.New("local part contains whitespace or control characters") } } domain = strings.TrimSuffix(domain, ".") if domain == "" || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") || strings.Contains(domain, "..") { return "", errors.New("domain has an empty label") } ascii, err := idna.Lookup.ToASCII(domain) if err != nil { return "", fmt.Errorf("domain is not a valid host name: %v", err) } for i := 0; i < len(ascii); i++ { c := ascii[i] if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '.') { return "", errors.New("domain contains characters other than a-z, 0-9, '-' and '.'") } } out := local + "@" + ascii if len(out) > MaxEmailLen { return "", errors.New("longer than 254 characters") } return out, nil } // SanitizeTraceID trims the id, strips control characters (it ends up in log // lines) and caps it at MaxTraceIDLen bytes. func SanitizeTraceID(s string) string { return sanitize(s, MaxTraceIDLen) } // SanitizeForLog strips control characters from an arbitrary field value and // caps it at MaxEmailLen bytes, so unusable addresses can be logged safely. func SanitizeForLog(s string) string { return sanitize(s, MaxEmailLen) } func sanitize(s string, max int) string { var b strings.Builder for _, r := range strings.TrimSpace(s) { if r < 0x20 || r == 0x7f || unicode.IsControl(r) { continue } if b.Len()+len(string(r)) > max { break } b.WriteRune(r) } return b.String() } // ParseDate validates a /counters or counterctl date: "" means today (UTC), // otherwise an ISO 8601 calendar date YYYY-MM-DD that is not in the future. // It returns the normalised YYYY-MM-DD string. func ParseDate(s string, now time.Time) (string, error) { y, m, d := now.UTC().Date() today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) s = strings.TrimSpace(s) if s == "" { return today.Format("2006-01-02"), nil } day, err := time.Parse("2006-01-02", s) if err != nil { return "", fmt.Errorf("invalid date %q: use ISO 8601 YYYY-MM-DD", s) } if day.After(today) { return "", fmt.Errorf("date %s is in the future (today is %s UTC)", s, today.Format("2006-01-02")) } return day.Format("2006-01-02"), nil }