224 lines
7.3 KiB
Go
224 lines
7.3 KiB
Go
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
|
|
}
|