e2e: the run ends on port 25, with a report, junk and inbound mail

Until now the suite only read back the configuration generated for the
two optional port-25 features. DMARC ingest shipped in 1.7.0 with a
delivery service Postfix could not start, and nothing sent a report
through it until a real one arrived on the test server on 2026-10-11.

The last steps recreate the container with INBOUND_RELAY_ENABLE and
DMARC_REPORTS_ENABLE on, over the same /data, and talk to port 25 as a
stranger's mail server:

- the container log has no postconf warning, the session survives;
- a domain takes the SelfPost-hosted report address; a gzip report
  mailed to it appears under Outbound > DMARC reports; an address nobody
  chose is refused at RCPT;
- a report on another domain and a message with no report are accepted,
  dropped with "dropped: ..." on their delivery lines, counted as two
  parse failures, absent from the queue and from the reports;
- an inbound domain with the sink as upstream and one listed recipient:
  mail for it reaches the sink, another recipient and another domain are
  refused at RCPT.

The stage directory now holds docker-compose.yml and .env, as an
operator's project directory does (a full backup needs them; the
restore scenario that uses it is not in this commit - the panel's full
backup turned out not to work, see the next one).

make e2e on the test server: ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-11 17:40:47 +03:00
co-authored by Claude Opus 5.5
parent 4dd46ea355
commit 8496b289ec
6 changed files with 444 additions and 4 deletions
+81
View File
@@ -0,0 +1,81 @@
package e2e
import (
"fmt"
"net"
"net/smtp"
"time"
)
// port25Addr is where compose.override.yml publishes port 25: no TLS required,
// no AUTH offered - a stranger's mail server talking to this one.
const port25Addr = "127.0.0.1:20025"
// deliverPort25 is one transaction on port 25 the way another mail server makes
// it: HELO, MAIL, RCPT, DATA with message as it stands. The result says at
// which stage it was refused, like attemptSend's.
func deliverPort25(from, to, message string) sendResult {
var res sendResult
conn, err := net.DialTimeout("tcp", port25Addr, 10*time.Second)
if err != nil {
res.dialErr = fmt.Errorf("dial %s: %w", port25Addr, err)
return res
}
c, err := smtp.NewClient(conn, selfpostHostname)
if err != nil {
res.dialErr = fmt.Errorf("smtp handshake: %w", err)
_ = conn.Close()
return res
}
defer c.Close()
if err := c.Hello("mx.stranger.e2e.test"); err != nil {
res.dialErr = err
return res
}
if err := c.Mail(from); err != nil {
res.mailErr = err
_ = c.Quit()
return res
}
if err := c.Rcpt(to); err != nil {
res.rcptErr = err
_ = c.Quit()
return res
}
w, err := c.Data()
if err != nil {
res.dataErr = err
_ = c.Quit()
return res
}
if _, err := w.Write([]byte(message)); err != nil {
res.dataErr = err
} else if err := w.Close(); err != nil {
res.dataErr = err
}
_ = c.Quit()
return res
}
// waitForPort25Ready polls port 25 until Postfix greets on it. A bare connect
// is not enough: Docker's proxy accepts on the published port whether or not
// anything listens behind it.
func waitForPort25Ready(timeout time.Duration) error {
return waitFor("port 25 to greet", timeout, 500*time.Millisecond, func() (bool, error) {
conn, err := net.DialTimeout("tcp", port25Addr, 2*time.Second)
if err != nil {
return false, err
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
buf := make([]byte, 4)
n, err := conn.Read(buf)
if err != nil {
return false, err
}
if string(buf[:n]) != "220 " {
return false, fmt.Errorf("greeting starts with %q", buf[:n])
}
return true, nil
})
}