refused messages: the page shows what Postfix wrote (stage 2b, 0009)
The Delivery log box of every refused row said "This message never reached
the queue, so Postfix wrote no delivery lines for it" - untrue: Postfix
wrote a line for each. The owner, 2026-10-13: the box is required on every
message page. Pictured for him first (dev/shots, the refused variant of
out-message: wrong From with its queue id lines, rate-limited with the
NOQUEUE line); the variant has no mockup screen of its own - the guard ties
screens to pages, and its outline is out-message's.
- Wrong From: cleanup gives the message a queue id before the headers are
judged; the sender check reads {i} at end-of-headers and writes it on the
refused row. Its lines - stored under the id like any other's - are on the
page, but only those from 10 minutes before to 1 minute after the refusal
(store.SendLogLines for a rejected row): Postfix frees the id at once, and
a later message with it lends nothing. No mail.log fallback for a refused
row, for the same reason.
- Rate-limited and wrong client: refused at MAIL FROM, no queue id. Migration
0009 adds send_log.refusal_line; logtail.refusalLines matches smtpd's
"NOQUEUE: milter-reject: MAIL from ...; from=<...>" (not port 25's, not
CONNECT/EHLO) and store.AttachRefusalLine puts it on the newest refused row
of that sender (and login, when the line names it) within two minutes; a
line whose row is not written yet waits up to 30 s (retried at each pass).
- The page: the queue id fact says "given, the message was not queued" or
"none - refused at MAIL FROM, before one is given"; the box's caption is
"The line from mail.log for this refusal" for the latter; a row from
before says the line was not kept. LogLineLevel colours a refusal by its
reply: 5xx red, 4xx amber (IPv6 clients included). The id in the fact is
not muted as in the picture: the kit has no muted inline.
Tests: store (attach by sender, login, time; not a queued row, not one an
hour off; a refused row's lines are its own minutes); logtail (attach, wait
and retry, port 25 and other lines ignored); milter (the refused row carries
{i}); view (facts, caption, line levels); handlers (the line on the page,
the sentence without it). e2e refusals_show_postfixs_line: a client-address
refusal made there, and the pages of a wrong From, a rate-limited and a
wrong client refusal each show Postfix's line.
schema-migrations row 9, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 25/62.
Evidence on the test server for this tree: make e2e exit 0 (the new case
in 5.4 s); go test ./... ok but for two schema tests, fixed after the run
(the schema at 0009) - store tests ok locally; the full run follows before
the push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -241,6 +241,12 @@ const deliveryLogMargin = 24 * time.Hour
|
||||
// reported as a fault, and that one is logged for the operator as well.
|
||||
func (h *Handlers) deliveryLog(row store.SendLogRow) ([]view.LogLine, string) {
|
||||
if row.QueueID == "" {
|
||||
if row.RefusalLine != "" {
|
||||
return logLines([]string{row.RefusalLine}), ""
|
||||
}
|
||||
if row.Status == store.StatusRejected {
|
||||
return nil, "Postfix's line about this refusal is not stored: the message was refused before version 2.5.1, or the line was never read."
|
||||
}
|
||||
return nil, "This message never reached the queue, so Postfix wrote no delivery lines for it."
|
||||
}
|
||||
stored, err := h.store.SendLogLines(row, h.cfg.RetryPolicy.LongestLifetime()+deliveryLogMargin, deliveryLogLines)
|
||||
@@ -252,7 +258,9 @@ func (h *Handlers) deliveryLog(row store.SendLogRow) ([]view.LogLine, string) {
|
||||
for i, l := range stored {
|
||||
lines[i] = l.Line
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
// A refused message's id was freed at once and may be another's by now:
|
||||
// what was not stored with the row is not looked for in the log.
|
||||
if len(lines) == 0 && row.Status != store.StatusRejected {
|
||||
lines, err = logtail.QueueLines(h.cfg.MailLogPath, row.QueueID, deliveryLogLines)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
logf("panel: delivery log %s: %v", row.QueueID, err)
|
||||
@@ -262,6 +270,11 @@ func (h *Handlers) deliveryLog(row store.SendLogRow) ([]view.LogLine, string) {
|
||||
if len(lines) == 0 {
|
||||
return nil, "Nothing is stored for this queue id and nothing is in the current mail log. Lines are kept with the row since version 2.2.0; a message accepted before that had them only in mail.log, which has since rotated."
|
||||
}
|
||||
return logLines(lines), ""
|
||||
}
|
||||
|
||||
// logLines are lines of mail.log as the delivery log box shows them.
|
||||
func logLines(lines []string) []view.LogLine {
|
||||
out := make([]view.LogLine, len(lines))
|
||||
for i, line := range lines {
|
||||
// A line whose head is not a timestamp the log format recognises keeps its
|
||||
@@ -269,7 +282,7 @@ func (h *Handlers) deliveryLog(row store.SendLogRow) ([]view.LogLine, string) {
|
||||
stamp, rest := logtail.SplitTimestamp(line)
|
||||
out[i] = view.LogLine{Time: stamp, Text: rest, Level: view.LogLineLevel(rest)}
|
||||
}
|
||||
return out, ""
|
||||
return out
|
||||
}
|
||||
|
||||
// deliveriesBackURL rebuilds the delivery-log URL a detail page was opened
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
@@ -150,3 +151,38 @@ func TestADomainAddedAgainShowsItsAssigneeNothingOld(t *testing.T) {
|
||||
t.Errorf("the global log is missing a row")
|
||||
}
|
||||
}
|
||||
|
||||
// A refused message's page shows what Postfix wrote about the refusal: the
|
||||
// NOQUEUE line put on the row of a refusal at MAIL FROM, or, for a row from
|
||||
// before that line was kept, a sentence that says so (audit 2026-10-12,
|
||||
// stage 2b).
|
||||
func TestARefusedMessageShowsPostfixsLine(t *testing.T) {
|
||||
h, st := inboundHandlers(t)
|
||||
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
||||
d, _ := st.AddDomain("example.org", "mail")
|
||||
st.AddApplication(d.ID, "alerts", store.AddressModeWildcard, nil)
|
||||
for _, subject := range []string{"with its line", "from before"} {
|
||||
if err := st.InsertRejected(store.SendLogEntry{Domain: "example.org", AppLogin: "alerts", From: "alerts@example.org", Subject: subject, Reason: store.ReasonLimit}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if subject == "with its line" {
|
||||
line := time.Now().UTC().Format("2006-01-02T15:04:05Z") + " mail postfix/smtpd[2131]: NOQUEUE: milter-reject: MAIL from unknown[198.51.100.7]: 451 4.7.1 Service unavailable - try again later; from=<alerts@example.org> proto=ESMTP helo=<app>"
|
||||
if ok, err := st.AttachRefusalLine("alerts", "alerts@example.org", time.Now(), line); err != nil || !ok {
|
||||
t.Fatalf("attach = %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
rows, _ := st.QuerySendLog(store.SendLogFilter{AllDomains: true}, 10, 0)
|
||||
for _, r := range rows {
|
||||
body := send(h.HandleDelivery, &root, "GET", "/outbound/log/"+idStr(r.ID), map[string]string{"id": idStr(r.ID)}, nil).Body.String()
|
||||
if r.Subject == "with its line" {
|
||||
if !strings.Contains(body, `<span class="sp-w">mail postfix/smtpd[2131]: NOQUEUE: milter-reject: MAIL from unknown[198.51.100.7]: 451 4.7.1`) {
|
||||
t.Errorf("the page of a refusal with its line lacks it:\n%s", body)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(body, "the message was refused before version 2.5.1, or the line was never read") {
|
||||
t.Errorf("the page of a refusal without its line does not say why")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ package view
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -246,15 +247,23 @@ func NewOutMessage(m Meta, in MessageInput) *OutMessage {
|
||||
{Label: "Application", Value: value(in.App, in.AppHref), Mono: true},
|
||||
}
|
||||
history := Box{No: "02", Title: "History", Help: topicLink(HelpLog, "What the statuses mean")}
|
||||
delivery := Box{No: "03", Title: "Delivery log", End: Rich("Lines from ", Code("mail.log"), " with this queue id")}
|
||||
if in.Status == "rejected" {
|
||||
// A refused message was never accepted and never queued: its one date
|
||||
// is the refusal, and it has no queue id to look anything up by.
|
||||
// is the refusal. One refused for its From: line was given a queue id
|
||||
// before its headers were judged; one refused at MAIL FROM was not,
|
||||
// and its box holds Postfix's one line about the refusal.
|
||||
facts = append(facts, Fact{Label: "Refused", Value: Plain(formatStamp(in.Accepted))})
|
||||
if in.Reason == "from" {
|
||||
facts = append(facts, Fact{Label: "From: line", Value: Plain(orDash(in.HeaderFrom)), Mono: true, Small: true})
|
||||
history.Help = topicLink(HelpSenderCheck, "What an application may write in From:")
|
||||
}
|
||||
facts = append(facts, Fact{Label: "Queue id", Value: Plain("none - never queued")})
|
||||
if in.QueueID != "" {
|
||||
facts = append(facts, Fact{Label: "Queue id", Value: Rich(Mono(in.QueueID), " — given, the message was not queued")})
|
||||
} else {
|
||||
facts = append(facts, Fact{Label: "Queue id", Value: Rich("none - refused at ", Mono("MAIL FROM"), ", before one is given")})
|
||||
delivery.End = Rich("The line from ", Code("mail.log"), " for this refusal")
|
||||
}
|
||||
} else {
|
||||
facts = append(facts,
|
||||
Fact{Label: "Accepted", Value: Plain(formatStamp(in.Accepted))},
|
||||
@@ -276,7 +285,7 @@ func NewOutMessage(m Meta, in MessageInput) *OutMessage {
|
||||
Message: Box{No: "01", Title: "Message"},
|
||||
Facts: facts,
|
||||
History: history,
|
||||
Delivery: Box{No: "03", Title: "Delivery log", End: Rich("Lines from ", Code("mail.log"), " with this queue id")},
|
||||
Delivery: delivery,
|
||||
LogEmpty: EmptyState{Icon: "ti-file-text"},
|
||||
}
|
||||
}
|
||||
@@ -330,5 +339,17 @@ func LogLineLevel(text string) string {
|
||||
case strings.Contains(text, "status=deferred"), strings.Contains(text, " warning:"):
|
||||
return "warn"
|
||||
}
|
||||
// A refusal is as permanent as its reply: "550"/"5.7.1" red, "451" amber.
|
||||
if m := rejectCodeRe.FindStringSubmatch(text); m != nil {
|
||||
if m[1] == "5" {
|
||||
return "error"
|
||||
}
|
||||
return "warn"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// rejectCodeRe finds the reply of a refusal Postfix logged: the code or the
|
||||
// enhanced status after "reject: <stage> from <client>[<address>]:", from
|
||||
// smtpd ("reject:") and cleanup ("milter-reject:") alike.
|
||||
var rejectCodeRe = regexp.MustCompile(`reject: \S+ from [^\]]*\]: ([45])(?:\d\d|\.\d+\.\d+) `)
|
||||
|
||||
@@ -124,23 +124,28 @@ func TestOutMessageWithoutSubjectOrRecipient(t *testing.T) {
|
||||
out := renderSignedIn(t, "out-message", p)
|
||||
pageHas(t, "Message", out, `<h1 class="title is-4">(no subject)</h1>`, `<span>—</span><i class="ti ti-arrow-right sp-muted"></i><span>—</span>`,
|
||||
`<span class="tag is-light">rejected</span>`, `href="/outbound/log?domain=gone.example&p=2"`,
|
||||
`<dd>gone.example</dd>`, `<dd class="sp-mono">old</dd>`, `<dt>Refused</dt>`, `<dd>none - never queued</dd>`,
|
||||
`never reached the queue`)
|
||||
`<dd>gone.example</dd>`, `<dd class="sp-mono">old</dd>`, `<dt>Refused</dt>`,
|
||||
`<dd>none - refused at <span class="sp-mono">MAIL FROM</span>, before one is given</dd>`,
|
||||
`The line from <code>mail.log</code> for this refusal`, `never reached the queue`)
|
||||
pageLacks(t, "Message", out, `<pre`, `<a href="/outbound/domains/`, `Status reported`, `From: line`)
|
||||
}
|
||||
|
||||
// A row refused for its From: line says so in its tag, shows what the line
|
||||
// said, and its History links to the topic that explains the rule.
|
||||
// said, and its History links to the topic that explains the rule. The
|
||||
// message had a queue id - cleanup gives one before the headers are judged -
|
||||
// and its box shows Postfix's lines by it, the refusal red.
|
||||
func TestOutMessageRefusedForItsFromLine(t *testing.T) {
|
||||
at := time.Date(2026, 9, 21, 13, 40, 55, 0, time.UTC)
|
||||
p := NewOutMessage(admin(), MessageInput{ID: 184231, Domain: "shop.example.org", App: "shop",
|
||||
p := NewOutMessage(admin(), MessageInput{ID: 184231, QueueID: "7BCAF3C75DA0", Domain: "shop.example.org", App: "shop",
|
||||
From: "shop@shop.example.org", To: "k.berg@web.de", Subject: "A message from the director",
|
||||
Status: "rejected", Reason: "from", HeaderFrom: "director@example.org", Accepted: at, Reported: at}).
|
||||
WithDeliveryLog(nil, "This message never reached the queue, so Postfix wrote no delivery lines for it.")
|
||||
WithDeliveryLog([]LogLine{{Time: "13:40:55", Text: "postfix/cleanup[2120]: 7BCAF3C75DA0: milter-reject: END-OF-MESSAGE from unknown[198.51.100.7]: 5.7.1 From: director@example.org - not an address this application sends as",
|
||||
Level: LogLineLevel("postfix/cleanup[2120]: 7BCAF3C75DA0: milter-reject: END-OF-MESSAGE from unknown[198.51.100.7]: 5.7.1 From: director@example.org - not an address this application sends as")}}, "")
|
||||
out := renderSignedIn(t, "out-message", p)
|
||||
pageHas(t, "Message", out, `<span class="tag is-danger is-light">wrong From</span>`,
|
||||
`<dt>From: line</dt><dd class="sp-mono sp-small">director@example.org</dd>`, `<dt>Refused</dt>`,
|
||||
`<dd>none - never queued</dd>`, `href="/help#sender-check"`)
|
||||
`<span class="sp-mono">7BCAF3C75DA0</span> — given, the message was not queued`, `href="/help#sender-check"`,
|
||||
`Lines from <code>mail.log</code> with this queue id`, `<span class="sp-e">postfix/cleanup[2120]: 7BCAF3C75DA0: milter-reject`)
|
||||
}
|
||||
|
||||
func TestSendLogTag(t *testing.T) {
|
||||
@@ -184,12 +189,15 @@ func TestStepLevelAndLogLineLevel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
for text, want := range map[string]string{
|
||||
"postfix/smtp[1]: Q1: to=<a@b>, status=sent (250 OK)": "",
|
||||
"postfix/smtp[1]: Q1: to=<a@b>, status=deferred (try later)": "warn",
|
||||
"postfix/smtp[1]: Q1: to=<a@b>, status=bounced (no such user)": "error",
|
||||
"postfix/qmgr[1]: Q1: status=expired, returned to sender": "error",
|
||||
"postfix/smtp[1]: warning: TLS library problem": "warn",
|
||||
"opendkim[1]: Q1: DKIM-Signature field added": "",
|
||||
"postfix/smtp[1]: Q1: to=<a@b>, status=sent (250 OK)": "",
|
||||
"postfix/smtp[1]: Q1: to=<a@b>, status=deferred (try later)": "warn",
|
||||
"postfix/smtp[1]: Q1: to=<a@b>, status=bounced (no such user)": "error",
|
||||
"postfix/qmgr[1]: Q1: status=expired, returned to sender": "error",
|
||||
"postfix/smtp[1]: warning: TLS library problem": "warn",
|
||||
"opendkim[1]: Q1: DKIM-Signature field added": "",
|
||||
"postfix/cleanup[1]: Q1: milter-reject: END-OF-MESSAGE from unknown[10.77.0.1]: 5.7.1 From: x - no": "error",
|
||||
"postfix/smtpd[1]: NOQUEUE: milter-reject: MAIL from unknown[10.77.0.1]: 451 4.7.1 Service unavailable": "warn",
|
||||
"postfix/smtpd[1]: NOQUEUE: reject: RCPT from unknown[2001:db8::1]: 554 5.7.1 <a@b>: Relay access denied": "error",
|
||||
} {
|
||||
if got := LogLineLevel(text); got != want {
|
||||
t.Errorf("LogLineLevel(%q) = %q, want %q", text, got, want)
|
||||
|
||||
Reference in New Issue
Block a user