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,
}
+2
View File
@@ -139,6 +139,7 @@ func healthFixture(refresh bool) *Health {
h.WithNotificationMail("postmaster@mail.example.org", []MailRow{
NewMailRow("DKIM", mailFixtureDKIMHost, DNSCheck{Status: "ok"}),
NewMailRow("SPF", "mail.example.org", DNSCheck{Status: "error"}),
NewMailRow("DMARC", "_dmarc.mail.example.org", DNSCheck{Status: "warn"}),
}, "warn")
return h
}
@@ -171,6 +172,7 @@ func settingsFixture() *Settings {
WithMail([]Record{
ServerMailDKIM("sp2026", mailFixtureDKIMHost, key, DNSCheck{Status: "ok"}),
ServerMailSPF("mail.example.org", "v=spf1 a -all", DNSCheck{Status: "error", Detail: "No SPF record is published for mail.example.org."}),
ServerMailDMARC("_dmarc.mail.example.org", "v=DMARC1; p=reject", DNSCheck{Status: "warn", Detail: "No DMARC record at _dmarc.mail.example.org."}),
}, "3 min ago").
WithTimeZone("Europe/Moscow", msk, time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC))
}
+1 -1
View File
@@ -299,7 +299,7 @@ var serverTopics = []HelpTopic{
Br(), Br(),
"Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.",
Br(), Br(),
"For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, ", Code("v=spf1 a -all"), ". Server › Settings gives both with the values to publish and whether they are in place, and its Re-check DNS checks them again once they are published; Server › Health shows them too. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either."),
"For that mail to arrive, the server's own name needs DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, an SPF record, ", Code("v=spf1 a -all"), ", and a DMARC record, ", Code("v=DMARC1; p=reject"), ", which has receivers refuse mail forged in the server's name - a false password-reset message among it; only the panel sends as this name, signed, so reject is safe. Server › Settings gives all three with the values to publish and whether they are in place, and its Re-check DNS checks them again once they are published; Server › Health shows them too. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains depends on none of them."),
}},
}
+15
View File
@@ -213,6 +213,21 @@ func ServerMailSPF(host, value string, c DNSCheck) Record {
return r
}
// ServerMailDMARC is the DMARC record of the server's own name
// (server-dmarc-hint). While it is not in place the note under the value says
// what it is for and why its strict policy is safe.
func ServerMailDMARC(host, value string, c DNSCheck) Record {
r := Record{Name: "DMARC", Note: Plain("For the server's own name, not for your domains"),
Host: host, Type: "TXT", Value: value}
var standing Text
if c.Status != "ok" {
standing = Plain("Without it a receiver does not refuse mail forged in the server's name - a false password-reset message included. Only the panel sends as this name, signed, so reject is safe.")
}
applyCheck(&r, c, standing, nil)
r.Status = mailTag(c)
return r
}
// DMARCRecordInput is what a DMARC record needs besides its check: where the
// reports go in words (Source: the address, or "no reports") and the page that changes it,
// the page of the reports themselves ("" when the panel has none), and whether