First commit Go counter service

This commit is contained in:
Dimitrije Dimitrijevic
2026-10-15 02:12:18 +02:00
commit c43ff17627
59 changed files with 11205 additions and 0 deletions
+223
View File
@@ -0,0 +1,223 @@
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
}