diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b5188e..2237ec4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 answer is an error with `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` - inbound mail is deferred - and a warning otherwise - mail goes through unfiltered. +- **Port 25 no longer accepts `dmarc-reports@`**, the report + mailbox of the server as a whole. Only an address a domain has chosen is + accepted: its hosted address `dmarc-reports+@`, 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 each row of a domain's Applications table again, as POST forms that look like the links beside them. diff --git a/docs/assets/panel-redesign/evidence/dmarc.png b/docs/assets/panel-redesign/evidence/dmarc.png index 6968df9..ceaaf07 100644 Binary files a/docs/assets/panel-redesign/evidence/dmarc.png and b/docs/assets/panel-redesign/evidence/dmarc.png differ diff --git a/docs/guide.md b/docs/guide.md index 8fafa68..1421c94 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -874,9 +874,10 @@ settings) and nowhere else: 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 -`SELFPOST_HOSTNAME` are accepted on port 25: the generic -`dmarc-reports@` and any address on that host that a domain has -chosen. +`SELFPOST_HOSTNAME` that a domain has chosen are accepted on port 25 - the +hosted address `dmarc-reports+@` of a domain set to +*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 `rua=mailto:…` pointing at the address the domain shows. Receivers deliver to the diff --git a/internal/dmarc/addresses.go b/internal/dmarc/addresses.go index b3902a2..0f601c4 100644 --- a/internal/dmarc/addresses.go +++ b/internal/dmarc/addresses.go @@ -4,7 +4,8 @@ import ( "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" // HostedReportAddress is the per-domain SelfPost-hosted rua= destination. @@ -14,11 +15,6 @@ func HostedReportAddress(hostname, domain string) string { 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. func IsHostedOnHostname(addr, hostname string) bool { addr = strings.ToLower(strings.TrimSpace(addr)) diff --git a/internal/dmarc/service.go b/internal/dmarc/service.go index 8cc59c7..350de3a 100644 --- a/internal/dmarc/service.go +++ b/internal/dmarc/service.go @@ -64,10 +64,9 @@ func (s *Service) AllowedRecipients() ([]string, error) { seen[addr] = true out = append(out, addr) } - // The instance's generic mailbox is always open: it is the address the - // guide tells a fresh install to publish. A domain's own report address - // opens the mailbox it names, if that is on this host. - add(DefaultHostedReportAddress(s.hostname)) + // Only what a domain has chosen is open: its report address opens the + // mailbox it names, if that is on this host. There is no mailbox for the + // server as a whole. domains, err := s.store.ListDomains() if err != nil { return nil, err @@ -101,11 +100,6 @@ func (s *Service) HostedSuggestion(domain string) string { 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. func (s *Service) ValidateHostedAddress(addr string) error { if !IsHostedOnHostname(addr, s.hostname) { diff --git a/internal/dmarc/service_test.go b/internal/dmarc/service_test.go index 4486cc5..0380ae1 100644 --- a/internal/dmarc/service_test.go +++ b/internal/dmarc/service_test.go @@ -7,8 +7,8 @@ import ( "github.com/mixeme/selfpost/internal/store" ) -// The ingest allow-list is the generic mailbox plus the report address of each -// domain that points at this host: a new domain (no reports) and a domain +// The ingest allow-list is the report address of each domain that points at +// 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. func TestAllowedRecipientsFollowTheDomainAddresses(t *testing.T) { st, err := store.Open(t.TempDir() + "/test.db") @@ -36,7 +36,7 @@ func TestAllowedRecipientsFollowTheDomainAddresses(t *testing.T) { if err != nil { 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) { t.Errorf("allowed recipients = %v, want %v", got, want) } diff --git a/internal/web/handlers/handlers_dmarc_pages_test.go b/internal/web/handlers/handlers_dmarc_pages_test.go index bb366da..4d3809a 100644 --- a/internal/web/handlers/handlers_dmarc_pages_test.go +++ b/internal/web/handlers/handlers_dmarc_pages_test.go @@ -49,7 +49,7 @@ func TestDMARCHubShowsIngestAndRecentReports(t *testing.T) { body := getBody(t, s.h.HandleDMARCList, "/outbound/dmarc") has(t, "hub", body, `

DMARC reports

`, `

Ingest

`, `ok`, - `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`, `

Recent reports

`, `example.org`, `Outlook.com`, "1\u00a0903", `7`, `href="/outbound/dmarc/reports/`+idStr(s.reportID)+`">View`) lacks(t, "hub", body, `None yet`) @@ -184,7 +184,7 @@ func TestDMARCPagesStayWithinTheViewersDomains(t *testing.T) { has(t, "hub for its administrator", hub.Body.String(), `

DMARC reports

`, `

Recent reports

`, `example.org`, `Outlook.com`, `href="/outbound/dmarc/reports/`+idStr(s.reportID)+`"`) lacks(t, "hub for its administrator", hub.Body.String(), "other.example.net", "google.com", - `/outbound/dmarc/reports/`+idStr(otherReport)+`"`, `

Ingest

`, `dmarc-reports@mail.example.org`, + `/outbound/dmarc/reports/`+idStr(otherReport)+`"`, `

Ingest

`, `Hosted addresses`, `dmarc-reports+<domain>`, `parse failure`, `Last report`, `Retention`) // Their own domain and report are theirs, and so is the way back to the hub. diff --git a/internal/web/handlers/handlers_dmarc_reports.go b/internal/web/handlers/handlers_dmarc_reports.go index 3abd2bf..630bc17 100644 --- a/internal/web/handlers/handlers_dmarc_reports.go +++ b/internal/web/handlers/handlers_dmarc_reports.go @@ -85,7 +85,7 @@ func (h *Handlers) HandleDMARCList(w http.ResponseWriter, r *http.Request) { } in := view.IngestInput{ OK: stats.IngestOK, KeptThisWeek: stats.KeptThisWeek, ParseFailures: stats.ParseFailures, - Hosted: h.dmarc.DefaultHostedSuggestion(), Host: h.cfg.Hostname, + Hosted: dmarc.HostedReportAddress(h.cfg.Hostname, ""), Host: h.cfg.Hostname, RetentionMax: store.DMARCReportsMaxKeep, RetentionDays: store.DMARCReportsMaxAgeDays, } if stats.LastReceivedAt != nil { diff --git a/internal/web/view/dmarc.go b/internal/web/view/dmarc.go index ad63193..e794461 100644 --- a/internal/web/view/dmarc.go +++ b/internal/web/view/dmarc.go @@ -112,7 +112,7 @@ func NewDMARCHub(m Meta, in IngestInput) *DMARCHub { IngestFacts: []Fact{ last, {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"))}, }, Recent: Box{No: "02", Title: "Recent reports"}, diff --git a/internal/web/view/dmarc_test.go b/internal/web/view/dmarc_test.go index 4f6b15c..dbf7422 100644 --- a/internal/web/view/dmarc_test.go +++ b/internal/web/view/dmarc_test.go @@ -12,7 +12,7 @@ func TestDMARCHubShowsIngestAndTheLatestReports(t *testing.T) { pageHas(t, "DMARC reports", out, `

DMARC reports

`, `rua=`, `

Ingest

`, `ok`, - `
2026-09-21 06:12 UTC
`, `
14 kept · 0 parse failures
`, `
dmarc@mail.example.org
`, `
500 reports or 90 days
`, + `
2026-09-21 06:12 UTC
`, `
14 kept · 0 parse failures
`, `
Hosted addresses
`, `
dmarc-reports+<domain>@mail.example.org
`, `
500 reports or 90 days
`, `Received, UTC`, `09-21 06:12`, `example.org`, "1 903", `7`, `0View`, ) diff --git a/internal/web/view/fixtures_test.go b/internal/web/view/fixtures_test.go index 46a819e..62d172e 100644 --- a/internal/web/view/fixtures_test.go +++ b/internal/web/view/fixtures_test.go @@ -271,7 +271,7 @@ func outMessageFixture() *OutMessage { func dmarcHubFixture() *DMARCHub { p := NewDMARCHub(admin(), IngestInput{ 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+@mail.example.org", Host: "mail.example.org", RetentionMax: 500, RetentionDays: 90, }) 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,