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
+9
View File
@@ -5,6 +5,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
### Added
- **A DMARC record for the server's own name** (server-dmarc-hint; the
security audit's S3): Server › Settings › Notification mail gives
`v=DMARC1; p=reject` at `_dmarc.<hostname>` beside the DKIM and SPF
records, checks it, and Health's card shows it. Without one a receiver
does not refuse mail forged in the server's name, a false password-reset
message among it; `p=none` is shown as weak.
## [2.14.5] - 2026-10-18 ## [2.14.5] - 2026-10-18
A line less in the mail log. No migration; schema 13. A line less in the mail log. No migration; schema 13.
+9 -3
View File
@@ -679,18 +679,24 @@ the Outbound log and count against no rate limit. They name the page of the
panel to open and carry no link: the panel does not know the address its panel to open and carry no link: the panel does not know the address its
reverse proxy serves it under. reverse proxy serves it under.
**Two DNS records make them arrive.** The server's own name is not one of your **Three DNS records make them arrive.** The server's own name is not one of your
sending domains, so it needs records of its own, shown with the values to sending domains, so it needs records of its own, shown with the values to
publish under **Server › Settings › Notification mail**: publish under **Server › Settings › Notification mail**:
- a **DKIM** key for the hostname, which the panel generates and signs - a **DKIM** key for the hostname, which the panel generates and signs
notifications with (`<selector>._domainkey.<hostname>`, TXT); notifications with (`<selector>._domainkey.<hostname>`, TXT);
- an **SPF** record for the hostname: `v=spf1 a -all` (TXT). - an **SPF** record for the hostname: `v=spf1 a -all` (TXT);
- a **DMARC** record for the hostname: `v=DMARC1; p=reject` at
`_dmarc.<hostname>` (TXT). It has receivers refuse mail forged in the
server's name - a false "reset your password" message among it. Only the
panel sends as this name, signed with its key, from the address its SPF
names, so reject costs nothing; `p=quarantine` is accepted too, `p=none`
is shown as weak, since it refuses nothing.
**Server › Health** shows whether each is in place in the *Notification mail* **Server › Health** shows whether each is in place in the *Notification mail*
card. Without them notifications are still sent, and a receiver may file them card. Without them notifications are still sent, and a receiver may file them
as spam or, if the parent domain publishes a strict DMARC policy, refuse them. as spam or, if the parent domain publishes a strict DMARC policy, refuse them.
Mail of your sending domains depends on neither. If the hostname is itself one Mail of your sending domains depends on none of them. If the hostname is itself one
of your sending domains, the DKIM record is that domain's and is already of your sending domains, the DKIM record is that domain's and is already
published - and whoever administers that domain can then send mail that published - and whoever administers that domain can then send mail that
looks like the panel's; a hostname that is nobody's sending domain is the looks like the panel's; a hostname that is nobody's sending domain is the
+1 -1
View File
@@ -30,7 +30,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|---|---|---|---|---| |---|---|---|---|---|
| security-audit-2026-10-17 | Follow-ups of the 2026-10-17 security audit: A2 (high), seven mediums, the lows | stages 0-6 shipped `2.14.4` (2026-10-17); stage H (the host, S1-S3, S5) the owner's | 27/29 | [security-audit-2026-10-17.md](security-audit-2026-10-17.md) | | security-audit-2026-10-17 | Follow-ups of the 2026-10-17 security audit: A2 (high), seven mediums, the lows | stages 0-6 shipped `2.14.4` (2026-10-17); stage H (the host, S1-S3, S5) the owner's | 27/29 | [security-audit-2026-10-17.md](security-audit-2026-10-17.md) |
| upstream-tls-verify | Inbound: offer "Verified" TLS to the upstream (Postfix `secure`, the upstream's name checked), beside Required, which checks no certificate (audit M7) | proposed | — | — | | upstream-tls-verify | Inbound: offer "Verified" TLS to the upstream (Postfix `secure`, the upstream's name checked), beside Required, which checks no certificate (audit M7) | proposed | — | — |
| server-dmarc-hint | Server › Settings lists a DMARC record for the server's own name beside its DKIM and SPF (audit S3, product side) | proposed | — | — | | server-dmarc-hint | Server › Settings lists a DMARC record for the server's own name beside its DKIM and SPF (audit S3, product side) | built 2026-10-18 (owner: "Оба предложения делаем") | — | — |
| inbound-upstream-result | Inbound › Log shows what the upstream answered: sent, deferred, bounced, with its last reply | shipped `2.10.0` | 7/7 | [plans/inbound-upstream-result.md](plans/inbound-upstream-result.md) | | inbound-upstream-result | Inbound › Log shows what the upstream answered: sent, deferred, bounced, with its last reply | shipped `2.10.0` | 7/7 | [plans/inbound-upstream-result.md](plans/inbound-upstream-result.md) |
| health-setup-checks | Server › Health also checks the certificate's name, Postfix's greeting and the trusted proxy (was preflight) | shipped `2.11.0` | — | — | | health-setup-checks | Server › Health also checks the certificate's name, Postfix's greeting and the trusted proxy (was preflight) | shipped `2.11.0` | — | — |
| test-email | Send a test message from the panel (was preflight) | shipped `2.12.0` | — | — | | test-email | Send a test message from the panel (was preflight) | shipped `2.12.0` | — | — |
+15 -5
View File
@@ -188,6 +188,7 @@ type Mail struct {
Name string Name string
DKIM Result DKIM Result
SPF Result SPF Result
DMARC Result
Overall health.Status Overall health.Status
CheckedAt time.Time CheckedAt time.Time
} }
@@ -196,9 +197,17 @@ type Mail struct {
// address record is the one sender. // address record is the one sender.
const ServerMailSPF = "v=spf1 a -all" const ServerMailSPF = "v=spf1 a -all"
// ServerMail checks the DKIM and SPF records of the server's own name. q is as // ServerMailDMARC is the DMARC record to publish for the server's own name
// for a domain, with Name the hostname. Cached like a domain; force skips the // (server-dmarc-hint; security audit 2026-10-17, S3). Only the panel sends as
// cache. // that name - from postmaster@ it, signed with the name's own key, from the
// address its SPF names - so every genuine message aligns and reject costs
// nothing; without a record a receiver does not refuse mail forged in the
// server's name, a false password-reset message among it.
const ServerMailDMARC = "v=DMARC1; p=reject"
// ServerMail checks the DKIM, SPF and DMARC records of the server's own name.
// q is as for a domain, with Name the hostname. Cached like a domain; force
// skips the cache.
func (c *Checker) ServerMail(q Query, force bool) Mail { func (c *Checker) ServerMail(q Query, force bool) Mail {
key := "\x00mail\x00" + q.Name key := "\x00mail\x00" + q.Name
if !force { if !force {
@@ -214,16 +223,17 @@ func (c *Checker) ServerMail(q Query, force bool) Mail {
defer cancel() defer cancel()
m := Mail{Name: q.Name, CheckedAt: time.Now()} m := Mail{Name: q.Name, CheckedAt: time.Now()}
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(2) wg.Add(3)
go func() { defer wg.Done(); m.DKIM = c.checkDKIM(ctx, q) }() go func() { defer wg.Done(); m.DKIM = c.checkDKIM(ctx, q) }()
go func() { defer wg.Done(); m.SPF = c.checkSPF(ctx, q) }() go func() { defer wg.Done(); m.SPF = c.checkSPF(ctx, q) }()
go func() { defer wg.Done(); m.DMARC = c.checkServerDMARC(ctx, q.Name) }()
wg.Wait() wg.Wait()
// checkSPF words a missing record for a sending domain; for the server's // checkSPF words a missing record for a sending domain; for the server's
// own name the record to publish is a different one. // own name the record to publish is a different one.
if m.SPF.Status == health.StatusError && len(m.SPF.Records) == 0 { if m.SPF.Status == health.StatusError && len(m.SPF.Records) == 0 {
m.SPF.Detail = fmt.Sprintf("No SPF record is published for %s. Publish the TXT record %q - without it a receiver has nothing authorising this server to send as its own name.", q.Name, ServerMailSPF) m.SPF.Detail = fmt.Sprintf("No SPF record is published for %s. Publish the TXT record %q - without it a receiver has nothing authorising this server to send as its own name.", q.Name, ServerMailSPF)
} }
m.Overall = health.Worst(m.DKIM.Status, m.SPF.Status) m.Overall = health.Worst(m.DKIM.Status, m.SPF.Status, m.DMARC.Status)
c.mu.Lock() c.mu.Lock()
c.mail[key] = cached[Mail]{value: m, expires: m.CheckedAt.Add(c.domainTTL)} c.mail[key] = cached[Mail]{value: m, expires: m.CheckedAt.Add(c.domainTTL)}
+26
View File
@@ -105,6 +105,32 @@ func (c *Checker) checkDMARC(ctx context.Context, q Query) Result {
return Result{Status: health.StatusOK, Detail: detail, Records: records} return Result{Status: health.StatusOK, Detail: detail, Records: records}
} }
// checkServerDMARC checks the DMARC record of the server's own name. It is a
// sending domain's check held to more: a record missing is an error, as a
// missing DKIM or SPF record of this name is, and p=none, which refuses
// nothing, a warning - either leaves forged mail in the server's name
// deliverable, and that name sends nothing a stricter policy could hurt
// (ServerMailDMARC). quarantine is as good as reject here.
func (c *Checker) checkServerDMARC(ctx context.Context, name string) Result {
r := c.checkDMARC(ctx, Query{Name: name})
recordName := DMARCRecordName(name)
switch {
case r.Status == health.StatusWarn && len(r.Records) == 0:
// Missing, as a missing DKIM or SPF record of this name is: the page
// draws it as a warning all the same (notifications still go).
r.Status = health.StatusError
r.Detail = fmt.Sprintf("No DMARC record at %s. Publish the TXT record %q - without it a receiver does not refuse mail forged in this server's name.", recordName, ServerMailDMARC)
case r.Status == health.StatusOK:
if policy, _ := tagValue(r.Records[0], "p"); strings.EqualFold(policy, "none") {
r.Status = health.StatusWarn
r.Detail = fmt.Sprintf("Published with p=none, which refuses nothing: mail forged in this server's name is still delivered. Publish %q - only the panel sends as this name, signed.", ServerMailDMARC)
} else {
r.Detail = fmt.Sprintf("Published with policy p=%s.", policy)
}
}
return r
}
// checkReportAuth verifies the hub domain publishes _report._dmarc for external // checkReportAuth verifies the hub domain publishes _report._dmarc for external
// aggregate-report destinations. Missing authorisation does not affect outbound // aggregate-report destinations. Missing authorisation does not affect outbound
// delivery, only whether reports reach the rua= mailbox. // delivery, only whether reports reach the rua= mailbox.
+28 -2
View File
@@ -27,14 +27,15 @@ func TestServerMailPublished(t *testing.T) {
txt: map[string][]string{ txt: map[string][]string{
"sp._domainkey.mail.example.org": {serverDKIM}, "sp._domainkey.mail.example.org": {serverDKIM},
"mail.example.org": {ServerMailSPF}, "mail.example.org": {ServerMailSPF},
"_dmarc.mail.example.org": {ServerMailDMARC},
}, },
addr: map[string][]net.IPAddr{"mail.example.org": ipAddrs("203.0.113.25")}, addr: map[string][]net.IPAddr{"mail.example.org": ipAddrs("203.0.113.25")},
} }
c := newTestChecker(f) c := newTestChecker(f)
m := c.ServerMail(serverMailQuery(), false) m := c.ServerMail(serverMailQuery(), false)
if m.DKIM.Status != health.StatusOK || m.SPF.Status != health.StatusOK || m.Overall != health.StatusOK { if m.DKIM.Status != health.StatusOK || m.SPF.Status != health.StatusOK || m.DMARC.Status != health.StatusOK || m.Overall != health.StatusOK {
t.Fatalf("DKIM %s (%s), SPF %s (%s), overall %s", m.DKIM.Status, m.DKIM.Detail, m.SPF.Status, m.SPF.Detail, m.Overall) t.Fatalf("DKIM %s (%s), SPF %s (%s), DMARC %s (%s), overall %s", m.DKIM.Status, m.DKIM.Detail, m.SPF.Status, m.SPF.Detail, m.DMARC.Status, m.DMARC.Detail, m.Overall)
} }
// Cached like a domain, and re-read when forced. // Cached like a domain, and re-read when forced.
@@ -102,3 +103,28 @@ func TestSPFLookupThatDidNotAnswerIsNotAMismatch(t *testing.T) {
t.Errorf("the name does not exist: SPF is %s (%s), want an error", got.Status, got.Detail) t.Errorf("the name does not exist: SPF is %s (%s), want an error", got.Status, got.Detail)
} }
} }
// The server's own name's DMARC record (server-dmarc-hint): missing is an
// error and p=none a warning, each naming the record to publish; quarantine or
// reject is ok.
func TestServerMailDMARC(t *testing.T) {
for _, c := range []struct {
name, record string
want health.Status
says string
}{
{"missing", "", health.StatusError, `Publish the TXT record "v=DMARC1; p=reject"`},
{"p=none", "v=DMARC1; p=none; rua=mailto:x@example.org", health.StatusWarn, "refuses nothing"},
{"p=quarantine", "v=DMARC1; p=quarantine", health.StatusOK, "p=quarantine"},
{"p=reject", ServerMailDMARC, health.StatusOK, "p=reject"},
} {
f := &fakeResolver{txt: map[string][]string{}}
if c.record != "" {
f.txt["_dmarc.mail.example.org"] = []string{c.record}
}
m := newTestChecker(f).ServerMail(serverMailQuery(), false)
if m.DMARC.Status != c.want || !strings.Contains(m.DMARC.Detail, c.says) {
t.Errorf("%s: %s (%s), want %s saying %q", c.name, m.DMARC.Status, m.DMARC.Detail, c.want, c.says)
}
}
}
@@ -162,10 +162,11 @@ func serverMailStand(t *testing.T) (*Handlers, *fakeZone, domain.ServerMail) {
return h, zone, mail 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) { func publishMail(zone *fakeZone, mail domain.ServerMail) {
zone.publishTXT(mail.Record.Name, mail.Record.Value) zone.publishTXT(mail.Record.Name, mail.Record.Value)
zone.publishTXT(notifyHost, dnscheck.ServerMailSPF) 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 // 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>`, `<h3>SPF</h3><span class="tag is-success is-light">published</span>`,
`For the server&#39;s own name, not for your domains`, `For the server&#39;s own name, not for your domains`,
`value="v=spf1 a -all"`, `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>`, `<span class="sp-mono">postmaster@mail.example.org</span>`,
} { } {
if !strings.Contains(out, want) { if !strings.Contains(out, want) {
t.Errorf("settings page is missing %q:\n%s", want, out) t.Errorf("settings page is missing %q:\n%s", want, out)
} }
} }
if strings.Contains(out, "may file them as spam") { if strings.Contains(out, "may file them as spam") || strings.Contains(out, "does not refuse mail forged") {
t.Error("the warning about a missing SPF record is shown while it is published") 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{ for _, want := range []string{
`<h3>DKIM</h3><span class="tag is-warning is-light">missing</span>`, `<h3>DKIM</h3><span class="tag is-warning is-light">missing</span>`,
`<h3>SPF</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.`, `Notifications are sent without it, and a receiver may file them as spam.`,
`No TXT record is published at sp2026._domainkey.mail.example.org.`, `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>`, `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>`, `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>`, `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>`, `Sent from <span class="sp-mono">postmaster@mail.example.org</span>`,
`href="/server/settings#notification-mail"`, `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. // the records of the server's own name again as well as for its address.
func TestHealthRecheckAsksForNotificationMailAgain(t *testing.T) { func TestHealthRecheckAsksForNotificationMailAgain(t *testing.T) {
h, zone, mail := serverMailStand(t) h, zone, mail := serverMailStand(t)
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-warning is-light">missing`) != 2 { if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-warning is-light">missing`) != 3 {
t.Fatalf("before publishing, both records should be missing:\n%s", out) t.Fatalf("before publishing, the three records should be missing:\n%s", out)
} }
publishMail(zone, mail) publishMail(zone, mail)
@@ -309,8 +315,8 @@ func TestHealthRecheckAsksForNotificationMailAgain(t *testing.T) {
if rec.Code != http.StatusSeeOther { if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /server/health/recheck = %d, want 303", rec.Code) 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 { if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-success is-light">published`) != 3 {
t.Errorf("after Re-check DNS both records should be published:\n%s", out) 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. // Settings, where a record that has just been published comes back folded.
func TestSettingsRecheckAsksForNotificationMailAgain(t *testing.T) { func TestSettingsRecheckAsksForNotificationMailAgain(t *testing.T) {
h, zone, mail := serverMailStand(t) h, zone, mail := serverMailStand(t)
if out := getBody(t, h.HandleServerSettings, "/server/settings"); strings.Count(out, `is-warning is-light">missing`) != 2 { if out := getBody(t, h.HandleServerSettings, "/server/settings"); strings.Count(out, `is-warning is-light">missing`) != 3 {
t.Fatalf("before publishing, both records should be missing:\n%s", out) t.Fatalf("before publishing, the three records should be missing:\n%s", out)
} }
publishMail(zone, mail) 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")) 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") out := getBody(t, h.HandleServerSettings, "/server/settings?rechecked=1")
if n := strings.Count(out, `<details class="sp-record">`); n != 2 { if n := strings.Count(out, `<details class="sp-record">`); n != 3 {
t.Errorf("after Re-check DNS both records should be published and folded, %d are:\n%s", n, out) 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") { 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) 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) { spfOf := func(c view.DNSCheck) (view.Record, view.MailRow) {
return view.ServerMailSPF(host, dnscheck.ServerMailSPF, c), view.NewMailRow("SPF", host, c) 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() mail, err := h.domains.ServerMail()
if err != nil { if err != nil {
logf("panel: server mail: %v", err) logf("panel: server mail: %v", err)
rec, row := spfOf(view.DNSCheck{Status: string(health.StatusUnknown)}) unknown := view.DNSCheck{Status: string(health.StatusUnknown)}
return serverMailState{records: []view.Record{rec}, rows: []view.MailRow{row}, 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) srv := h.dns.Server(host, false)
check := h.dns.ServerMail(dnscheck.Query{ check := h.dns.ServerMail(dnscheck.Query{
@@ -183,13 +189,14 @@ func (h *Handlers) serverMail(force bool) serverMailState {
}, force) }, force)
dkim := dnsCheck(check.DKIM) dkim := dnsCheck(check.DKIM)
spfRec, spfRow := spfOf(dnsCheck(check.SPF)) spfRec, spfRow := spfOf(dnsCheck(check.SPF))
dmarcRec, dmarcRow := dmarcOf(dnsCheck(check.DMARC))
status := check.Overall status := check.Overall
if status == health.StatusError { if status == health.StatusError {
status = health.StatusWarn status = health.StatusWarn
} }
return serverMailState{ return serverMailState{
records: []view.Record{view.ServerMailDKIM(mail.Selector, mail.Record.Name, mail.Record.Value, dkim), spfRec}, 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}, rows: []view.MailRow{view.NewMailRow("DKIM", mail.Record.Name, dkim), spfRow, dmarcRow},
status: string(status), status: string(status),
checked: check.CheckedAt, checked: check.CheckedAt,
} }
+2
View File
@@ -139,6 +139,7 @@ func healthFixture(refresh bool) *Health {
h.WithNotificationMail("postmaster@mail.example.org", []MailRow{ h.WithNotificationMail("postmaster@mail.example.org", []MailRow{
NewMailRow("DKIM", mailFixtureDKIMHost, DNSCheck{Status: "ok"}), NewMailRow("DKIM", mailFixtureDKIMHost, DNSCheck{Status: "ok"}),
NewMailRow("SPF", "mail.example.org", DNSCheck{Status: "error"}), NewMailRow("SPF", "mail.example.org", DNSCheck{Status: "error"}),
NewMailRow("DMARC", "_dmarc.mail.example.org", DNSCheck{Status: "warn"}),
}, "warn") }, "warn")
return h return h
} }
@@ -171,6 +172,7 @@ func settingsFixture() *Settings {
WithMail([]Record{ WithMail([]Record{
ServerMailDKIM("sp2026", mailFixtureDKIMHost, key, DNSCheck{Status: "ok"}), 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."}), 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"). }, "3 min ago").
WithTimeZone("Europe/Moscow", msk, time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)) 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(), 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.", "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(), 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 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 // 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, // 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 // the page of the reports themselves ("" when the panel has none), and whether