from-header-binding: what the reviews had left as found, and address extensions
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:
mix
2026-10-12 21:29:17 +03:00
co-authored by Claude Opus 5.5
parent 89fd0a6116
commit c817f0833c
13 changed files with 224 additions and 23 deletions
+61 -8
View File
@@ -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 {
+17
View File
@@ -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)
}
}
}