The row's recipient has to be the address Postfix writes in to=<..> or orig_to=<..>, or the delivery line finds no row and the sweep closes it as bounced. Postfix 3.7.11 hands the milter the RCPT argument as the client wrote it (smtpd.c, milter_argv(); extract_addr() leaves it), go-milter v0.4.1 trims "<>" and drops the ESMTP parameters (session.go, case CodeRcpt) - so cleanAddress's cut at the first blank broke a quoted local part (B-G5). Postfix keeps the address in internal form (tok822: quotes off, backslash escapes taken) and logs it external (info_log_addr_form.c, quote_822_local(): quoted again only when not a dot-string); recipientAsLogged does the same, "ab"@x -> ab@x, "a b"@x stays. A recipient given twice in any case is one row: cleanup drops the duplicate, comparing folded (cleanup_out_recipient.c, been_here(), BH_FLAG_FOLD; A-31). A rewritten recipient is matched by orig_to=, which log_adhoc() writes only when it differs (A-9). The store's case-insensitive match stays, now with its reason from the source (D-S20). Stand (2026-10-14, make e2e, exit 0): new recipients_as_postfix_logs_them - RCPT TO "x y"@, "ab"@ and "X Y"@ sink.e2e.test give two rows, "x y"@sink.e2e.test and ab@sink.e2e.test, both sent. go vet and go test ./... pass on the server; a unit test drives the milter through go-milter's own client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+120
-11
@@ -1,23 +1,132 @@
|
||||
package milter
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
// The journal row's recipient has to be the address Postfix later logs in
|
||||
// to=<…>, or the delivery line matches no row and the sweep closes it as
|
||||
// bounced. A quoted local part may hold a space or a ">".
|
||||
"github.com/emersion/go-milter"
|
||||
)
|
||||
|
||||
// The milter gets the address as go-milter v0.4.1 hands it: the command's
|
||||
// first argument, the ESMTP parameters left out, "<>" trimmed (session.go,
|
||||
// case CodeMail/CodeRcpt). Nothing more is cut: a quoted local part may hold
|
||||
// a blank or a ">" (assumptions B-G5).
|
||||
func TestCleanAddress(t *testing.T) {
|
||||
for _, c := range []struct{ in, want string }{
|
||||
{"<a@example.net>", "a@example.net"},
|
||||
{"<a@example.net> SIZE=100 BODY=8BITMIME", "a@example.net"},
|
||||
{"a@example.net", "a@example.net"},
|
||||
{"a@example.net SIZE=100", "a@example.net"},
|
||||
{"<>", ""},
|
||||
{`<"a b"@example.net>`, `"a b"@example.net`},
|
||||
{`<"a>b"@example.net> NOTIFY=SUCCESS`, `"a>b"@example.net`},
|
||||
{`<"a\"> b"@example.net>`, `"a\"> b"@example.net`},
|
||||
{"", ""},
|
||||
{`"a b"@example.net`, `"a b"@example.net`},
|
||||
{`"a>b"@example.net`, `"a>b"@example.net`},
|
||||
{` a@example.net `, "a@example.net"},
|
||||
} {
|
||||
if got := cleanAddress(c.in); got != c.want {
|
||||
t.Errorf("cleanAddress(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The journal row's recipient has to be the address Postfix later logs in
|
||||
// to=<…>, or the delivery line matches no row and the sweep closes it as
|
||||
// bounced: the local part unquoted, then quoted again only when it is not a
|
||||
// dot-string (Postfix 3.7.11, src/global/tok822_parse.c and
|
||||
// quote_822_local.c). Composed by hand after those functions.
|
||||
func TestRecipientAsLogged(t *testing.T) {
|
||||
for _, c := range []struct{ in, want string }{
|
||||
{"a@example.net", "a@example.net"},
|
||||
{"User@Example.NET", "User@Example.NET"}, // case kept
|
||||
{`"ab"@example.net`, "ab@example.net"}, // quotes not needed
|
||||
{`"a.b"@example.net`, "a.b@example.net"},
|
||||
{`"a b"@example.net`, `"a b"@example.net`},
|
||||
{`"a\b"@example.net`, "ab@example.net"}, // the backslash only escapes
|
||||
{`"a\"b"@example.net`, `"a\"b"@example.net`},
|
||||
{`"a..b"@example.net`, `"a..b"@example.net`},
|
||||
{`".a"@example.net`, `".a"@example.net`},
|
||||
{`"a(b)"@example.net`, `"a(b)"@example.net`},
|
||||
{`"почта"@example.net`, "почта@example.net"}, // 8-bit clean
|
||||
{"postmaster", "postmaster"},
|
||||
} {
|
||||
if got := recipientAsLogged(c.in); got != c.want {
|
||||
t.Errorf("recipientAsLogged(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Through go-milter's own client, as Postfix sends it: the address and its
|
||||
// ESMTP parameters as separate strings. A recipient given twice, in another
|
||||
// case, is one row - cleanup keeps one (assumptions A-31).
|
||||
func TestRecipientsThroughGoMilter(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := &fakeRecorder{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go Serve(ctx, ln, rec)
|
||||
|
||||
// The library's server speaks milter version 2, so the client asks for no
|
||||
// action only version 6 has (go-milter v0.4.1, client.go, negotiate();
|
||||
// its client_test.go does the same).
|
||||
cl := milter.NewClientWithOptions("tcp", ln.Addr().String(), milter.ClientOptions{
|
||||
ActionMask: milter.OptAddHeader | milter.OptChangeHeader,
|
||||
})
|
||||
defer cl.Close()
|
||||
s, err := cl.Session()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
step := func(what string, act *milter.Action, err error) {
|
||||
t.Helper()
|
||||
if err != nil || act.Code != milter.ActContinue {
|
||||
t.Fatalf("%s: %+v, %v", what, act, err)
|
||||
}
|
||||
}
|
||||
act, err := s.Conn("client.example.org", milter.FamilyInet, 40000, "203.0.113.7")
|
||||
step("connect", act, err)
|
||||
if err := s.Macros(milter.CodeMail, "{auth_authen}", "app1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
act, err = s.Mail("<noreply@example.com>", []string{"SIZE=100", "BODY=8BITMIME"})
|
||||
step("mail", act, err)
|
||||
for _, r := range []struct {
|
||||
rcpt string
|
||||
args []string
|
||||
}{
|
||||
{`<"a b"@example.net>`, []string{"NOTIFY=SUCCESS"}},
|
||||
{`<"ab"@example.net>`, nil},
|
||||
{`<AB@example.net>`, nil}, // ab@example.net again, in capitals
|
||||
} {
|
||||
act, err = s.Rcpt(r.rcpt, r.args)
|
||||
step("rcpt "+r.rcpt, act, err)
|
||||
}
|
||||
act, err = s.HeaderField("Subject", "hello")
|
||||
step("header", act, err)
|
||||
act, err = s.HeaderEnd()
|
||||
step("end of headers", act, err)
|
||||
if err := s.Macros(milter.CodeEOB, "i", "Q1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, act, err = s.End()
|
||||
step("end", act, err)
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
rec.mu.Lock()
|
||||
var got []string
|
||||
for _, e := range rec.entries {
|
||||
got = append(got, e.To)
|
||||
}
|
||||
rec.mu.Unlock()
|
||||
if strings.Join(got, "; ") == `"a b"@example.net; ab@example.net` {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("rows for %v, want \"a b\"@example.net and ab@example.net", got)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user