from-header-binding: what the reviews had left as found, and address extensions
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
The owner on the three things the reviews' commit left as found: can they be fixed? They can. - Downtime that crossed a UTC midnight was all booked on the day of the restart: it is cut at the midnights between (byDay). - A message with the null envelope sender - a bounce an application writes itself - left a refusal row without a domain, which no domain administrator would see: the row takes the domain the login is bound to. - A log read again after an unclean stop counted its deferrals again: while the tailer catches up, a warning no later than the last one counted is passed over. The mark is kept to the fraction of a second the log gives. And on the check being stricter than Postfix for an address with an extension (invoices+x@ passed in the envelope, was refused in the header): it makes Postfix's second try now, the address without its extension. recipient_delimiter was the distribution's default; it is set in postfix-config.sh, since the check depends on it. e2e sends one through. Test server, this tree: go vet ok, go test 23 packages ok, make e2e ok (339 s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,9 +31,9 @@ import (
|
||||
// answer, Postfix defers mail. It therefore depends on as little as it can.
|
||||
// It reads no database: what an application may send as comes from the file
|
||||
// of sender bindings Postfix itself reads for the envelope, so the two go by
|
||||
// one table. Where the lookups differ the check is the stricter: Postfix
|
||||
// also tries an address without its "+extension" and compares logins without
|
||||
// regard to case. The send-log row of a refusal is written by another goroutine and
|
||||
// one table, by one lookup: the address, the address without its
|
||||
// "+extension", the domain. (Postfix compares logins without regard to case;
|
||||
// the check compares them as written, which is how the panel writes them.) The send-log row of a refusal is written by another goroutine and
|
||||
// is not waited for.
|
||||
|
||||
// tableFile is a table of the mail path that the panel rewrites and the check
|
||||
@@ -69,9 +69,10 @@ func (f *tableFile) changed() (lines []string, again bool, err error) {
|
||||
// sender - an address, or "@domain" for any address of a domain - with the
|
||||
// logins that may use it.
|
||||
type Bindings struct {
|
||||
mu sync.Mutex
|
||||
file tableFile
|
||||
logins map[string]map[string]bool // sender -> logins
|
||||
mu sync.Mutex
|
||||
file tableFile
|
||||
logins map[string]map[string]bool // sender -> logins
|
||||
domainOf map[string]string // login -> the domain it sends as
|
||||
}
|
||||
|
||||
// NewBindings reads the file at path on first use.
|
||||
@@ -86,6 +87,7 @@ func (b *Bindings) fresh() error {
|
||||
return err
|
||||
}
|
||||
logins := make(map[string]map[string]bool)
|
||||
domains := make(map[string]string)
|
||||
for _, line := range lines {
|
||||
// texthash: <key><whitespace><value>, the value a comma-separated
|
||||
// list of logins (internal/postfix renderSenderLoginMaps).
|
||||
@@ -102,10 +104,13 @@ func (b *Bindings) fresh() error {
|
||||
for _, l := range strings.Split(strings.Join(fields[1:], ""), ",") {
|
||||
if l != "" {
|
||||
set[l] = true
|
||||
if _, known := domains[l]; !known {
|
||||
domains[l] = domainOf(sender)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
b.logins = logins
|
||||
b.logins, b.domainOf = logins, domains
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -122,12 +127,48 @@ func (b *Bindings) Allowed(login, addr string) (bool, error) {
|
||||
if b.logins[addr][login] {
|
||||
return true, nil
|
||||
}
|
||||
// Postfix's second try: the address without its extension, so that an
|
||||
// application listed as invoices@ may also send as invoices+march@
|
||||
// (recipient_delimiter in build/postfix-config.sh).
|
||||
if bare := withoutExtension(addr); bare != addr && b.logins[bare][login] {
|
||||
return true, nil
|
||||
}
|
||||
if d := domainOf(addr); d != "" && b.logins["@"+d][login] {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// extensionDelimiter is Postfix's recipient_delimiter, which
|
||||
// build/postfix-config.sh sets to this.
|
||||
const extensionDelimiter = "+"
|
||||
|
||||
// withoutExtension drops the extension of an address's local part: what
|
||||
// follows its first delimiter. "user+tag@example.org" is "user@example.org";
|
||||
// an address whose local part begins with the delimiter has no extension.
|
||||
func withoutExtension(addr string) string {
|
||||
at := strings.LastIndexByte(addr, '@')
|
||||
if at < 0 {
|
||||
return addr
|
||||
}
|
||||
if i := strings.Index(addr[:at], extensionDelimiter); i > 0 {
|
||||
return addr[:i] + addr[at:]
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// DomainOf returns the domain login sends as, from the first of its bindings
|
||||
// in the file's order; "" for a login that has none. An application belongs
|
||||
// to one domain, so all of its bindings name the same one.
|
||||
func (b *Bindings) DomainOf(login string) string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.fresh() != nil {
|
||||
return ""
|
||||
}
|
||||
return b.domainOf[login]
|
||||
}
|
||||
|
||||
// Signers is OpenDKIM's SigningTable as the check reads it: the names this
|
||||
// server signs for - every sending domain, with applications or without, and
|
||||
// the server's own name.
|
||||
@@ -319,7 +360,7 @@ func (s *senderSession) record(shown string) {
|
||||
to += fmt.Sprintf(" and %d more", more)
|
||||
}
|
||||
e := store.SendLogEntry{
|
||||
Domain: domainOf(s.from),
|
||||
Domain: s.domain(),
|
||||
AppLogin: s.login,
|
||||
From: s.from,
|
||||
To: to,
|
||||
@@ -365,6 +406,18 @@ func unquoted(v string) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// domain is the sending domain of the refused message for its row in the
|
||||
// log: the envelope sender's, which Postfix has held against the login - or,
|
||||
// for the null sender of a bounce an application writes itself, which Postfix
|
||||
// holds against nobody, the domain the login is bound to. Without it the row
|
||||
// would belong to no domain and its administrator would never see it.
|
||||
func (s *senderSession) domain() string {
|
||||
if d := domainOf(s.from); d != "" {
|
||||
return d
|
||||
}
|
||||
return s.bind.DomainOf(s.login)
|
||||
}
|
||||
|
||||
// mentionedDomains returns, lower-cased, what follows every "@" of v as far
|
||||
// as it reads as a host name.
|
||||
func mentionedDomains(v string) []string {
|
||||
|
||||
@@ -94,6 +94,8 @@ func TestSenderCheck(t *testing.T) {
|
||||
{"own domain, any address, with a name", "shop", [][2]string{from(`"The Shop" <Orders@Shop.Example.Org>`)}, true, ""},
|
||||
{"second application of the domain", "shop2", [][2]string{from("x@shop.example.org")}, true, ""},
|
||||
{"a listed address", "billing", [][2]string{from("Invoices <invoices@example.org>")}, true, ""},
|
||||
{"a listed address with an extension", "billing", [][2]string{from("invoices+2026-march@example.org")}, true, ""},
|
||||
{"an extension, any address of the domain", "shop", [][2]string{from("orders+48213@shop.example.org")}, true, ""},
|
||||
{"header name in another case", "shop", [][2]string{{"FROM", "a@shop.example.org"}}, true, ""},
|
||||
{"its own name inside the display name", "shop", [][2]string{from(`"shop@shop.example.org" <a@shop.example.org>`)}, true, ""},
|
||||
{"an encoded display name", "shop", [][2]string{from("=?utf-8?b?0JzQsNCz0LDQt9C40L0=?= <a@shop.example.org>")}, true, ""},
|
||||
@@ -107,6 +109,8 @@ func TestSenderCheck(t *testing.T) {
|
||||
{"the server's own name", "shop", [][2]string{from("postmaster@mail.example.org")}, false, "not an address this application sends as"},
|
||||
{"a stranger's domain", "shop", [][2]string{from("someone@gmail.example")}, false, "not an address this application sends as"},
|
||||
{"an address off the list", "billing", [][2]string{from("director@example.org")}, false, "not an address this application sends as"},
|
||||
{"an address off the list, with an extension", "billing", [][2]string{from("director+invoices@example.org")}, false, "not an address this application sends as"},
|
||||
{"a listed name as the extension of another", "billing", [][2]string{from("+invoices@example.org")}, false, "not an address this application sends as"},
|
||||
{"a subdomain of its own", "shop", [][2]string{from("a@sub.shop.example.org")}, false, "not an address this application sends as"},
|
||||
{"no From: line", "shop", [][2]string{{"Subject", "hello"}}, false, "no From: line"},
|
||||
{"two From: lines", "shop", [][2]string{from("a@shop.example.org"), from("director@example.org")}, false, "more than one From: line"},
|
||||
@@ -254,3 +258,16 @@ func TestSenderCheckRefusalIsOneRow(t *testing.T) {
|
||||
t.Fatalf("rows = %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// A message with the null envelope sender - a bounce an application writes
|
||||
// itself - is judged like any other, and its row belongs to the domain the
|
||||
// login is bound to.
|
||||
func TestSenderCheckRefusalWithNullSenderHasItsDomain(t *testing.T) {
|
||||
bind := NewBindings(writeMaps(t, senderMaps))
|
||||
for login, want := range map[string]string{"shop": "shop.example.org", "billing": "example.org"} {
|
||||
_, rows := offer(t, bind, login, "", [2]string{"From", "someone@gmail.example"})
|
||||
if len(rows) != 1 || rows[0].Domain != want || rows[0].From != "" {
|
||||
t.Errorf("%s: rows = %+v, want one of domain %s", login, rows, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user