server-dmarc-hint: the server's own name gets a DMARC record

Owner, 2026-10-18: "Оба предложения делаем"; the mockup is 8b2dc8f. The
security audit's S3 left a product side: the panel showed the DKIM and SPF
records of the server's own name, not a DMARC one, and without it a
receiver does not refuse mail forged in that name - a false password-reset
message among it.

dnscheck.ServerMail checks a third record (Mail.DMARC, checkServerDMARC):
the sending-domain DMARC check held to more - missing is an error, as a
missing DKIM or SPF record of this name is (the page draws it as a
warning: notifications still go), p=none a warning ("weak": it refuses
nothing), quarantine or reject ok - and Overall includes it. The record to
publish is ServerMailDMARC, v=DMARC1; p=reject: only the panel sends as
the name, signed with its key, from the address its SPF names, so reject
costs nothing. Settings › Notification mail draws it after SPF
(view.ServerMailDMARC, folded when published), Health's card has the row,
Re-check DNS re-reads it with the others.

Tests: TestServerMailDMARC (missing, p=none, quarantine, reject); the
Settings and Health tests publish and expect three records. Help word for
word with the mockup; guide § Notifications; roadmap; CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-18 10:05:39 +03:00
co-authored by Claude Opus 5.5
parent 8b2dc8f40f
commit 554e007f22
11 changed files with 135 additions and 28 deletions
@@ -162,10 +162,11 @@ func serverMailStand(t *testing.T) (*Handlers, *fakeZone, domain.ServerMail) {
return h, zone, mail
}
// publishMail puts both records of the server's own name into the zone.
// publishMail puts the three records of the server's own name into the zone.
func publishMail(zone *fakeZone, mail domain.ServerMail) {
zone.publishTXT(mail.Record.Name, mail.Record.Value)
zone.publishTXT(notifyHost, dnscheck.ServerMailSPF)
zone.publishTXT(dnscheck.DMARCRecordName(notifyHost), dnscheck.ServerMailDMARC)
}
// With both records in DNS, Settings gives each with the value to publish and
@@ -182,14 +183,16 @@ func TestSettingsMailRecordsPublished(t *testing.T) {
`<h3>SPF</h3><span class="tag is-success is-light">published</span>`,
`For the server&#39;s own name, not for your domains`,
`value="v=spf1 a -all"`,
`<h3>DMARC</h3><span class="tag is-success is-light">published</span>`,
`value="_dmarc.mail.example.org"`, `value="v=DMARC1; p=reject"`,
`<span class="sp-mono">postmaster@mail.example.org</span>`,
} {
if !strings.Contains(out, want) {
t.Errorf("settings page is missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "may file them as spam") {
t.Error("the warning about a missing SPF record is shown while it is published")
if strings.Contains(out, "may file them as spam") || strings.Contains(out, "does not refuse mail forged") {
t.Error("the note about a missing SPF or DMARC record is shown while it is published")
}
}
@@ -203,7 +206,9 @@ func TestSettingsMailRecordsMissing(t *testing.T) {
for _, want := range []string{
`<h3>DKIM</h3><span class="tag is-warning is-light">missing</span>`,
`<h3>SPF</h3><span class="tag is-warning is-light">missing</span>`,
`value="sp2026._domainkey.mail.example.org"`, `value="v=spf1 a -all"`,
`<h3>DMARC</h3><span class="tag is-warning is-light">missing</span>`,
`value="sp2026._domainkey.mail.example.org"`, `value="v=spf1 a -all"`, `value="v=DMARC1; p=reject"`,
`Without it a receiver does not refuse mail forged in the server&#39;s name`,
`Notifications are sent without it, and a receiver may file them as spam.`,
`No TXT record is published at sp2026._domainkey.mail.example.org.`,
} {
@@ -259,6 +264,7 @@ func TestHealthNotificationMailCard(t *testing.T) {
`id="notification-mail"`, `<h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div>`,
`DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span>`,
`SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span>`,
`DMARC<br><span class="sp-muted sp-mono sp-small">_dmarc.mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span>`,
`Sent from <span class="sp-mono">postmaster@mail.example.org</span>`,
`href="/server/settings#notification-mail"`,
} {
@@ -296,8 +302,8 @@ func TestHealthNotificationMailCardIsGreenAndPolled(t *testing.T) {
// the records of the server's own name again as well as for its address.
func TestHealthRecheckAsksForNotificationMailAgain(t *testing.T) {
h, zone, mail := serverMailStand(t)
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-warning is-light">missing`) != 2 {
t.Fatalf("before publishing, both records should be missing:\n%s", out)
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-warning is-light">missing`) != 3 {
t.Fatalf("before publishing, the three records should be missing:\n%s", out)
}
publishMail(zone, mail)
@@ -309,8 +315,8 @@ func TestHealthRecheckAsksForNotificationMailAgain(t *testing.T) {
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /server/health/recheck = %d, want 303", rec.Code)
}
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-success is-light">published`) != 2 {
t.Errorf("after Re-check DNS both records should be published:\n%s", out)
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-success is-light">published`) != 3 {
t.Errorf("after Re-check DNS the three records should be published:\n%s", out)
}
}
@@ -318,8 +324,8 @@ func TestHealthRecheckAsksForNotificationMailAgain(t *testing.T) {
// Settings, where a record that has just been published comes back folded.
func TestSettingsRecheckAsksForNotificationMailAgain(t *testing.T) {
h, zone, mail := serverMailStand(t)
if out := getBody(t, h.HandleServerSettings, "/server/settings"); strings.Count(out, `is-warning is-light">missing`) != 2 {
t.Fatalf("before publishing, both records should be missing:\n%s", out)
if out := getBody(t, h.HandleServerSettings, "/server/settings"); strings.Count(out, `is-warning is-light">missing`) != 3 {
t.Fatalf("before publishing, the three records should be missing:\n%s", out)
}
publishMail(zone, mail)
@@ -332,8 +338,8 @@ func TestSettingsRecheckAsksForNotificationMailAgain(t *testing.T) {
t.Fatalf("POST /server/settings/dns-recheck = %d to %q, want 303 to /server/settings?rechecked=1", rec.Code, rec.Header().Get("Location"))
}
out := getBody(t, h.HandleServerSettings, "/server/settings?rechecked=1")
if n := strings.Count(out, `<details class="sp-record">`); n != 2 {
t.Errorf("after Re-check DNS both records should be published and folded, %d are:\n%s", n, out)
if n := strings.Count(out, `<details class="sp-record">`); n != 3 {
t.Errorf("after Re-check DNS the three records should be published and folded, %d are:\n%s", n, out)
}
if !strings.Contains(out, "DNS re-checked.") || !strings.Contains(out, "· checked just now") {
t.Errorf("Settings does not say the records were just re-checked:\n%s", out)
+11 -4
View File
@@ -170,11 +170,17 @@ func (h *Handlers) serverMail(force bool) serverMailState {
spfOf := func(c view.DNSCheck) (view.Record, view.MailRow) {
return view.ServerMailSPF(host, dnscheck.ServerMailSPF, c), view.NewMailRow("SPF", host, c)
}
dmarcHost := dnscheck.DMARCRecordName(host)
dmarcOf := func(c view.DNSCheck) (view.Record, view.MailRow) {
return view.ServerMailDMARC(dmarcHost, dnscheck.ServerMailDMARC, c), view.NewMailRow("DMARC", dmarcHost, c)
}
mail, err := h.domains.ServerMail()
if err != nil {
logf("panel: server mail: %v", err)
rec, row := spfOf(view.DNSCheck{Status: string(health.StatusUnknown)})
return serverMailState{records: []view.Record{rec}, rows: []view.MailRow{row}, status: string(health.StatusUnknown)}
unknown := view.DNSCheck{Status: string(health.StatusUnknown)}
rec, row := spfOf(unknown)
drec, drow := dmarcOf(unknown)
return serverMailState{records: []view.Record{rec, drec}, rows: []view.MailRow{row, drow}, status: string(health.StatusUnknown)}
}
srv := h.dns.Server(host, false)
check := h.dns.ServerMail(dnscheck.Query{
@@ -183,13 +189,14 @@ func (h *Handlers) serverMail(force bool) serverMailState {
}, force)
dkim := dnsCheck(check.DKIM)
spfRec, spfRow := spfOf(dnsCheck(check.SPF))
dmarcRec, dmarcRow := dmarcOf(dnsCheck(check.DMARC))
status := check.Overall
if status == health.StatusError {
status = health.StatusWarn
}
return serverMailState{
records: []view.Record{view.ServerMailDKIM(mail.Selector, mail.Record.Name, mail.Record.Value, dkim), spfRec},
rows: []view.MailRow{view.NewMailRow("DKIM", mail.Record.Name, dkim), spfRow},
records: []view.Record{view.ServerMailDKIM(mail.Selector, mail.Record.Name, mail.Record.Value, dkim), spfRec, dmarcRec},
rows: []view.MailRow{view.NewMailRow("DKIM", mail.Record.Name, dkim), spfRow, dmarcRow},
status: string(status),
checked: check.CheckedAt,
}