release: 1.1.0: Domain-admin role and internal/web package split

The first MINOR after 1.0.0: send-only DMARC guidance in the panel, AGPL packaging on every page, and an internal split of `internal/web` ahead of domain-admin work. Upgrading runs one SQLite migration (empty defaults; existing DNS guidance is unchanged until you set a report address).
Changed:
  - `internal/web` split into subpackages (`web/view`, `web/auth`, `web/validate`,
Added:
  - panel: DMARC guidance for send-only relays — the suggested `_dmarc` record
  - AGPL packaging hygiene: [NOTICE](NOTICE) names the copyright holder and the
  - [docs/roadmap.md](docs/roadmap.md) — candidate item **visual-style** (panel

Full detail: CHANGELOG.md (1.1.0).
This commit is contained in:
mix
2026-09-22 15:24:52 +03:00
parent 75207f0d1e
commit f62fe23826
93 changed files with 3256 additions and 1770 deletions
+54 -5
View File
@@ -60,13 +60,15 @@ func (c *Checker) checkDKIM(ctx context.Context, q Query) Result {
// checkDMARC reports whether the domain publishes a DMARC policy. DMARC is not
// required for delivery, so its absence is advice (warn), not a fault.
func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
name := DMARCRecordName(domainName)
func (c *Checker) checkDMARC(ctx context.Context, q Query) Result {
name := DMARCRecordName(q.Name)
txt, found, err := c.lookupTXT(ctx, name)
if err != nil {
return lookupFailed("the DMARC record", err)
}
example := DMARCExample(q.DMARCReportEmail)
var records []string
for _, rec := range txt {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
@@ -74,10 +76,11 @@ func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
}
}
if !found || len(records) == 0 {
return Result{
Status: health.StatusWarn,
Detail: fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing at least %q tells receivers what to do with mail that fails DKIM and gets you reports.", name, DMARCExample(domainName)),
detail := fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing %q tells receivers what to do with mail that fails authentication.", name, example)
if q.DMARCReportEmail == "" {
detail += " Aggregate reports (rua=) are optional on a send-only relay — omit rua= unless a mailbox that receives inbound mail is configured."
}
return Result{Status: health.StatusWarn, Detail: detail}
}
if len(records) > 1 {
return Result{
@@ -102,6 +105,52 @@ func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
return Result{Status: health.StatusOK, Detail: detail, Records: records}
}
// checkReportAuth verifies the hub domain publishes _report._dmarc for external
// aggregate-report destinations. Missing authorisation does not affect outbound
// delivery, only whether reports reach the rua= mailbox.
func (c *Checker) checkReportAuth(ctx context.Context, hubDomain string) Result {
name := ReportAuthRecordName(hubDomain)
expected := ReportAuthExample()
txt, found, err := c.lookupTXT(ctx, name)
if err != nil {
return lookupFailed("the DMARC report-authorisation record", err)
}
var records []string
for _, rec := range txt {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
records = append(records, rec)
}
}
if !found || len(records) == 0 {
return Result{
Status: health.StatusWarn,
Detail: fmt.Sprintf("No report-authorisation record at %s. Aggregate DMARC reports sent to a mailbox on %s will not be delivered until %q is published there.", name, hubDomain, expected),
}
}
if len(records) > 1 {
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("More than one DMARC report-authorisation record is published at %s. Keep exactly one.", name),
Records: records,
}
}
return Result{
Status: health.StatusOK,
Detail: fmt.Sprintf("Published at %s — aggregate reports addressed to %s are authorised.", name, hubDomain),
Records: records,
}
}
// ReportAuth checks whether hubDomain authorises external DMARC aggregate
// reports. It is used on the settings page for the administrator profile.
func (c *Checker) ReportAuth(ctx context.Context, hubDomain string) Result {
if hubDomain == "" {
return Result{}
}
return c.checkReportAuth(ctx, hubDomain)
}
// publicKeyTag extracts the p= (public key) tag of a DKIM record, with all
// whitespace removed: DNS providers and TXT chunking freely insert spaces and
// line breaks into the base64, none of which are part of the key.