sender check: a Sender: line is held to the rule From: is held to
test / test (push) Waiting to run

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:
mix
2026-10-16 07:41:42 +03:00
co-authored by Claude Opus 5.5
parent d9d9af3932
commit 0cceb549cc
18 changed files with 268 additions and 53 deletions
+6
View File
@@ -42,6 +42,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
OpenDKIM did: the line must be one address at a domain the application
may send as, and the 256-byte limit of OpenDKIM's buffer goes. The key is
the login's, so no `From:` line can choose another tenant's.
- **A `Sender:` line is held to the same rule as `From:`** when a message
has one - the address a mail program shows as "on behalf of". A message
with another domain's address there, or with two `Sender:` lines, is
refused with `550 5.7.1 Sender: …`, tagged `wrong Sender` in the
Outbound log. A system writing for people outside your domains puts its
own address in `From:` and theirs in `Reply-To:`.
### Fixed
+5 -1
View File
@@ -418,7 +418,11 @@ the hole it closed was the same.
`<b@evil.com, a@mine.com>` - is refused. That address, its local part
unquoted as Postfix holds the envelope's, is what the bindings are asked
about, folded to lower case in ASCII only: a Unicode fold would make the
Kelvin sign a `k` and let a look-alike pass. The check asks Postfix for
Kelvin sign a `k` and let a look-alike pass. Since `2.8.0` a `Sender:`
line - the sending agent's, shown beside `From:` as "on behalf of" - is
held to the same rule when the message has one (at most one; the owner's
decision, 2026-10-16): the reply says `Sender:` instead of `From:` and the
row's reason is `sender`. `From:` is judged first. The check asks Postfix for
header values with their leading space (`SMFIP_HDR_LEADSPC`), as the
signer does, so both read the same bytes. Until `2.8.0` the line was also
read with a port of OpenDKIM's own parser, so that the address judged was
+15 -1
View File
@@ -1345,6 +1345,17 @@ is permanent, so the application does not send the message again; it is a
row of the [Outbound log](#outbound-log) tagged `wrong From`, and the row's
page shows what the line said.
Since `2.8.0` a **`Sender:` line** - the one a mail program shows beside
`From:` as "on behalf of" - is held to the same rule when the message has
one: one address, one the application may send as, and no more than one
such line. Otherwise the message is refused the same way, `550 5.7.1
Sender: …`, tagged `wrong Sender`. Most mail libraries write no `Sender:`;
one that writes the application's own address passes. A system that writes
for people outside your domains - a ticket system, a CRM - puts its own
address in `From:` with the person's name (`"Jane via Support"
<support@…>`) and the person's address in `Reply-To:`: their domain in
`From:` would fail DMARC at the receiver anyway.
What this stops working when you upgrade from an earlier version:
- an application that sends with the `From:` of another of your domains -
@@ -1353,7 +1364,10 @@ What this stops working when you upgrade from an earlier version:
differ - add the second address to the list;
- a contact form that puts its visitor's address into `From:` - put it into
`Reply-To:` and send from an address of your own, which is also what
DMARC requires.
DMARC requires;
- (`2.8.0`) a message with a `Sender:` of another domain, or of an address
the application is not listed for - drop the line or write the
application's own address there.
The check is strict: while the panel does not answer, Postfix answers with a
temporary error (`451`, and `454` to the login): an application that queues
+3 -2
View File
@@ -209,8 +209,9 @@ libmilter1.0.1.
*Done (2026-10-16):* help and Health went with stage 2 (design `005922b`);
the reviews ran on stages 1-2 and their findings are fixed in `c2f63d2`,
with the first rollout's incident (the queue's owner numbers); the
documents in the commit after it. Open: holding `Sender:` to the login
(security review F1, the owner's decision), and go-milter v0.4.1 keeping
documents in the commit after it. `Sender:` is held to the login like
`From:` (security review F1; the owner's decision, 2026-10-16). Open:
go-milter v0.4.1 keeping
every header in a map of its own per session (security.md, accepted risks)
- a header section the signer refuses past 1 MiB is still held by the
library, on 465/587 and on port 25 alike, as before 2.8.0.
+18 -23
View File
@@ -61,9 +61,10 @@ bounded only by `message_size_limit` (now 1 MiB, refused above it); the old
key tree stayed readable by group `selfpost` and went into every backup (now
the panel's alone and left out); a hostname with capitals deferred every
notification (the server's name is kept in lower case for DKIM); only `From`
was over-signed (now the shown headers too) - all fixed. `Sender:` is now
signed but not held to the login, which waits for the owner; that and what the
header cap leaves are under Accepted risks.
was over-signed (now the shown headers too) - all fixed. `Sender:`, which
the review found neither held nor signed (F1), is now both: signed, and held
to the login like `From:` (the owner's decision, 2026-10-16). What the header
cap leaves is under Accepted risks.
(2) **Accepted risks** —
deliberate departures beyond the mandatory, recorded so the decision is not
lost.
@@ -318,7 +319,10 @@ operator can act on it.
What this does not promise is what a display name looks like to a person:
a name of another sender in it passes, signed with the application's own
key - a forged display name is as old as mail, and no key of another's is
involved. Nor does it look at `Sender:` (Accepted risks). What the client
involved. Since `2.8.0` a `Sender:` line - shown beside `From:` as "on
behalf of" - is held to the same rule when there is one, and refused as
`550 5.7.1 Sender: ...`; `Resent-From:` is signed but not judged
(Accepted risks). What the client
wrote reaches the reply, the log and the send log only as printable ASCII,
cut, and a refused message is one row of the log whatever its recipient
count. Until `2.4.0` nothing held the header: OpenDKIM chose the key by it
@@ -715,25 +719,16 @@ deferred item from the roadmap.
signed with that domain's key. Whoever may export the domain has the key,
and its applications may send as `postmaster@<hostname>` by right. The
guide says so; a hostname that is nobody's sending domain avoids it.
- **`Sender:` is signed but not held to the login** (open: the owner's
decision). Since `2.8.0` the signer signs `Sender:` and `Resent-From:`
and over-signs `Sender:`, so neither can be added or changed after the
message leaves without breaking the signature; the sender check judges
`From:` alone (`senderSession.Header` keeps `From` and `Subject`). An
application of `mine.com` can write `From: billing@mine.com` and
`Sender: ceo@bank.com`: the check passes it, the signature is
`d=mine.com`, DMARC aligns on `From:` and passes, and a mail program that
shows `Sender:` beside `From:` - Outlook as "on behalf of", by the
review's account, not tried on this build - shows the other address. No
key of another's is involved, as with a display name; until `2.8.0` the
header was neither checked nor signed. The fix the security review of
2026-10-16 (F1) gives, and recommends: the check also reads `Sender:` and
`Resent-From:` and refuses one whose single address the application may
not send as, under the rule `From:` is held to - a few lines in
`internal/milter/sender.go` and their tests. It changes what is refused
(an application whose mail program writes a `Sender:` of another domain
today would be refused), hence the owner's decision; this item goes when
it is taken.
- **`Resent-From:` is signed but not held to the login.** Since `2.8.0`
the sender check holds `Sender:` to the rule `From:` is held to (the
owner's decision, 2026-10-16; security review F1), and the signer signs
both `Sender:` and `Resent-From:`. `Resent-From:` - written when a
message is passed on again (RFC 5322 § 3.6.6) - is not judged: an
application of `mine.com` could write `Resent-From: ceo@bank.com` beside
its own `From:`, signed `d=mine.com`. Few mail programs show it, and no
key of another's is involved, as with a display name. Holding it too is a
few lines in `internal/milter/sender.go` (`judge()`); the trigger to do it
is a mail program that shows it in place of `From:`.
- **A header section is bounded at 1 MiB by the signer, not by the milter
library.** The signer counts a message's headers as Postfix hands them
over and refuses a section of more than 1 MiB with `552 5.3.4`
+10 -3
View File
@@ -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,
})
+12
View File
@@ -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
View File
@@ -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 {
+45
View File
@@ -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)
}
})
}
}
+1
View File
@@ -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).
+4 -3
View File
@@ -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
+6 -2
View File
@@ -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) {
+3 -3
View File
@@ -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{
+2
View File
@@ -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":
+1
View File
@@ -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 {
+3
View File
@@ -327,6 +327,9 @@ func TestE2E(t *testing.T) {
run("sender_check_holds_the_from_line", func(t *testing.T) {
testSenderCheck(t, sc)
})
run("sender_check_holds_the_sender_line", func(t *testing.T) {
testSenderLine(t, sc)
})
run("passed_from_lines_signed_for_their_domain_only", func(t *testing.T) {
testSignedForItsDomainOnly(t, sc)
})
+67
View File
@@ -0,0 +1,67 @@
package e2e
import (
"strings"
"testing"
"time"
)
// testSenderLine: a Sender: line is held to the rule the From: line is held
// to (the owner's decision, 2026-10-16): the application's own address passes
// and is signed; another domain's is refused for good at the end of DATA,
// "550 5.7.1 Sender: ...", and is a row of the Outbound log tagged "wrong
// Sender" (internal/milter/sender.go, judge()).
func testSenderLine(t *testing.T, sc *scenario) {
message := func(token, sender string) string {
return strings.Join([]string{
"From: Alerts <alerts@" + senderDomain + ">",
"Sender: " + sender,
"To: " + recipient,
"Subject: e2e " + token,
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
"",
"sender line check",
"",
}, "\r\n")
}
own := uniqueToken("senderown")
res := attemptSend(sendAttempt{
authLogin: sc.appLogin, authPassword: sc.appPassword,
from: "alerts@" + senderDomain, to: recipient, raw: message(own, "mailer@"+senderDomain),
})
if !res.ok() {
t.Fatalf("its own address in Sender: was refused: %v", res.firstErr())
}
file, delivered, err := findSinkFile(h.stageDir, own, 30*time.Second)
if err != nil {
t.Fatalf("never reached the sink: %v", err)
}
if err := checkPanelSignature(h, file, delivered, senderDomain); err != nil {
t.Fatal(err)
}
if signedNames := strings.ToLower(dkimTagsOf(t, delivered)["h"]); !strings.Contains(signedNames, "sender:") {
t.Errorf("h=%s does not sign Sender", signedNames)
}
foreign := uniqueToken("senderforeign")
res = attemptSend(sendAttempt{
authLogin: sc.appLogin, authPassword: sc.appPassword,
from: "alerts@" + senderDomain, to: recipient, raw: message(foreign, "ceo@"+otherDomain),
})
if err := res.refusedAt("DATA", 550, "5.7.1 Sender:"); err != nil {
t.Fatalf("another domain in Sender: %v", err)
}
if _, err := findSinkMessage(h.stageDir, foreign, 2*time.Second); err == nil {
t.Error("the refused message reached the sink")
}
if err := waitFor("the Sender: refusal in the Outbound log", 15*time.Second, 500*time.Millisecond, func() (bool, error) {
rows, err := sc.panel.sendLogRows(senderDomain, "")
if err != nil {
return false, err
}
return strings.Contains(rows, `">wrong Sender</span>`), nil
}); err != nil {
t.Error(err)
}
}