First commit Go counter service
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateOut(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in OutRequest
|
||||
wantErr string // substring; "" = accepted
|
||||
want ValidatedOut
|
||||
}{
|
||||
{"valid", OutRequest{TraceID: "tr", AuthUser: "x@example.com", MailFrom: "x@example.com", Recipient: "y@example.com"}, "",
|
||||
ValidatedOut{TraceID: "tr", AuthUser: Address{Value: "x@example.com"}, MailFrom: "x@example.com", MailFromUsable: true, Recipient: Address{Value: "y@example.com"}}},
|
||||
{"normalised", OutRequest{AuthUser: " X@Example.COM ", Recipient: "Y@EXAMPLE.com"}, "",
|
||||
ValidatedOut{AuthUser: Address{Value: "x@example.com"}, Recipient: Address{Value: "y@example.com"}}},
|
||||
// Support/token logins (review #3): mizu forwards the raw SASL login
|
||||
// user@dom@SUFFIX; the base address is counted, the suffix logged.
|
||||
{"login suffix", OutRequest{AuthUser: "x@yagolivas.com@SUPPORTTOKEN", Recipient: "y@example.com"}, "",
|
||||
ValidatedOut{AuthUser: Address{Value: "x@yagolivas.com"}, LoginSuffix: "SUPPORTTOKEN", Recipient: Address{Value: "y@example.com"}}},
|
||||
// mail_from never rejects (review #2): kept normalised when possible,
|
||||
// otherwise as a sanitised string for the log.
|
||||
{"mail_from empty", OutRequest{AuthUser: "x@example.com", Recipient: "y@example.com", MailFrom: ""}, "",
|
||||
ValidatedOut{AuthUser: Address{Value: "x@example.com"}, Recipient: Address{Value: "y@example.com"}}},
|
||||
{"mail_from garbage kept for log", OutRequest{AuthUser: "x@example.com", Recipient: "y@example.com", MailFrom: "john smith@x.com\x1b[1m"}, "",
|
||||
ValidatedOut{AuthUser: Address{Value: "x@example.com"}, MailFrom: "john smith@x.com[1m", Recipient: Address{Value: "y@example.com"}}},
|
||||
// One unusable side: the other is still counted.
|
||||
{"auth_user missing, recipient ok", OutRequest{Recipient: "y@example.com"}, "",
|
||||
ValidatedOut{AuthUser: Address{Reason: "missing"}, Recipient: Address{Value: "y@example.com"}}},
|
||||
{"auth_user bad, recipient ok", OutRequest{AuthUser: "nobody", Recipient: "y@example.com"}, "",
|
||||
ValidatedOut{AuthUser: Address{Raw: "nobody", Reason: "invalid e-mail address"}, Recipient: Address{Value: "y@example.com"}}},
|
||||
{"recipient literal, sender ok", OutRequest{AuthUser: "x@example.com", Recipient: "u@[192.0.2.1]"}, "",
|
||||
ValidatedOut{AuthUser: Address{Value: "x@example.com"}, Recipient: Address{Raw: "u@[192.0.2.1]", Reason: "invalid e-mail address"}}},
|
||||
{"recipient underscore host, sender ok", OutRequest{AuthUser: "x@example.com", Recipient: "u@host_name.example.com"}, "",
|
||||
ValidatedOut{AuthUser: Address{Value: "x@example.com"}, Recipient: Address{Raw: "u@host_name.example.com", Reason: "invalid e-mail address"}}},
|
||||
// IDN domains are counted under their punycode CDB key.
|
||||
{"idn recipient", OutRequest{AuthUser: "x@example.com", Recipient: "info@Bücher.de"}, "",
|
||||
ValidatedOut{AuthUser: Address{Value: "x@example.com"}, Recipient: Address{Value: "info@xn--bcher-kva.de"}}},
|
||||
{"trailing dot stripped", OutRequest{AuthUser: "x@example.com", Recipient: "u@example.com."}, "",
|
||||
ValidatedOut{AuthUser: Address{Value: "x@example.com"}, Recipient: Address{Value: "u@example.com"}}},
|
||||
// Neither side usable: 400 naming both.
|
||||
{"both missing", OutRequest{}, "auth_user: missing; recipient: missing", ValidatedOut{}},
|
||||
{"both bad", OutRequest{AuthUser: "nobody", Recipient: " "}, "auth_user: invalid e-mail address; recipient: missing", ValidatedOut{}},
|
||||
{"trace sanitised", OutRequest{TraceID: "ab\ncd\x1b[31m" + strings.Repeat("z", 200), AuthUser: "x@example.com", Recipient: "y@example.com"}, "",
|
||||
ValidatedOut{TraceID: "abcd[31m" + strings.Repeat("z", 120), AuthUser: Address{Value: "x@example.com"}, Recipient: Address{Value: "y@example.com"}}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ValidateOut(tt.in)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("got %+v\nwant %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in InRequest
|
||||
wantErr string
|
||||
want ValidatedIn
|
||||
}{
|
||||
{"bounce sender", InRequest{TraceID: "t", MailFrom: "", Recipient: "Y@Example.com", Junk: true}, "",
|
||||
ValidatedIn{TraceID: "t", Recipient: "y@example.com", Junk: true}},
|
||||
{"valid sender", InRequest{MailFrom: "S@Gmail.com", Recipient: "y@example.com"}, "",
|
||||
ValidatedIn{MailFrom: "s@gmail.com", MailFromUsable: true, Recipient: "y@example.com"}},
|
||||
// Every odd-but-accepted-by-mizu sender keeps the recipient counted.
|
||||
{"sender with space", InRequest{MailFrom: "john smith@example.com", Recipient: "y@example.com"}, "",
|
||||
ValidatedIn{MailFrom: "john smith@example.com", Recipient: "y@example.com"}},
|
||||
{"sender two ats", InRequest{MailFrom: "a@b@example.com", Recipient: "y@example.com"}, "",
|
||||
ValidatedIn{MailFrom: "a@b@example.com", Recipient: "y@example.com"}},
|
||||
{"sender idn", InRequest{MailFrom: "info@bücher.de", Recipient: "y@example.com"}, "",
|
||||
ValidatedIn{MailFrom: "info@xn--bcher-kva.de", MailFromUsable: true, Recipient: "y@example.com"}},
|
||||
{"sender literal", InRequest{MailFrom: "postmaster@[192.0.2.1]", Recipient: "y@example.com"}, "",
|
||||
ValidatedIn{MailFrom: "postmaster@[192.0.2.1]", Recipient: "y@example.com"}},
|
||||
{"sender too long", InRequest{MailFrom: strings.Repeat("v", 250) + "@example.com", Recipient: "y@example.com"}, "",
|
||||
ValidatedIn{MailFrom: strings.Repeat("v", 250) + "@exa", Recipient: "y@example.com"}},
|
||||
{"sender control chars stripped", InRequest{MailFrom: "a\r\nb@x.com", Recipient: "y@example.com"}, "",
|
||||
ValidatedIn{MailFrom: "ab@x.com", Recipient: "y@example.com"}}, // sanitised for the log, not repaired into an address
|
||||
{"recipient idn", InRequest{Recipient: "info@Bücher.de"}, "", ValidatedIn{Recipient: "info@xn--bcher-kva.de"}},
|
||||
{"recipient missing", InRequest{MailFrom: "s@gmail.com"}, "recipient: missing", ValidatedIn{}},
|
||||
{"recipient bad", InRequest{Recipient: "nobody"}, "recipient: invalid e-mail address", ValidatedIn{}},
|
||||
{"recipient literal", InRequest{Recipient: "u@[192.0.2.1]"}, "recipient: invalid e-mail address", ValidatedIn{}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ValidateIn(tt.in)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
var fe *FieldError
|
||||
if !errors.As(err, &fe) {
|
||||
t.Errorf("error should be a *FieldError, got %T", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("got %+v\nwant %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAuthUser(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, addr, suffix, wantErr string
|
||||
}{
|
||||
{"x@yagolivas.com", "x@yagolivas.com", "", ""},
|
||||
{" X@Yagolivas.com@SUPPORTTOKEN ", "x@yagolivas.com", "SUPPORTTOKEN", ""},
|
||||
{"x@yagolivas.com@tok@en", "x@yagolivas.com", "tok@en", ""},
|
||||
{"x@yagolivas.com@", "x@yagolivas.com", "", ""},
|
||||
{"nobody", "", "", "exactly one @"},
|
||||
{"@dom@SUFFIX", "", "", "exactly one @"},
|
||||
{"", "", "", "empty"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
addr, suffix, err := NormalizeAuthUser(tt.in)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("NormalizeAuthUser(%q) err = %v, want %q", tt.in, err, tt.wantErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || addr != tt.addr || suffix != tt.suffix {
|
||||
t.Errorf("NormalizeAuthUser(%q) = %q, %q, %v; want %q, %q", tt.in, addr, suffix, err, tt.addr, tt.suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEmail(t *testing.T) {
|
||||
good := map[string]string{
|
||||
" A.B+tag@Sub.Example.COM ": "a.b+tag@sub.example.com",
|
||||
"x@example.com\t": "x@example.com",
|
||||
"x@xn--exmple-cua.com": "x@xn--exmple-cua.com",
|
||||
"jürgen@example.com": "jürgen@example.com", // SMTPUTF8 local part
|
||||
"x@exämple.com": "x@xn--exmple-cua.com",
|
||||
"u@münchen.de": "u@xn--mnchen-3ya.de",
|
||||
"u@日本.jp": "u@xn--wgv71a.jp",
|
||||
"u@example.com.": "u@example.com",
|
||||
}
|
||||
for in, want := range good {
|
||||
if got, err := NormalizeEmail(in); err != nil || got != want {
|
||||
t.Errorf("NormalizeEmail(%q) = %q, %v; want %q", in, got, err, want)
|
||||
}
|
||||
}
|
||||
bad := []string{"", "nobody", "@x.com", "x@", "a@b@c.com", "x@exam ple.com", "x y@example.com", "x\x01@example.com",
|
||||
strings.Repeat("a", 250) + "@e.com", "x@.example.com", "x@a..b.com", "x@[192.0.2.1]", "x@host_name.example.com", "x@-bad.com", "x@example.com.."}
|
||||
for _, in := range bad {
|
||||
if got, err := NormalizeEmail(in); err == nil {
|
||||
t.Errorf("NormalizeEmail(%q) = %q, want error", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeForLog(t *testing.T) {
|
||||
if got := SanitizeForLog("a\r\nb\x1b[31m" + strings.Repeat("z", 300)); !strings.HasPrefix(got, "ab[31m") || len(got) != MaxEmailLen {
|
||||
t.Errorf("got %q (len %d)", got, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeTraceID(t *testing.T) {
|
||||
if got := SanitizeTraceID(" a-b_c.d:e "); got != "a-b_c.d:e" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := SanitizeTraceID("x\r\ny\x00z\x7f"); got != "xyz" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := SanitizeTraceID(""); got != "" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDate(t *testing.T) {
|
||||
now := time.Date(2026, 10, 13, 23, 30, 0, 0, time.FixedZone("CEST", 2*3600)) // 21:30 UTC on the 13th
|
||||
tests := []struct {
|
||||
in, want, wantErr string
|
||||
}{
|
||||
{"", "2026-10-13", ""},
|
||||
{"2026-10-13", "2026-10-13", ""},
|
||||
{"2026-10-12", "2026-10-12", ""},
|
||||
{"2026-10-14", "", "future"},
|
||||
{"2026-1-5", "", "YYYY-MM-DD"},
|
||||
{"2026-13-01", "", "YYYY-MM-DD"},
|
||||
{"13.10.2026", "", "YYYY-MM-DD"},
|
||||
{"2026-10-13T00:00:00Z", "", "YYYY-MM-DD"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := ParseDate(tt.in, now)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("ParseDate(%q) err = %v, want containing %q", tt.in, err, tt.wantErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || got != tt.want {
|
||||
t.Errorf("ParseDate(%q) = %q, %v; want %q", tt.in, got, err, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountResponseJSON(t *testing.T) {
|
||||
b, err := json.Marshal(CountResponse{Accepted: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != `{"accepted":true}` {
|
||||
t.Errorf("JSON = %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountersResponseJSON(t *testing.T) {
|
||||
org := int64(1234)
|
||||
in := int64(500)
|
||||
resp := CountersResponse{Query: "me@example.com", Mode: "address", Date: "2026-10-13", Managed: true,
|
||||
Domain: "example.com", OrgID: &org,
|
||||
Levels: []CountersLevel{{Level: "A", ID: "me@example.com", In: 12, InLimit: &in, Out: 3, OutLimit: nil}}}
|
||||
b, _ := json.Marshal(resp)
|
||||
s := string(b)
|
||||
for _, want := range []string{`"query":"me@example.com"`, `"mode":"address"`, `"date":"2026-10-13"`, `"managed":true`, `"exempt":false`,
|
||||
`"domain":"example.com"`, `"org_id":1234`, `"levels":[`, `"in":12`, `"in_limit":500`, `"out":3`, `"out_limit":null`} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("JSON %s lacks %s", s, want)
|
||||
}
|
||||
}
|
||||
b, _ = json.Marshal(CountersResponse{Query: "x@nowhere.tld", Mode: "address", Date: "2026-10-13", Levels: []CountersLevel{}})
|
||||
if strings.Contains(string(b), "org_id") || strings.Contains(string(b), `"domain"`) {
|
||||
t.Errorf("unmanaged response should omit org_id and domain: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorResponseJSON(t *testing.T) {
|
||||
b, _ := json.Marshal(ErrorResponse{Error: "recipient: missing"})
|
||||
if string(b) != `{"error":"recipient: missing"}` {
|
||||
t.Errorf("got %s", b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Package api holds the JSON contract between mailqueuer, the daemon and
|
||||
// counterctl, plus the request validators (the "fields" step of the HTTP
|
||||
// validation layer).
|
||||
package api
|
||||
|
||||
// OutRequest is the body of POST /out: one recipient of an authenticated
|
||||
// submission.
|
||||
type OutRequest struct {
|
||||
TraceID string `json:"trace_id"` // mizu's X-Trace-Id; log correlation only
|
||||
AuthUser string `json:"auth_user"` // SMTP AUTH user (mizu's X-Auth-User)
|
||||
MailFrom string `json:"mail_from"` // envelope sender, optional
|
||||
Recipient string `json:"recipient"`
|
||||
}
|
||||
|
||||
// InRequest is the body of POST /in: one recipient of an inbound message.
|
||||
type InRequest struct {
|
||||
TraceID string `json:"trace_id"`
|
||||
MailFrom string `json:"mail_from"` // "" for a bounce sender <>
|
||||
Recipient string `json:"recipient"`
|
||||
Junk bool `json:"junk"` // mizu's X-Junk: yes
|
||||
}
|
||||
|
||||
// CountResponse answers /in and /out with 202: the request was validated and
|
||||
// queued; counting happens off the request path. The counted values appear in
|
||||
// the counting log and in GET /counters.
|
||||
type CountResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
}
|
||||
|
||||
// ErrorResponse is the body of every non-2xx answer.
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// CountersLevel is one row of GET /counters.
|
||||
type CountersLevel struct {
|
||||
Level string `json:"level"`
|
||||
ID string `json:"id"`
|
||||
In int64 `json:"in"`
|
||||
InLimit *int64 `json:"in_limit"` // null when unlimited or unmanaged
|
||||
Out int64 `json:"out"`
|
||||
OutLimit *int64 `json:"out_limit"`
|
||||
}
|
||||
|
||||
// CountersResponse answers GET /counters?address=|domain=[&date=].
|
||||
type CountersResponse struct {
|
||||
Query string `json:"query"`
|
||||
Mode string `json:"mode"` // address | domain
|
||||
Date string `json:"date"` // UTC day
|
||||
Managed bool `json:"managed"`
|
||||
Exempt bool `json:"exempt"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
OrgID *int64 `json:"org_id,omitempty"`
|
||||
Levels []CountersLevel `json:"levels"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user