The owner, 2026-10-16: the Inbound log lacks the upstream's answer - "Не понятна судьба письма: принято, отклонено, хост не доступен"; the result in the row only, a page per message later if needed. Mockups5348330and46b90d2, plan295d72e. - Migration 0012: inbound_spam_log gains queue_id, upstream_status ('', queued, held, sent, deferred, bounced; CHECK), upstream_relay, upstream_reply, upstream_at, and a partial index on queue_id. Schema 12. - The inbound journal milter writes an accepted row with the queue id of end-of-message and "queued" ("held" when the filter had it held), the recipient as Postfix logs it (recipientAsLogged), after detaching older rows of a queue id Postfix uses again; no queue id, no result. - The tailer: a delivery line no send-log row took goes to the journal - status, relay= ("" for none), the reply after the first status=, cut to 255 printable bytes, the line's time; queued/held/deferred rows only, sent and bounced final. qmgr's expiry makes them bounced, keeping the last reply. cleanup's "removed (" detaches rows of a message never queued. Addresses in delivery lines are read by Postfix's quoting (loggedAddress): a quoted RCPT TO cannot end one early. - The page: an Upstream column - a plain tag for queued and held, else a tag opening the host, the reply, when and what follows; "—" for refused mail and older rows. Help (word for word with the mockup), guide, architecture, security, schema-migrations, CHANGELOG. Reviewed before the commit (technical and security, 2026-10-16); the plan lists each finding and what it became. Tests: store (results, finality, expiry, held, a reused id, the queue-id index in every plan), milter (queue id, held, quoted recipient, no {i}), logtail (relay and reply, a forged quoted recipient, Run hands the journal what the send log did not take, cleanup's removal), view (the column, held, null sender), e2e (both relayed messages sent with the sink's 250; an upstream refusing connections deferred with the reason and no host, then expired to bounced; the refused row has none). go vet + go test (26 packages) and make e2e on the test server for this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testInboundUpstreamDown: an inbound domain whose upstream refuses
|
||||
// connections (the sink's address, a port nothing listens on). Port 25 takes
|
||||
// the message; Postfix cannot hand it on and logs "relay=none ...
|
||||
// status=deferred (connect to ...: Connection refused)" (Postfix 3.10.13,
|
||||
// src/smtp/smtp_trouble.c:273, 421), which the Inbound log shows as deferred
|
||||
// with that reason and no host. Then the message is made to expire - postsuper
|
||||
// -e, postqueue -i, as testExpiredMessageIsClosed does - and its row is
|
||||
// bounced, its last reply kept. The queue is emptied after: the bounce to the
|
||||
// stranger has nowhere to go on the stand. Runs inside testInboundRelay.
|
||||
func testInboundUpstreamDown(t *testing.T, sc *scenario, message func(to, token string) string) {
|
||||
const domain = "down.e2e.test"
|
||||
to := "user@" + domain
|
||||
resp, body, err := sc.panel.postForm("/inbound/domains", url.Values{"name": {domain}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := regexp.MustCompile(`^/inbound/domains/(\d+)$`).FindStringSubmatch(resp.Request.URL.Path)
|
||||
if m == nil {
|
||||
t.Fatalf("add inbound domain: landed on %s:\n%.800s", resp.Request.URL.Path, body)
|
||||
}
|
||||
base := "/inbound/domains/" + m[1]
|
||||
for path, form := range map[string]url.Values{
|
||||
base + "/upstream": {"host": {"10.77.0.11"}, "port": {"2526"}, "tls_mode": {"none"}},
|
||||
base + "/recipients": {"recipient_mode": {"list"}, "addresses": {to}},
|
||||
} {
|
||||
resp, body, err := sc.panel.postForm(path, form)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != 200 || resp.Request.URL.Path != base {
|
||||
t.Fatalf("POST %s: status %d, landed on %s:\n%.800s", path, resp.StatusCode, resp.Request.URL.Path, body)
|
||||
}
|
||||
}
|
||||
defer h.execIn("selfpost", "postsuper", "-d", "ALL")
|
||||
|
||||
token := uniqueToken("inbound-down")
|
||||
if res := deliverPort25("someone@stranger.e2e.test", to, message(to, token)); !res.ok() {
|
||||
t.Fatalf("port 25 did not accept mail for %s: %v", to, res.firstErr())
|
||||
}
|
||||
row := func() (string, error) {
|
||||
return asPanel(`import sqlite3,sys
|
||||
r=sqlite3.connect("/data/selfpost.db").execute("select queue_id, upstream_status, upstream_relay, upstream_reply from inbound_spam_log where subject like ? order by id desc",("%"+sys.argv[1]+"%",)).fetchone()
|
||||
print("" if r is None else "|".join(r))`, token)
|
||||
}
|
||||
var queueID string
|
||||
if err := waitFor("the row to be deferred", 60*time.Second, time.Second, func() (bool, error) {
|
||||
out, err := row()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
f := strings.Split(out, "|")
|
||||
if len(f) == 4 && f[1] == "deferred" {
|
||||
if f[2] != "" || !strings.HasPrefix(f[3], "connect to 10.77.0.11[10.77.0.11]:2526: Connection refused") {
|
||||
return false, fmt.Errorf("deferred with host %q, reply %q", f[2], f[3])
|
||||
}
|
||||
queueID = f[0]
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("row %q\n%s", out, mailLogTail(h, "postfix/"))
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, body, err = sc.panel.get("/inbound/log")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(body, `<span class="tag is-warning is-light">deferred</span><h2>Upstream</h2>`) ||
|
||||
!strings.Contains(body, `<tr><th>Reply</th><td class="sp-mono sp-small">connect to 10.77.0.11[10.77.0.11]:2526: Connection refused</td></tr>`) ||
|
||||
!strings.Contains(body, `Postfix tries again; the message is in the queue`) {
|
||||
t.Fatalf("the Inbound log does not show the deferral:\n%.4000s", body)
|
||||
}
|
||||
|
||||
for _, args := range [][]string{{"postsuper", "-e", queueID}, {"postqueue", "-i", queueID}} {
|
||||
if out, err := h.execIn("selfpost", args...); err != nil {
|
||||
t.Fatalf("%s: %v\n%s", strings.Join(args, " "), err, out)
|
||||
}
|
||||
}
|
||||
if err := waitFor("the expired row to be bounced", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
|
||||
out, err := row()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
f := strings.Split(out, "|")
|
||||
if len(f) == 4 && f[1] == "bounced" &&
|
||||
strings.HasPrefix(f[3], "expired in the queue; last reply: connect to 10.77.0.11[10.77.0.11]:2526") {
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("row %q\n%s", out, mailLogTail(h, "postfix/"))
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user