e2e: a negative case passes only on its own refusal (audit stage 0)

sendResult.refusedAt(stage, code, text) and replyIs(err, code, text):
nil only for the server's reply with that code and text at that stage;
a reset, a timeout or another reply is described for the failure.

- level 1: only anvil's "450 4.7.1 Error: too much mail from", only after
  a message of the loop was accepted. Held to that, it failed twice on
  the stand, each time for a real reason:
  1. waitForSMTPSReady took a TCP connect for readiness; Docker's port
     proxy accepts before Postfix listens and then resets - the reset
     once taken for the limit. It now waits for the TLS 220 greeting.
  2. the first message then got "553 ... not owned by user l1app": an
     smtpd idle from before the panel's postfix reload took the
     connection with the old sender_login_maps. The level-1 limit had
     never been what the case saw. The first message now waits (15 s at
     most) for the map, failing at once on any other refusal.
  Now: "level-1 limit tripped on attempt 51/60: 450 4.7.1 Error: too
  much mail from 10.77.0.1".
- level 2: 451 4.7.1 at MAIL, and both MAIL FROM of one connection.
- sender mismatch: 553 at RCPT, "not owned by user".
- no AUTH and the relay case: 554 "Client host rejected: Access
  denied" at RCPT; the relay case's comment claimed
  reject_unauth_destination, which on 465 comes after the client
  restriction - port 25 holds "554 Relay access denied".
- port 25: 550 5.1.1 for unknown recipients and report addresses.
- panel down: a 4xx reply (textproto), not just text starting with 4.

The product side (a second after an application is saved when its first
message can get 553) is written in the audit plan, not changed. Audit plan
line ticked with what was found, roadmap 7/61. No CHANGELOG line: tests only.

Evidence: make e2e on the test server for this tree, exit 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-14 14:10:47 +03:00
co-authored by Claude Opus 5.5
parent 8b9798884a
commit 96425dd515
6 changed files with 156 additions and 49 deletions
+58 -8
View File
@@ -2,9 +2,12 @@ package e2e
import (
"crypto/tls"
"errors"
"fmt"
"net"
"net/smtp"
"net/textproto"
"strings"
"time"
)
@@ -51,6 +54,40 @@ func (r sendResult) firstErr() error {
return nil
}
// refusedAt says nil only when the transaction was refused at stage
// ("connect", "AUTH", "MAIL", "RCPT" or "DATA") with the server's reply code
// and a reply text containing text. A negative case passes on its own refusal
// and nothing else: a reset or a timeout while Postfix was still starting once
// passed for the level-1 rate limit. Anything else is described for the
// failure message - where it stopped and what came.
func (r sendResult) refusedAt(stage string, code int, text string) error {
for _, s := range []struct {
name string
err error
}{{"connect", r.dialErr}, {"AUTH", r.authErr}, {"MAIL", r.mailErr}, {"RCPT", r.rcptErr}, {"DATA", r.dataErr}} {
if s.err == nil {
continue
}
if s.name != stage {
return fmt.Errorf("stopped at %s, not at %s: %v", s.name, stage, s.err)
}
return replyIs(s.err, code, text)
}
return fmt.Errorf("accepted, want refused at %s with %d", stage, code)
}
// replyIs says nil when err is the server's reply code with text in it.
func replyIs(err error, code int, text string) error {
var reply *textproto.Error
if !errors.As(err, &reply) {
return fmt.Errorf("no SMTP reply but %v, want %d ...%s", err, code, text)
}
if reply.Code != code || !strings.Contains(reply.Msg, text) {
return fmt.Errorf("reply %d %s, want %d ...%s", reply.Code, reply.Msg, code, text)
}
return nil
}
// attemptSend drives one SMTP transaction over the implicit-TLS port, exactly
// as an application client library would: TLS connect, EHLO, optional AUTH
// PLAIN, MAIL/RCPT/DATA. The certificate is the stand's throwaway self-signed
@@ -117,18 +154,31 @@ func attemptSend(a sendAttempt) sendResult {
return res
}
// waitForSMTPSReady polls the implicit-TLS port until a bare TCP connect
// succeeds. Used after a container (re)start: the panel's HTTP port and
// Postfix's smtpd come up independently (postfix-wrapper.sh additionally
// waits on both milter sockets before starting Postfix at all), so a caller
// that only waited for the panel could still race a not-yet-listening smtpd.
// waitForSMTPSReady polls the implicit-TLS port until Postfix greets over
// TLS. Used after a container (re)start: the panel's HTTP port and Postfix's
// smtpd come up independently (postfix-wrapper.sh additionally waits on both
// milter sockets before starting Postfix at all), so a caller that only
// waited for the panel could still race a not-yet-listening smtpd. A bare TCP
// connect is not enough: Docker's port proxy accepts on the host side before
// anything listens in the container and then resets the connection, which is
// how the level-1 rate-limit case once took a reset for its limit.
func waitForSMTPSReady(timeout time.Duration) error {
return waitFor("smtps port to accept connections", timeout, 300*time.Millisecond, func() (bool, error) {
conn, err := net.DialTimeout("tcp", smtpsAddr, 2*time.Second)
return waitFor("smtps port to greet", timeout, 300*time.Millisecond, func() (bool, error) {
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 2 * time.Second}, "tcp", smtpsAddr, &tls.Config{
InsecureSkipVerify: true,
ServerName: selfpostHostname,
})
if err != nil {
return false, err
}
_ = conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
c, err := smtp.NewClient(conn, selfpostHostname) // reads the 220 greeting
if err != nil {
_ = conn.Close()
return false, err
}
_ = c.Quit()
_ = c.Close()
return true, nil
})
}