Follows the design commit before it.
- internal/dmarc: the ingest allow-list is the report address of each
domain that is on this host and nothing else;
DefaultHostedReportAddress and DefaultHostedSuggestion are removed.
Since 1.7.0 the general mailbox dmarc-reports@<hostname> was open
while no panel-wide default existed; the three-choice scheme had
left it open always.
- The DMARC hub's Ingest box shows the form of a hosted address,
dmarc-reports+<domain>@<hostname>.
- guide.md: what port 25 accepts.
Evidence: docs/assets/panel-redesign/evidence/dmarc.png re-shot (the
address form wraps after the plus sign in its column at 1440 px).
go test -count=1 ./internal/dmarc/... ./internal/web/... ./cmd/panel: ok
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -133,6 +133,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
`unix:/path`, one second at most, nothing sent). A filter that does not
|
`unix:/path`, one second at most, nothing sent). A filter that does not
|
||||||
answer is an error with `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` - inbound
|
answer is an error with `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` - inbound
|
||||||
mail is deferred - and a warning otherwise - mail goes through unfiltered.
|
mail is deferred - and a warning otherwise - mail goes through unfiltered.
|
||||||
|
- **Port 25 no longer accepts `dmarc-reports@<hostname>`**, the report
|
||||||
|
mailbox of the server as a whole. Only an address a domain has chosen is
|
||||||
|
accepted: its hosted address `dmarc-reports+<domain>@<hostname>`, or a
|
||||||
|
typed address that is on this host. A domain whose published `rua=` still
|
||||||
|
names the general mailbox must be set to *SelfPost hosted* and its DMARC
|
||||||
|
record republished (owner, 2026-10-10).
|
||||||
- panel-redesign (design, stage 3): **New password** and **Delete** are in
|
- panel-redesign (design, stage 3): **New password** and **Delete** are in
|
||||||
each row of a domain's Applications table again, as POST forms that look
|
each row of a domain's Applications table again, as POST forms that look
|
||||||
like the links beside them.
|
like the links beside them.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 80 KiB |
+4
-3
@@ -874,9 +874,10 @@ settings) and nowhere else:
|
|||||||
|
|
||||||
Changing the choice changes the DMARC record to publish; the domain page shows
|
Changing the choice changes the DMARC record to publish; the domain page shows
|
||||||
the new value. There is no panel-wide or per-user default. Only addresses on
|
the new value. There is no panel-wide or per-user default. Only addresses on
|
||||||
`SELFPOST_HOSTNAME` are accepted on port 25: the generic
|
`SELFPOST_HOSTNAME` that a domain has chosen are accepted on port 25 - the
|
||||||
`dmarc-reports@<hostname>` and any address on that host that a domain has
|
hosted address `dmarc-reports+<domain>@<hostname>` of a domain set to
|
||||||
chosen.
|
*SelfPost hosted*, or a typed address that happens to be on this host. There
|
||||||
|
is no mailbox for the server as a whole.
|
||||||
|
|
||||||
**DNS.** Publish the usual `_dmarc` TXT on each sending domain with
|
**DNS.** Publish the usual `_dmarc` TXT on each sending domain with
|
||||||
`rua=mailto:…` pointing at the address the domain shows. Receivers deliver to the
|
`rua=mailto:…` pointing at the address the domain shows. Receivers deliver to the
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultLocalPart is the mailbox local-part for the panel-wide hosted address.
|
// DefaultLocalPart is the local part of every hosted address; the domain
|
||||||
|
// follows it after a plus sign.
|
||||||
const DefaultLocalPart = "dmarc-reports"
|
const DefaultLocalPart = "dmarc-reports"
|
||||||
|
|
||||||
// HostedReportAddress is the per-domain SelfPost-hosted rua= destination.
|
// HostedReportAddress is the per-domain SelfPost-hosted rua= destination.
|
||||||
@@ -14,11 +15,6 @@ func HostedReportAddress(hostname, domain string) string {
|
|||||||
return DefaultLocalPart + "+" + domain + "@" + hostname
|
return DefaultLocalPart + "+" + domain + "@" + hostname
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultHostedReportAddress is the settings-level hosted rua= when ingest is on.
|
|
||||||
func DefaultHostedReportAddress(hostname string) string {
|
|
||||||
return DefaultLocalPart + "@" + strings.ToLower(strings.TrimSpace(hostname))
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsHostedOnHostname reports whether addr is delivered locally on hostname.
|
// IsHostedOnHostname reports whether addr is delivered locally on hostname.
|
||||||
func IsHostedOnHostname(addr, hostname string) bool {
|
func IsHostedOnHostname(addr, hostname string) bool {
|
||||||
addr = strings.ToLower(strings.TrimSpace(addr))
|
addr = strings.ToLower(strings.TrimSpace(addr))
|
||||||
|
|||||||
@@ -64,10 +64,9 @@ func (s *Service) AllowedRecipients() ([]string, error) {
|
|||||||
seen[addr] = true
|
seen[addr] = true
|
||||||
out = append(out, addr)
|
out = append(out, addr)
|
||||||
}
|
}
|
||||||
// The instance's generic mailbox is always open: it is the address the
|
// Only what a domain has chosen is open: its report address opens the
|
||||||
// guide tells a fresh install to publish. A domain's own report address
|
// mailbox it names, if that is on this host. There is no mailbox for the
|
||||||
// opens the mailbox it names, if that is on this host.
|
// server as a whole.
|
||||||
add(DefaultHostedReportAddress(s.hostname))
|
|
||||||
domains, err := s.store.ListDomains()
|
domains, err := s.store.ListDomains()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -101,11 +100,6 @@ func (s *Service) HostedSuggestion(domain string) string {
|
|||||||
return HostedReportAddress(s.hostname, domain)
|
return HostedReportAddress(s.hostname, domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultHostedSuggestion is the settings-level hosted address.
|
|
||||||
func (s *Service) DefaultHostedSuggestion() string {
|
|
||||||
return DefaultHostedReportAddress(s.hostname)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateHostedAddress ensures addr is on this hostname before saving.
|
// ValidateHostedAddress ensures addr is on this hostname before saving.
|
||||||
func (s *Service) ValidateHostedAddress(addr string) error {
|
func (s *Service) ValidateHostedAddress(addr string) error {
|
||||||
if !IsHostedOnHostname(addr, s.hostname) {
|
if !IsHostedOnHostname(addr, s.hostname) {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The ingest allow-list is the generic mailbox plus the report address of each
|
// The ingest allow-list is the report address of each domain that points at
|
||||||
// domain that points at this host: a new domain (no reports) and a domain
|
// this host, and nothing for the server as a whole: a new domain (no reports) and a domain
|
||||||
// reporting to another host open nothing of their own.
|
// reporting to another host open nothing of their own.
|
||||||
func TestAllowedRecipientsFollowTheDomainAddresses(t *testing.T) {
|
func TestAllowedRecipientsFollowTheDomainAddresses(t *testing.T) {
|
||||||
st, err := store.Open(t.TempDir() + "/test.db")
|
st, err := store.Open(t.TempDir() + "/test.db")
|
||||||
@@ -36,7 +36,7 @@ func TestAllowedRecipientsFollowTheDomainAddresses(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AllowedRecipients: %v", err)
|
t.Fatalf("AllowedRecipients: %v", err)
|
||||||
}
|
}
|
||||||
want := []string{"dmarc-reports+hosted.example@mail.example.com", "dmarc-reports@mail.example.com", "ops@mail.example.com"}
|
want := []string{"dmarc-reports+hosted.example@mail.example.com", "ops@mail.example.com"}
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
t.Errorf("allowed recipients = %v, want %v", got, want)
|
t.Errorf("allowed recipients = %v, want %v", got, want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func TestDMARCHubShowsIngestAndRecentReports(t *testing.T) {
|
|||||||
body := getBody(t, s.h.HandleDMARCList, "/outbound/dmarc")
|
body := getBody(t, s.h.HandleDMARCList, "/outbound/dmarc")
|
||||||
has(t, "hub", body,
|
has(t, "hub", body,
|
||||||
`<h1 class="title is-3">DMARC reports</h1>`, `<h2>Ingest</h2>`, `<span class="tag is-success is-light">ok</span>`,
|
`<h1 class="title is-3">DMARC reports</h1>`, `<h2>Ingest</h2>`, `<span class="tag is-success is-light">ok</span>`,
|
||||||
`1 kept · 0 parse failures`, `dmarc-reports@mail.example.org`, `500 reports or 90 days`,
|
`1 kept · 0 parse failures`, `Hosted addresses`, `dmarc-reports+<domain>@mail.example.org`, `500 reports or 90 days`,
|
||||||
`<h2>Recent reports</h2>`, `<a href="/outbound/dmarc/domains/1">example.org</a>`, `Outlook.com`,
|
`<h2>Recent reports</h2>`, `<a href="/outbound/dmarc/domains/1">example.org</a>`, `Outlook.com`,
|
||||||
"<td>1\u00a0903</td>", `<span class="tag is-warning is-light">7</span>`, `href="/outbound/dmarc/reports/`+idStr(s.reportID)+`">View</a>`)
|
"<td>1\u00a0903</td>", `<span class="tag is-warning is-light">7</span>`, `href="/outbound/dmarc/reports/`+idStr(s.reportID)+`">View</a>`)
|
||||||
lacks(t, "hub", body, `None yet`)
|
lacks(t, "hub", body, `None yet`)
|
||||||
@@ -184,7 +184,7 @@ func TestDMARCPagesStayWithinTheViewersDomains(t *testing.T) {
|
|||||||
has(t, "hub for its administrator", hub.Body.String(), `<h1 class="title is-3">DMARC reports</h1>`, `<h2>Recent reports</h2>`,
|
has(t, "hub for its administrator", hub.Body.String(), `<h1 class="title is-3">DMARC reports</h1>`, `<h2>Recent reports</h2>`,
|
||||||
`<a href="/outbound/dmarc/domains/1">example.org</a>`, `Outlook.com`, `href="/outbound/dmarc/reports/`+idStr(s.reportID)+`"`)
|
`<a href="/outbound/dmarc/domains/1">example.org</a>`, `Outlook.com`, `href="/outbound/dmarc/reports/`+idStr(s.reportID)+`"`)
|
||||||
lacks(t, "hub for its administrator", hub.Body.String(), "other.example.net", "google.com",
|
lacks(t, "hub for its administrator", hub.Body.String(), "other.example.net", "google.com",
|
||||||
`/outbound/dmarc/reports/`+idStr(otherReport)+`"`, `<h2>Ingest</h2>`, `dmarc-reports@mail.example.org`,
|
`/outbound/dmarc/reports/`+idStr(otherReport)+`"`, `<h2>Ingest</h2>`, `Hosted addresses`, `dmarc-reports+<domain>`,
|
||||||
`parse failure`, `Last report`, `Retention`)
|
`parse failure`, `Last report`, `Retention`)
|
||||||
|
|
||||||
// Their own domain and report are theirs, and so is the way back to the hub.
|
// Their own domain and report are theirs, and so is the way back to the hub.
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func (h *Handlers) HandleDMARCList(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
in := view.IngestInput{
|
in := view.IngestInput{
|
||||||
OK: stats.IngestOK, KeptThisWeek: stats.KeptThisWeek, ParseFailures: stats.ParseFailures,
|
OK: stats.IngestOK, KeptThisWeek: stats.KeptThisWeek, ParseFailures: stats.ParseFailures,
|
||||||
Hosted: h.dmarc.DefaultHostedSuggestion(), Host: h.cfg.Hostname,
|
Hosted: dmarc.HostedReportAddress(h.cfg.Hostname, "<domain>"), Host: h.cfg.Hostname,
|
||||||
RetentionMax: store.DMARCReportsMaxKeep, RetentionDays: store.DMARCReportsMaxAgeDays,
|
RetentionMax: store.DMARCReportsMaxKeep, RetentionDays: store.DMARCReportsMaxAgeDays,
|
||||||
}
|
}
|
||||||
if stats.LastReceivedAt != nil {
|
if stats.LastReceivedAt != nil {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func NewDMARCHub(m Meta, in IngestInput) *DMARCHub {
|
|||||||
IngestFacts: []Fact{
|
IngestFacts: []Fact{
|
||||||
last,
|
last,
|
||||||
{Label: "This week", Value: Plain(FormatCount(int64(in.KeptThisWeek)) + " kept · " + plural(int64(in.ParseFailures), "parse failure", "parse failures"))},
|
{Label: "This week", Value: Plain(FormatCount(int64(in.KeptThisWeek)) + " kept · " + plural(int64(in.ParseFailures), "parse failure", "parse failures"))},
|
||||||
{Label: "Hosted address", Value: Plain(in.Hosted), Mono: true},
|
{Label: "Hosted addresses", Value: Plain(in.Hosted), Mono: true},
|
||||||
{Label: "Retention", Value: Plain(FormatCount(int64(in.RetentionMax)) + " reports or " + plural(int64(in.RetentionDays), "day", "days"))},
|
{Label: "Retention", Value: Plain(FormatCount(int64(in.RetentionMax)) + " reports or " + plural(int64(in.RetentionDays), "day", "days"))},
|
||||||
},
|
},
|
||||||
Recent: Box{No: "02", Title: "Recent reports"},
|
Recent: Box{No: "02", Title: "Recent reports"},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func TestDMARCHubShowsIngestAndTheLatestReports(t *testing.T) {
|
|||||||
pageHas(t, "DMARC reports", out,
|
pageHas(t, "DMARC reports", out,
|
||||||
`<h1 class="title is-3">DMARC reports</h1>`, `<code>rua=</code>`,
|
`<h1 class="title is-3">DMARC reports</h1>`, `<code>rua=</code>`,
|
||||||
`<h2>Ingest</h2>`, `<span class="tag is-success is-light">ok</span>`,
|
`<h2>Ingest</h2>`, `<span class="tag is-success is-light">ok</span>`,
|
||||||
`<dd>2026-09-21 06:12 UTC</dd>`, `<dd>14 kept · 0 parse failures</dd>`, `<dd class="sp-mono">dmarc@mail.example.org</dd>`, `<dd>500 reports or 90 days</dd>`,
|
`<dd>2026-09-21 06:12 UTC</dd>`, `<dd>14 kept · 0 parse failures</dd>`, `<dt>Hosted addresses</dt>`, `<dd class="sp-mono">dmarc-reports+<domain>@mail.example.org</dd>`, `<dd>500 reports or 90 days</dd>`,
|
||||||
`<th>Received, UTC</th>`, `09-21 06:12`, `<a href="/outbound/dmarc/domains/1">example.org</a>`,
|
`<th>Received, UTC</th>`, `09-21 06:12`, `<a href="/outbound/dmarc/domains/1">example.org</a>`,
|
||||||
"<td>1 903</td>", `<span class="tag is-warning is-light">7</span>`, `<td>0</td><td class="sp-actions"><a href="/outbound/dmarc/reports/41">View</a>`,
|
"<td>1 903</td>", `<span class="tag is-warning is-light">7</span>`, `<td>0</td><td class="sp-actions"><a href="/outbound/dmarc/reports/41">View</a>`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ func outMessageFixture() *OutMessage {
|
|||||||
func dmarcHubFixture() *DMARCHub {
|
func dmarcHubFixture() *DMARCHub {
|
||||||
p := NewDMARCHub(admin(), IngestInput{
|
p := NewDMARCHub(admin(), IngestInput{
|
||||||
OK: true, Last: time.Date(2026, 9, 21, 6, 12, 0, 0, time.UTC), KeptThisWeek: 14, ParseFailures: 0,
|
OK: true, Last: time.Date(2026, 9, 21, 6, 12, 0, 0, time.UTC), KeptThisWeek: 14, ParseFailures: 0,
|
||||||
Hosted: "dmarc@mail.example.org", Host: "mail.example.org", RetentionMax: 500, RetentionDays: 90,
|
Hosted: "dmarc-reports+<domain>@mail.example.org", Host: "mail.example.org", RetentionMax: 500, RetentionDays: 90,
|
||||||
})
|
})
|
||||||
row := func(id int64, at, domain, reporter, window string, pass, fail int) DMARCReportRow {
|
row := func(id int64, at, domain, reporter, window string, pass, fail int) DMARCReportRow {
|
||||||
return DMARCReportRow{Received: at, Domain: domain, DomainHref: "/outbound/dmarc/domains/1", Reporter: reporter,
|
return DMARCReportRow{Received: at, Domain: domain, DomainHref: "/outbound/dmarc/domains/1", Reporter: reporter,
|
||||||
|
|||||||
Reference in New Issue
Block a user