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:
@@ -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'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'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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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."),
|
||||
}},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user