sender check: a Sender: line is held to the rule From: is held to
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
The owner's decision (2026-10-16) on the security review's F1: a Sender: line - the sending agent's, shown beside From: as "on behalf of", and signed since 2.8.0 - was neither held to the login nor, before, signed: an application could write its own From: and Sender: ceo@bank.com under this server's signature. Now, when a message has one, it must be one address the application may send as, and at most one such line (RFC 5322 § 3.6); From: is judged first. The refusal is "550 5.7.1 Sender: <address> - <why>", a send-log row of reason "sender" (store.ReasonSender), tagged "wrong Sender"; the message page names the line. The log tailer recovers such a row from cleanup's line as it does a From: refusal (fromRefusalRe reads either). Help text as in the design commit d9d9af3; guide, architecture, security and CHANGELOG say it, security.md keeps Resent-From: (signed, not judged) as an accepted risk. Tests: TestSenderLineHeldToTheSameRule (own address, a name, another application's, a stranger's, two addresses, two lines, From: first), the recovery of a Sender: refusal line, the tag, the message page; e2e sender_check_holds_the_sender_line (own Sender: passes and is signed, another domain's refused at DATA and logged). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,7 +59,10 @@ type RefusalStore interface {
|
||||
// src/cleanup/cleanup_milter.c, cleanup_milter_apply(): "%s: %s: %s from
|
||||
// %s[%s]: %s;" with the reply from its fifth byte (the "550 " dropped), then
|
||||
// " from=<..>", " to=<..>" - the last recipient only (assumptions A-15).
|
||||
var fromRefusalRe = regexp.MustCompile(`^\S+ postfix/(?:[a-z0-9-]+/)?cleanup\[\d+\]: ([0-9A-Za-z]+): milter-reject: END-OF-MESSAGE from \S+: 5\.7\.1 From: ([^;]*) - ([^;]*); from=<([^>]*)> to=<([^>]*)>`)
|
||||
//
|
||||
// Since 2.8.0 the check refuses for a Sender: line too, "5.7.1 Sender: ..."
|
||||
// in the same form; the second group says which.
|
||||
var fromRefusalRe = regexp.MustCompile(`^\S+ postfix/(?:[a-z0-9-]+/)?cleanup\[\d+\]: ([0-9A-Za-z]+): milter-reject: END-OF-MESSAGE from \S+: 5\.7\.1 (From|Sender): ([^;]*) - ([^;]*); from=<([^>]*)> to=<([^>]*)>`)
|
||||
|
||||
// clientLoginRe is smtpd's line naming the login a queue id was submitted
|
||||
// under: "<id>: client=host[addr], sasl_method=PLAIN, sasl_username=<login>".
|
||||
@@ -137,10 +140,14 @@ func (r *refusalLines) handle(line string, now time.Time, start Place) {
|
||||
if ok && login.start.FP == start.FP && login.start.End < start.End {
|
||||
from = login.start
|
||||
}
|
||||
reason := store.ReasonFrom
|
||||
if m[2] == "Sender" {
|
||||
reason = store.ReasonSender
|
||||
}
|
||||
r.recover = append(r.recover, pendingRecovery{
|
||||
e: store.SendLogEntry{
|
||||
QueueID: m[1], AppLogin: login.login, From: m[4], To: m[5], Domain: domainOf(m[4]),
|
||||
Reason: store.ReasonFrom, HeaderFrom: m[2],
|
||||
QueueID: m[1], AppLogin: login.login, From: m[5], To: m[6], Domain: domainOf(m[5]),
|
||||
Reason: reason, HeaderFrom: m[3],
|
||||
},
|
||||
at: at, until: now.Add(refusalLineWait), start: from,
|
||||
})
|
||||
|
||||
@@ -98,6 +98,18 @@ func TestFromRefusalIsRecoveredAfterTheWait(t *testing.T) {
|
||||
if e != want {
|
||||
t.Fatalf("recovered %+v, want %+v", e, want)
|
||||
}
|
||||
|
||||
// A refusal for the Sender: line (2.8.0) is recovered as one: composed
|
||||
// after the From: line above, with the check's reply as sender.go writes
|
||||
// it.
|
||||
st = &refusalProbe{}
|
||||
r = &refusalLines{st: st}
|
||||
r.handle(`Oct 14 12:00:00 mail postfix/smtpd[11]: F1A2B3C4D5E6: client=unknown[10.77.0.1], sasl_method=PLAIN, sasl_username=app1`, now, Place{})
|
||||
r.handle(`Oct 14 12:00:01 mail postfix/cleanup[13]: F1A2B3C4D5E6: milter-reject: END-OF-MESSAGE from unknown[10.77.0.1]: 5.7.1 Sender: ceo@bank.example - not an address this application sends as; from=<alerts@sender.e2e.test> to=<rcpt@sink.e2e.test> proto=ESMTP helo=<x>`, now, Place{})
|
||||
r.retry(now.Add(refusalLineWait + time.Second))
|
||||
if len(st.recovered) != 1 || st.recovered[0].Reason != store.ReasonSender || st.recovered[0].HeaderFrom != "ceo@bank.example" || st.recovered[0].From != "alerts@sender.e2e.test" {
|
||||
t.Fatalf("recovered %+v, want one Sender: refusal", st.recovered)
|
||||
}
|
||||
}
|
||||
|
||||
// While a line or a recovery waits, the offset saved for its file stays at the
|
||||
|
||||
+48
-15
@@ -284,6 +284,7 @@ type senderSession struct {
|
||||
rcpts []string
|
||||
subject string
|
||||
froms []string // the value of every From: field of the message
|
||||
senders []string // and of every Sender: field
|
||||
}
|
||||
|
||||
// MailFrom starts a message and forgets the one before it: a connection may
|
||||
@@ -297,7 +298,7 @@ type senderSession struct {
|
||||
func (s *senderSession) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
|
||||
s.from = cleanAddress(from)
|
||||
s.login = macro(m, "auth_authen")
|
||||
s.rcpts, s.subject, s.froms = nil, "", nil
|
||||
s.rcpts, s.subject, s.froms, s.senders = nil, "", nil, nil
|
||||
return milter.RespContinue, nil
|
||||
}
|
||||
|
||||
@@ -311,6 +312,8 @@ func (s *senderSession) Header(name, value string, m *milter.Modifier) (milter.R
|
||||
switch textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(name)) {
|
||||
case "From":
|
||||
s.froms = append(s.froms, value)
|
||||
case "Sender":
|
||||
s.senders = append(s.senders, value)
|
||||
case "Subject":
|
||||
if s.subject == "" {
|
||||
s.subject = mailhdr.DecodeSubject(value)
|
||||
@@ -333,7 +336,7 @@ func (s *senderSession) Header(name, value string, m *milter.Modifier) (milter.R
|
||||
// second message it can still hold the first one's From:. The session keeps
|
||||
// its own, emptied at each MAIL FROM (audit 2026-10-12, L9).
|
||||
func (s *senderSession) Headers(h textproto.MIMEHeader, m *milter.Modifier) (milter.Response, error) {
|
||||
shown, why, err := s.judge()
|
||||
field, shown, why, err := s.judge()
|
||||
if err != nil {
|
||||
// Strict: what cannot be checked is not let through.
|
||||
log.Printf("sender-check: %v - deferring mail of %q", err, s.login)
|
||||
@@ -342,27 +345,41 @@ func (s *senderSession) Headers(h textproto.MIMEHeader, m *milter.Modifier) (mil
|
||||
if why == "" {
|
||||
return milter.RespContinue, nil
|
||||
}
|
||||
log.Printf("sender-check: application %q refused: %s (From: %s)", s.login, why, shown)
|
||||
log.Printf("sender-check: application %q refused: %s (%s: %s)", s.login, why, field, shown)
|
||||
// cleanup has given the message a queue id by the end of its headers
|
||||
// (milter_end_of_header_macros has {i}); Postfix's lines about it - the
|
||||
// refusal among them - are found by it on the message's page.
|
||||
// Postfix 3.10.13, src/global/mail_params.h: DEF_MILT_EOH_MACROS "i",
|
||||
// answered by src/cleanup/cleanup_milter.c, cleanup_milter_eval().
|
||||
s.record(shown, macro(m, "i"))
|
||||
reason := store.ReasonFrom
|
||||
if field == "Sender" {
|
||||
reason = store.ReasonSender
|
||||
}
|
||||
s.record(reason, shown, macro(m, "i"))
|
||||
// A reply code of its own, so that the application is told what is wrong
|
||||
// and, the refusal being permanent, does not send the message again.
|
||||
return milter.NewResponseStr('y', "550 5.7.1 From: "+shown+" - "+why), nil
|
||||
return milter.NewResponseStr('y', "550 5.7.1 "+field+": "+shown+" - "+why), nil
|
||||
}
|
||||
|
||||
func (s *senderSession) Abort(m *milter.Modifier) error {
|
||||
s.rcpts, s.subject, s.froms = nil, "", nil
|
||||
s.rcpts, s.subject, s.froms, s.senders = nil, "", nil, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// judge decides about the message at hand. why is empty when it may pass;
|
||||
// otherwise it says what is wrong, and shown is the From: line in a form that
|
||||
// is safe to print - both go into the reply to the client.
|
||||
func (s *senderSession) judge() (shown, why string, err error) {
|
||||
// otherwise it says what is wrong, field is the header it is about ("From"
|
||||
// or "Sender"), and shown is that line in a form that is safe to print - all
|
||||
// go into the reply to the client.
|
||||
//
|
||||
// A Sender: line - the agent that sent a message written by the From:
|
||||
// line's author (RFC 5322 § 3.6.2), which a mail program shows beside From:
|
||||
// as "on behalf of" - is held to the same rule as From: when the message has
|
||||
// one, and at most one is allowed (§ 3.6, its table). The panel's signer
|
||||
// signs it (sign.go), so an address of another sender there would be shown
|
||||
// under this server's signature (security review of panel-dkim-signing, F1;
|
||||
// the owner's decision, 2026-10-16).
|
||||
func (s *senderSession) judge() (field, shown, why string, err error) {
|
||||
field = "From"
|
||||
if s.login == "" {
|
||||
// Not reachable while the submission ports demand authentication; a
|
||||
// message nobody answers for is not signed either way.
|
||||
@@ -370,15 +387,32 @@ func (s *senderSession) judge() (shown, why string, err error) {
|
||||
// smtpd_delay_reject (default yes) smtpd_client_restrictions
|
||||
// (permit_sasl_authenticated,reject on 465 and 587) refuse every RCPT;
|
||||
// src/smtpd/smtpd.c, data_cmd(): no valid recipients, no message.
|
||||
return "(none)", "no authenticated application", nil
|
||||
return field, "(none)", "no authenticated application", nil
|
||||
}
|
||||
switch len(s.froms) {
|
||||
case 0:
|
||||
return "(none)", "the message has no From: line", nil
|
||||
return field, "(none)", "the message has no From: line", nil
|
||||
case 1:
|
||||
default:
|
||||
return printable(strings.Join(s.froms, " | ")), "the message has more than one From: line", nil
|
||||
return field, printable(strings.Join(s.froms, " | ")), "the message has more than one From: line", nil
|
||||
}
|
||||
if shown, why, err = s.judgeLine(s.froms[0]); err != nil || why != "" {
|
||||
return field, shown, why, err
|
||||
}
|
||||
field = "Sender"
|
||||
switch len(s.senders) {
|
||||
case 0:
|
||||
return field, "", "", nil
|
||||
case 1:
|
||||
default:
|
||||
return field, printable(strings.Join(s.senders, " | ")), "the message has more than one Sender: line", nil
|
||||
}
|
||||
shown, why, err = s.judgeLine(s.senders[0])
|
||||
return field, shown, why, err
|
||||
}
|
||||
|
||||
// judgeLine holds one From: or Sender: line against the application.
|
||||
func (s *senderSession) judgeLine(raw string) (shown, why string, err error) {
|
||||
// The line must be one address in RFC 5322's grammar (net/mail), the
|
||||
// address a mail program shows; a display name may say anything. A line
|
||||
// a lenient program could show as another address - "ceo@bank.com
|
||||
@@ -389,7 +423,6 @@ func (s *senderSession) judge() (shown, why string, err error) {
|
||||
// 3.10.13, src/global/tok822_parse.c), is the one held against the
|
||||
// bindings: "x"@mine.com is x@mine.com, which may be another
|
||||
// application's (F2).
|
||||
raw := s.froms[0]
|
||||
shown = printable(raw)
|
||||
list, perr := fromParser.ParseList(unfold(raw))
|
||||
if perr != nil || len(list) != 1 {
|
||||
@@ -439,7 +472,7 @@ func asciiLower(v string) string {
|
||||
// each recipient as an accepted message has: nothing was sent to anybody, and
|
||||
// a refused message with a thousand recipients would otherwise be a thousand
|
||||
// rows at the price of one.
|
||||
func (s *senderSession) record(shown, queueID string) {
|
||||
func (s *senderSession) record(reason, shown, queueID string) {
|
||||
if s.refused == nil {
|
||||
return
|
||||
}
|
||||
@@ -457,7 +490,7 @@ func (s *senderSession) record(shown, queueID string) {
|
||||
From: s.from,
|
||||
To: to,
|
||||
Subject: s.subject,
|
||||
Reason: store.ReasonFrom,
|
||||
Reason: reason,
|
||||
HeaderFrom: shown,
|
||||
}
|
||||
select {
|
||||
|
||||
@@ -415,3 +415,48 @@ func TestSenderCheckPassesTheSharedLines(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A Sender: line, when the message has one, is held to the rule From: is
|
||||
// held to; the From: line is judged first (the owner's decision, 2026-10-16).
|
||||
func TestSenderLineHeldToTheSameRule(t *testing.T) {
|
||||
bind := NewBindings(writeMaps(t, senderMaps))
|
||||
from := [2]string{"From", "a@shop.example.org"}
|
||||
sender := func(v string) [2]string { return [2]string{"Sender", v} }
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
headers [][2]string
|
||||
field string // the line refused; "" when the message passes
|
||||
wantText string
|
||||
}{
|
||||
{"no Sender: line", [][2]string{from}, "", ""},
|
||||
{"its own address", [][2]string{from, sender("shop@shop.example.org")}, "", ""},
|
||||
{"its own address, with a name", [][2]string{from, sender(`"The Shop's mailer" <mailer@shop.example.org>`)}, "", ""},
|
||||
{"header name in another case", [][2]string{from, {"SENDER", "ceo@bank.example"}}, "Sender", "not an address this application sends as"},
|
||||
{"a stranger's address", [][2]string{from, sender("ceo@bank.example")}, "Sender", "not an address this application sends as"},
|
||||
{"another application's address", [][2]string{from, sender("desk@shop.example.org")}, "Sender", "not an address this application sends as"},
|
||||
{"two addresses", [][2]string{from, sender("a@shop.example.org, ceo@bank.example")}, "Sender", "one address"},
|
||||
{"two Sender: lines", [][2]string{from, sender("a@shop.example.org"), sender("b@shop.example.org")}, "Sender", "more than one Sender: line"},
|
||||
{"a wrong From: and a wrong Sender:", [][2]string{{"From", "ceo@bank.example"}, sender("x@bank.example")}, "From", "not an address this application sends as"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
resp, rows := offer(t, bind, "shop", "shop@shop.example.org", c.headers...)
|
||||
if c.field == "" {
|
||||
if resp != milter.RespContinue || len(rows) != 0 {
|
||||
t.Fatalf("refused: %s", replyText(resp))
|
||||
}
|
||||
return
|
||||
}
|
||||
text := replyText(resp)
|
||||
if !strings.HasPrefix(text, "y 550 5.7.1 "+c.field+": ") || !strings.Contains(text, c.wantText) {
|
||||
t.Fatalf("reply %q, want a 550 5.7.1 about %s: saying %q", text, c.field, c.wantText)
|
||||
}
|
||||
reason := store.ReasonFrom
|
||||
if c.field == "Sender" {
|
||||
reason = store.ReasonSender
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Reason != reason {
|
||||
t.Fatalf("log rows: %+v, want one of reason %q", rows, reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const (
|
||||
ReasonLimit = "limit" // a level-2 rate limit
|
||||
ReasonClient = "client" // the application's list of client addresses
|
||||
ReasonFrom = "from" // the From: line is not the application's
|
||||
ReasonSender = "sender" // the Sender: line is not the application's (2.8.0)
|
||||
)
|
||||
|
||||
// Who decided a "bounced" row the log did not (send_log.reason).
|
||||
|
||||
@@ -20,8 +20,8 @@ const (
|
||||
// refusal. The journal-milter refuses at MAIL FROM with a 4xx when a level-2
|
||||
// rate limit or the application's client-address list says no (Reason is
|
||||
// ReasonLimit or ReasonClient; such a row has no queue-id). The sender check
|
||||
// refuses a From: line that is not the application's with a 550 (ReasonFrom;
|
||||
// its row has the queue-id). Every refused row is left out of the level-2
|
||||
// refuses a From: or Sender: line that is not the application's with a
|
||||
// 550 (ReasonFrom, ReasonSender; its row has the queue-id). Every refused row is left out of the level-2
|
||||
// message count (it was never sent).
|
||||
StatusRejected = "rejected"
|
||||
)
|
||||
@@ -38,7 +38,8 @@ type SendLogEntry struct {
|
||||
To string
|
||||
Subject string
|
||||
// Reason and HeaderFrom are set on refused rows only: why the message
|
||||
// was refused (Reason*), and for ReasonFrom what its From: line said.
|
||||
// was refused (Reason*), and for ReasonFrom and ReasonSender the address
|
||||
// of the line refused (the column's name is from before Sender: was).
|
||||
Reason string
|
||||
HeaderFrom string
|
||||
// ClientIP is the client a refusal at MAIL FROM came from (InsertRejected
|
||||
|
||||
@@ -177,10 +177,14 @@ func deliveryEvents(row store.SendLogRow, policy postfix.RetryPolicy, sendsAs vi
|
||||
// acceptance: the journal-milter refused it, so Postfix never queued it.
|
||||
if row.Status == store.StatusRejected {
|
||||
switch row.Reason {
|
||||
case store.ReasonFrom:
|
||||
case store.ReasonFrom, store.ReasonSender:
|
||||
// The one step that names things: which line, whose, and what that
|
||||
// application may write instead.
|
||||
text := view.Rich(" — ", view.Code("From: "+row.HeaderFrom), " is not an address of application ", view.Mono(row.AppLogin))
|
||||
field := "From: "
|
||||
if row.Reason == store.ReasonSender {
|
||||
field = "Sender: "
|
||||
}
|
||||
text := view.Rich(" — ", view.Code(field+row.HeaderFrom), " is not an address of application ", view.Mono(row.AppLogin))
|
||||
if len(sendsAs) > 0 {
|
||||
text = append(append(text, view.Inline{Text: ", which sends as "}), sendsAs...)
|
||||
}
|
||||
|
||||
@@ -214,6 +214,25 @@ func TestMessagePageOfARefusedMessage(t *testing.T) {
|
||||
lacks(t, "refused message", body, `<pre`)
|
||||
}
|
||||
|
||||
// A message refused for its Sender: line says so - which line, and whose
|
||||
// address it is not - and the log tags it apart from a From: refusal.
|
||||
func TestMessagePageOfASenderRefusal(t *testing.T) {
|
||||
h, _ := serverWithDelivery(t)
|
||||
if err := h.store.InsertRejected(store.SendLogEntry{
|
||||
Domain: "bs.example.ru", AppLogin: "Queuer3C", From: "noreply@bs.example.ru", To: "late@example.ru", Subject: "On behalf",
|
||||
Reason: store.ReasonSender, HeaderFrom: "ceo@bank.example",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := h.store.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
|
||||
if err != nil || len(rows) != 1 || rows[0].Reason != store.ReasonSender {
|
||||
t.Fatalf("query: %v (%+v)", err, rows)
|
||||
}
|
||||
body := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(rows[0].ID))
|
||||
has(t, "Sender refusal", body, `Refused before queueing`, `<code>Sender: ceo@bank.example</code> is not an address of application`, `wrong Sender`)
|
||||
has(t, "log", getBody(t, h.HandleDeliveries, "/outbound/log"), `">wrong Sender</span>`)
|
||||
}
|
||||
|
||||
// Whatever the viewer reaches, another tenant's message is a 404 on its own
|
||||
// page whether the row exists or not.
|
||||
func TestMessagePageIsNotFoundForOtherTenants(t *testing.T) {
|
||||
|
||||
@@ -166,10 +166,10 @@ var outboundTopics = []HelpTopic{
|
||||
Plain("An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form."),
|
||||
}},
|
||||
{ID: HelpSenderCheck, Title: "Sender check", Body: []Text{
|
||||
Rich("An application sends only as itself, and that holds for both senders a message has. The envelope sender is held against the login by Postfix. The ",
|
||||
Rich("An application sends only as itself, and that holds for every sender a message names. The envelope sender is held against the login by Postfix. The ",
|
||||
Code("From:"), " line, the one a mail program shows and the one DMARC holds the DKIM signature to, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other ",
|
||||
Code("From:"), " - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with ",
|
||||
Code("550 5.7.1"), ". The line must hold one address, read as a mail program reads it: what a display name around it says is not judged, and a line holding more than one address is refused. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from sending, under this server's signature, as another domain or another application.",
|
||||
Code("550 5.7.1"), ". The line must hold one address, read as a mail program reads it: what a display name around it says is not judged, and a line holding more than one address is refused. A ", Code("Sender:"), " line, which a mail program shows beside ", Code("From:"), " as \"on behalf of\", is held to the same rule when the message has one. A system that writes for people outside your domains writes its own address in ", Code("From:"), " with the person's name, and the person's address in ", Code("Reply-To:"), ": their domain in ", Code("From:"), " would fail DMARC at the receiver anyway. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from sending, under this server's signature, as another domain or another application.",
|
||||
Br(), Br(),
|
||||
"The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; how many connections Postfix turned away because of it - it refuses from the connection on, before a message is offered, so a client that tries three times is three and a monitor that only connects is one; and how many messages were refused with a temporary error in the middle, when the panel stopped answering after a message had begun. A time marked ≈ includes a start at which supervisord could not say since when Postfix was running: the time since the panel's last sign of life is counted, which may be more than Postfix was up. The card warns when a connection was turned away or a message refused in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own."),
|
||||
}},
|
||||
@@ -177,7 +177,7 @@ var outboundTopics = []HelpTopic{
|
||||
Rich("Level 1 is a per-IP ceiling set in ", Code(".env"), ". Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's."),
|
||||
}},
|
||||
{ID: HelpLog, Title: "Outbound log", Body: []Text{
|
||||
Rich("One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and ", Code("mail.log"), " never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a ", Code("From:"), " line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in ",
|
||||
Rich("One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and ", Code("mail.log"), " never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a ", Code("From:"), " or ", Code("Sender:"), " line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in ",
|
||||
Code("mail.log"), ", found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains."),
|
||||
}},
|
||||
{ID: HelpQueue, Title: "Queue and retries", Body: []Text{
|
||||
|
||||
@@ -237,6 +237,8 @@ func SendLogTag(status, reason string) Tag {
|
||||
switch reason {
|
||||
case "from":
|
||||
return Tag{Status: "reject", Label: "wrong From"}
|
||||
case "sender":
|
||||
return Tag{Status: "reject", Label: "wrong Sender"}
|
||||
case "client":
|
||||
return Tag{Status: "reject", Label: "wrong client"}
|
||||
case "limit":
|
||||
|
||||
@@ -176,6 +176,7 @@ func TestSendLogTag(t *testing.T) {
|
||||
{"rejected", "", Tag{Status: "rejected"}},
|
||||
{"rejected", "limit", Tag{Status: "rate-limited"}},
|
||||
{"rejected", "from", Tag{Status: "reject", Label: "wrong From"}},
|
||||
{"rejected", "sender", Tag{Status: "reject", Label: "wrong Sender"}},
|
||||
{"rejected", "client", Tag{Status: "reject", Label: "wrong client"}},
|
||||
} {
|
||||
if got := SendLogTag(c.status, c.reason); got != c.want {
|
||||
|
||||
Reference in New Issue
Block a user