Receive DMARC aggregate reports on port 25 and show parsed summaries in the panel. Close Unreleased; pin compose and docs to 1.7.0. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/app"
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
@@ -38,6 +39,8 @@ type Config struct {
|
||||
// InboundEnabled mirrors INBOUND_RELAY_ENABLE: the inbound panel and
|
||||
// routes exist only when this is true.
|
||||
InboundEnabled bool
|
||||
// DMARCEnabled mirrors DMARC_REPORTS_ENABLE.
|
||||
DMARCEnabled bool
|
||||
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start; used
|
||||
// as bootstrap and fallback when the settings row is missing or invalid.
|
||||
SendLogRetentionEnvDefault int
|
||||
@@ -49,6 +52,7 @@ type Handlers struct {
|
||||
domains *domain.Service
|
||||
apps *app.Service
|
||||
inbound *inbound.Service
|
||||
dmarc *dmarc.Service
|
||||
cfg Config
|
||||
view *view.Engine
|
||||
dns *dnscheck.Checker
|
||||
@@ -62,6 +66,7 @@ func New(
|
||||
domains *domain.Service,
|
||||
apps *app.Service,
|
||||
inboundSvc *inbound.Service,
|
||||
dmarcSvc *dmarc.Service,
|
||||
cfg Config,
|
||||
v *view.Engine,
|
||||
dns *dnscheck.Checker,
|
||||
@@ -73,6 +78,7 @@ func New(
|
||||
domains: domains,
|
||||
apps: apps,
|
||||
inbound: inboundSvc,
|
||||
dmarc: dmarcSvc,
|
||||
cfg: cfg,
|
||||
view: v,
|
||||
dns: dns,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
@@ -163,9 +164,15 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
|
||||
dmarcMode := "inherit"
|
||||
dmarcCustom := ""
|
||||
hostedAddr := ""
|
||||
if h.dmarc != nil && h.cfg.DMARCEnabled {
|
||||
hostedAddr = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
|
||||
}
|
||||
if d.DMARCRua.Valid {
|
||||
if d.DMARCRua.String == "" {
|
||||
dmarcMode = "none"
|
||||
} else if hostedAddr != "" && strings.EqualFold(d.DMARCRua.String, hostedAddr) {
|
||||
dmarcMode = "hosted"
|
||||
} else {
|
||||
dmarcMode = "custom"
|
||||
dmarcCustom = d.DMARCRua.String
|
||||
@@ -173,6 +180,8 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
}
|
||||
dmarcSource := "policy"
|
||||
switch {
|
||||
case dmarcMode == "hosted":
|
||||
dmarcSource = "hosted"
|
||||
case dmarcMode == "custom":
|
||||
dmarcSource = "custom"
|
||||
case dmarcMode == "none":
|
||||
@@ -195,10 +204,13 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
data["ResolvedDMARCEmail"] = reportEmail
|
||||
data["DMARCRuaMode"] = dmarcMode
|
||||
data["DMARCRuaCustom"] = dmarcCustom
|
||||
data["HostedDMARCEmail"] = hostedAddr
|
||||
data["DMARCIngestEnabled"] = h.cfg.DMARCEnabled
|
||||
data["ReportAuthName"] = reportAuthName
|
||||
data["ReportAuthValue"] = reportAuthValue
|
||||
data["NeedsReportAuth"] = needsReportAuth
|
||||
data["SameDomainRUA"] = reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name)
|
||||
data["SameDomainRUA"] = reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name) &&
|
||||
!(h.cfg.DMARCEnabled && dmarc.IsHostedOnHostname(reportEmail, h.cfg.Hostname))
|
||||
data["Hostname"] = h.cfg.Hostname
|
||||
data["SubmissionEnabled"] = h.cfg.SubmissionEnabled
|
||||
data["Apps"] = appViews
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,13 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
rua = sql.NullString{Valid: true, String: email}
|
||||
case "hosted":
|
||||
if h.dmarc == nil || !h.dmarc.Enabled() {
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "SelfPost-hosted reports are not enabled on this server."})
|
||||
return
|
||||
}
|
||||
addr := dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
|
||||
rua = sql.NullString{Valid: true, String: addr}
|
||||
default:
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
|
||||
return
|
||||
@@ -52,6 +60,11 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
|
||||
return
|
||||
}
|
||||
if h.dmarc != nil && h.dmarc.Enabled() {
|
||||
if err := h.dmarc.Resync(); err != nil {
|
||||
logf("panel: domain %d: dmarc resync: %v", d.ID, err)
|
||||
}
|
||||
}
|
||||
h.dns.Forget(d.Name)
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
|
||||
type dmarcListRow struct {
|
||||
store.DMARCReportSummary
|
||||
DomainID int64
|
||||
ReceivedLabel string
|
||||
PeriodLabel string
|
||||
}
|
||||
|
||||
func (h *Handlers) requireDMARC(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) {
|
||||
if !h.cfg.DMARCEnabled || h.dmarc == nil {
|
||||
http.NotFound(w, r)
|
||||
return auth.Principal{}, false
|
||||
}
|
||||
p, ok := h.principal(r)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return auth.Principal{}, false
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
func (h *Handlers) canViewDMARCDomain(p auth.Principal, d store.Domain) bool {
|
||||
if p.IsGlobal() {
|
||||
return true
|
||||
}
|
||||
for _, id := range p.Domains {
|
||||
if id == d.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HandleDMARCList is the global DMARC reports index.
|
||||
func (h *Handlers) HandleDMARCList(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := h.requireDMARC(w, r)
|
||||
if !ok || !p.IsGlobal() {
|
||||
if ok && !p.IsGlobal() {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
assigned, err := h.assignedDomains(p)
|
||||
if err != nil {
|
||||
logf("panel: dmarc list domains: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
names := make([]string, 0, len(assigned))
|
||||
domainIDs := make(map[string]int64, len(assigned))
|
||||
for _, d := range assigned {
|
||||
names = append(names, d.Name)
|
||||
domainIDs[d.Name] = d.ID
|
||||
}
|
||||
reports, err := h.store.ListDMARCReports(names, 100)
|
||||
if err != nil {
|
||||
logf("panel: dmarc list: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
stats, err := h.store.DMARCIngestStats()
|
||||
if err != nil {
|
||||
logf("panel: dmarc ingest stats: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rows := make([]dmarcListRow, len(reports))
|
||||
for i, rep := range reports {
|
||||
rows[i] = dmarcListRow{
|
||||
DMARCReportSummary: rep,
|
||||
DomainID: domainIDs[rep.Domain],
|
||||
ReceivedLabel: rep.ReceivedAt.UTC().Format("2006-01-02 15:04"),
|
||||
PeriodLabel: formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd),
|
||||
}
|
||||
}
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = "SelfPost — DMARC reports"
|
||||
data["Active"] = "dmarc"
|
||||
data["Reports"] = rows
|
||||
data["IngestStats"] = stats
|
||||
data["HostedAddress"] = h.dmarc.DefaultHostedSuggestion()
|
||||
data["RetentionMax"] = store.DMARCReportsMaxKeep
|
||||
data["RetentionDays"] = store.DMARCReportsMaxAgeDays
|
||||
if stats.LastReceivedAt != nil {
|
||||
data["LastReceivedLabel"] = stats.LastReceivedAt.UTC().Format("2006-01-02 15:04")
|
||||
}
|
||||
h.view.Render(w, http.StatusOK, "dmarc", data)
|
||||
}
|
||||
|
||||
// HandleDMARCDomain shows roll-ups for one sending domain.
|
||||
func (h *Handlers) HandleDMARCDomain(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireDMARC(w, r); !ok {
|
||||
return
|
||||
}
|
||||
d, ok := h.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, _ := h.principal(r)
|
||||
if !h.canViewDMARCDomain(p, d) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
const windowDays = 7
|
||||
pass, fail, err := h.store.DMARCDomainRollup(d.Name, windowDays)
|
||||
if err != nil {
|
||||
logf("panel: dmarc domain rollup %s: %v", d.Name, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sources, err := h.store.DMARCSourceRollups(d.Name, windowDays)
|
||||
if err != nil {
|
||||
logf("panel: dmarc source rollups %s: %v", d.Name, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
reports, err := h.store.ListDMARCReportsForDomain(d.Name, 50)
|
||||
if err != nil {
|
||||
logf("panel: dmarc domain reports %s: %v", d.Name, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
reportRows := make([]dmarcListRow, len(reports))
|
||||
for i, rep := range reports {
|
||||
reportRows[i] = dmarcListRow{
|
||||
DMARCReportSummary: rep,
|
||||
ReceivedLabel: rep.ReceivedAt.UTC().Format("2006-01-02 15:04"),
|
||||
PeriodLabel: formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd),
|
||||
}
|
||||
}
|
||||
hints := make([]dmarc.SourceHint, len(sources))
|
||||
for i, s := range sources {
|
||||
hints[i] = dmarc.SourceHint{
|
||||
SourceIP: s.SourceIP,
|
||||
PassCount: s.PassCount,
|
||||
FailCount: s.FailCount,
|
||||
ThisRelay: h.sourceIsThisRelay(s.SourceIP),
|
||||
}
|
||||
}
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = "SelfPost — " + d.Name + " DMARC"
|
||||
data["Active"] = "dmarc"
|
||||
data["Domain"] = d
|
||||
data["Reports"] = reportRows
|
||||
data["Pass7d"] = pass
|
||||
data["Fail7d"] = fail
|
||||
data["Sources"] = hints
|
||||
data["PolicyHint"] = dmarc.TightenPolicyHint(pass, fail, hints)
|
||||
data["WindowDays"] = windowDays
|
||||
h.view.Render(w, http.StatusOK, "dmarc_domain", data)
|
||||
}
|
||||
|
||||
// HandleDMARCReport shows one parsed aggregate report.
|
||||
func (h *Handlers) HandleDMARCReport(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireDMARC(w, r); !ok {
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
rep, err := h.store.GetDMARCReport(id)
|
||||
if errors.Is(err, store.ErrDMARCReportNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logf("panel: dmarc report %d: %v", id, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
p, _ := h.principal(r)
|
||||
domains, err := h.assignedDomains(p)
|
||||
if err != nil {
|
||||
logf("panel: dmarc report authz: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var d store.Domain
|
||||
found := false
|
||||
for _, cand := range domains {
|
||||
if cand.Name == rep.Domain {
|
||||
d = cand
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = fmt.Sprintf("SelfPost — %s report", rep.Reporter)
|
||||
data["Active"] = "dmarc"
|
||||
data["Report"] = rep
|
||||
data["Domain"] = d
|
||||
data["Hostname"] = h.cfg.Hostname
|
||||
data["WindowLabel"] = formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd)
|
||||
data["ReceivedLabel"] = rep.ReceivedAt.UTC().Format("2006-01-02 15:04")
|
||||
data["PeriodBeginLabel"] = rep.PeriodBegin.UTC().Format("2006-01-02 15:04")
|
||||
data["PeriodEndLabel"] = rep.PeriodEnd.UTC().Format("2006-01-02 15:04")
|
||||
h.view.Render(w, http.StatusOK, "dmarc_report", data)
|
||||
}
|
||||
|
||||
func (h *Handlers) sourceIsThisRelay(ip string) bool {
|
||||
if ip == "" || h.dns == nil || h.cfg.Hostname == "" {
|
||||
return false
|
||||
}
|
||||
srv := h.dns.Server(h.cfg.Hostname, false)
|
||||
for _, s := range srv.IPs {
|
||||
if ip == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func formatDMARCWindow(begin, end time.Time) string {
|
||||
if begin.IsZero() {
|
||||
return ""
|
||||
}
|
||||
if begin.Year() == end.Year() && begin.YearDay() == end.YearDay() {
|
||||
return begin.UTC().Format("2 Jan")
|
||||
}
|
||||
return begin.UTC().Format("2 Jan") + " – " + end.UTC().Format("2 Jan")
|
||||
}
|
||||
@@ -63,6 +63,10 @@ func (h *Handlers) renderSettings(w http.ResponseWriter, r *http.Request, status
|
||||
data["Flash"] = settingsFlash(r)
|
||||
data["L1Messages"] = h.l1Messages()
|
||||
data["L1Window"] = h.l1Window()
|
||||
data["DMARCIngestEnabled"] = h.cfg.DMARCEnabled
|
||||
if h.dmarc != nil && h.cfg.DMARCEnabled {
|
||||
data["HostedReportAddress"] = h.dmarc.DefaultHostedSuggestion()
|
||||
}
|
||||
h.view.Render(w, status, "settings", data)
|
||||
}
|
||||
|
||||
@@ -220,6 +224,12 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if emailChanging && h.dmarc != nil && h.dmarc.Enabled() {
|
||||
if err := h.dmarc.Resync(); err != nil {
|
||||
logf("panel: settings: dmarc resync: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if token, ok := h.auth.SessionToken(r); ok {
|
||||
if renaming {
|
||||
h.auth.RenameSession(token, username)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{{define "wide"}}wide{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1>DMARC reports</h1>
|
||||
|
||||
<p class="muted">Aggregate reports SelfPost accepted for <code>rua=</code>.
|
||||
Forensic (<code>ruf=</code>) is out of scope. Open a report for the parsed
|
||||
XML — not a dashboard.</p>
|
||||
|
||||
<div class="pair">
|
||||
<div class="card">
|
||||
<h2>Ingest {{if .IngestStats.IngestOK}}<span class="st st-ok">ok</span>{{else}}<span class="st st-warn">quiet</span>{{end}}</h2>
|
||||
{{if .LastReceivedLabel}}
|
||||
<p>Last report {{.LastReceivedLabel}} UTC. Port 25 accepts only configured report addresses.</p>
|
||||
{{else}}
|
||||
<p>No reports received yet. Point <code>rua=</code> at
|
||||
<code>{{.HostedAddress}}</code> (or a per-domain hosted address) and publish
|
||||
MX for {{.HostedAddress}} on this server.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>This week</h2>
|
||||
<p>{{.IngestStats.KeptThisWeek}} kept{{if .IngestStats.ParseFailures}}, {{.IngestStats.ParseFailures}} parse failures{{end}}.
|
||||
Older summaries are pruned ({{.RetentionMax}} max, {{.RetentionDays}} days).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Recent reports</h2>
|
||||
{{if .Reports}}
|
||||
<table class="desk-only">
|
||||
<thead><tr><th>Received</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Reports}}
|
||||
<tr>
|
||||
<td class="time muted">{{.ReceivedLabel}}</td>
|
||||
<td>{{if .DomainID}}<a href="/dmarc/domains/{{.DomainID}}">{{.Domain}}</a>{{else}}{{.Domain}}{{end}}</td>
|
||||
<td class="muted">{{.Reporter}}</td>
|
||||
<td>{{.PeriodLabel}}</td>
|
||||
<td>{{.PassCount}}</td>
|
||||
<td>{{.FailCount}}</td>
|
||||
<td class="actions"><a href="/dmarc/reports/{{.ID}}">View</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<ul class="phone-list phone-only">
|
||||
{{range .Reports}}
|
||||
<li>
|
||||
<a href="/dmarc/reports/{{.ID}}">
|
||||
<span class="st {{if gt .FailCount 0}}st-warn{{else}}st-ok{{end}}">{{.PassCount}} pass</span>
|
||||
<span class="when">{{.ReceivedLabel}}</span>
|
||||
<span class="meta">{{.Domain}} · {{.Reporter}}</span>
|
||||
<span class="subj">{{.FailCount}} fail</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="muted">No reports yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,59 @@
|
||||
{{define "content"}}
|
||||
<h1>{{.Domain.Name}}</h1>
|
||||
{{template "back_link" (back "/dmarc" "All DMARC reports")}}
|
||||
{{template "back_link" (back (printf "/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
|
||||
|
||||
<div class="pair">
|
||||
<div class="card">
|
||||
<h2>Last {{.WindowDays}} days {{if and (gt .Pass7d 0) (eq .Fail7d 0)}}<span class="st st-ok">pass</span>{{else if gt .Fail7d 0}}<span class="st st-warn">review</span>{{end}}</h2>
|
||||
<p>{{.PolicyHint}}</p>
|
||||
<p class="muted"><a href="/domains/{{.Domain.ID}}#dmarc">Domain DNS and rua=</a></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Totals</h2>
|
||||
<p>{{.Pass7d}} pass · {{.Fail7d}} fail in aggregate summaries.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Reports</h2>
|
||||
{{if .Reports}}
|
||||
<table class="desk-only">
|
||||
<thead><tr><th>Received</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Reports}}
|
||||
<tr>
|
||||
<td class="time muted">{{.ReceivedLabel}}</td>
|
||||
<td class="muted">{{.Reporter}}</td>
|
||||
<td>{{.PeriodLabel}}</td>
|
||||
<td>{{.PassCount}}</td>
|
||||
<td>{{.FailCount}}</td>
|
||||
<td class="actions"><a href="/dmarc/reports/{{.ID}}">View</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No reports for this domain yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Sources}}
|
||||
<div class="card">
|
||||
<h2>Sources · last {{.WindowDays}} days</h2>
|
||||
<table class="desk-only">
|
||||
<thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Sources}}
|
||||
<tr>
|
||||
<td class="muted">{{.SourceIP}}{{if .ThisRelay}} (this relay){{end}}</td>
|
||||
<td>{{.PassCount}}</td>
|
||||
<td>{{.FailCount}}</td>
|
||||
<td><span class="st {{if gt .FailCount 0}}st-warn{{else}}st-ok{{end}}">{{.Disposition}}</span></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,52 @@
|
||||
{{define "content"}}
|
||||
<h1 class="subject">{{.Report.Reporter}} · {{.WindowLabel}}</h1>
|
||||
<p class="route">
|
||||
<span class="addr">{{.Report.Domain}}</span>
|
||||
<span class="st st-ok">{{.Report.PassCount}} pass</span>
|
||||
{{if gt .Report.FailCount 0}}<span class="st st-warn">{{.Report.FailCount}} fail</span>{{end}}
|
||||
</p>
|
||||
{{template "back_link" (back (printf "/dmarc/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
|
||||
|
||||
<div class="pair">
|
||||
<div class="card">
|
||||
<h2>Report</h2>
|
||||
<div class="facts">
|
||||
<div class="fact"><span class="fact-label">Reporter</span><span class="fact-value">{{.Report.Reporter}}</span></div>
|
||||
<div class="fact"><span class="fact-label">Report id</span><span class="fact-value mono">{{.Report.ReportID}}</span></div>
|
||||
<div class="fact"><span class="fact-label">Window</span><span class="fact-value">{{.PeriodBeginLabel}} – {{.PeriodEndLabel}} UTC</span></div>
|
||||
<div class="fact"><span class="fact-label">Received</span><span class="fact-value">{{.ReceivedLabel}} UTC</span></div>
|
||||
{{if .Report.ContactEmail}}<div class="fact"><span class="fact-label">Contact</span><span class="fact-value">{{.Report.ContactEmail}}</span></div>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card{{if gt .Report.FailCount 0}} attn{{end}}">
|
||||
<h2>Published policy</h2>
|
||||
<div class="facts">
|
||||
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">{{.Report.Domain}}</span></div>
|
||||
<div class="fact"><span class="fact-label">p / sp / pct</span><span class="fact-value mono">{{.Report.PolicyP}} / {{.Report.PolicySP}} / {{.Report.PolicyPct}}</span></div>
|
||||
<div class="fact"><span class="fact-label">adkim / aspf</span><span class="fact-value mono">{{.Report.PolicyADKIM}} / {{.Report.PolicyASPF}}</span></div>
|
||||
{{if .Report.Recipient}}<div class="fact"><span class="fact-label">rua</span><span class="fact-value">{{.Report.Recipient}}</span></div>{{end}}
|
||||
</div>
|
||||
<p class="muted"><code>p={{.Report.PolicyP}}</code> is what receivers enforced in this window.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Records</h2>
|
||||
<table class="desk-only">
|
||||
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Report.Records}}
|
||||
<tr>
|
||||
<td class="muted">{{.SourceIP}}</td>
|
||||
<td>{{.Count}}</td>
|
||||
<td><span class="st {{if or (eq .SPFResult "pass") (eq .DKIMResult "pass")}}st-ok{{else}}st-warn{{end}}">{{.Disposition}}</span></td>
|
||||
<td><span class="st {{if eq .SPFResult "pass"}}st-ok{{else}}st-error{{end}}">{{.SPFResult}}</span></td>
|
||||
<td><span class="st {{if eq .DKIMResult "pass"}}st-ok{{else}}st-error{{end}}">{{.DKIMResult}}</span></td>
|
||||
<td>{{.HeaderFrom}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">Parsed from the aggregate XML. Forensic (<code>ruf=</code>) samples are not stored.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -170,8 +170,11 @@
|
||||
</div>
|
||||
|
||||
{{if .SameDomainRUA}}
|
||||
<p class="error">The report address is on this sending domain. SelfPost does
|
||||
not receive inbound mail — use a mailbox on another domain.</p>
|
||||
<p class="error">The report address is on this sending domain.{{if not .DMARCIngestEnabled}}
|
||||
SelfPost does not receive inbound mail — use a mailbox on another domain.{{end}}</p>
|
||||
{{end}}
|
||||
{{if and .DMARCIngestEnabled .ResolvedDMARCEmail}}
|
||||
<p class="muted"><a href="/dmarc/domains/{{.Domain.ID}}">View DMARC reports</a> for this domain.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .NeedsReportAuth}}
|
||||
@@ -385,6 +388,7 @@
|
||||
<label for="dmarc_rua_mode">Aggregate reports (rua=)</label>
|
||||
<select id="dmarc_rua_mode" name="dmarc_rua_mode" data-custom-mode="custom">
|
||||
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
|
||||
{{if .DMARCIngestEnabled}}<option value="hosted"{{if eq .DMARCRuaMode "hosted"}} selected{{end}}>SelfPost hosted ({{.HostedDMARCEmail}})</option>{{end}}
|
||||
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
|
||||
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
|
||||
</select>
|
||||
|
||||
@@ -73,6 +73,9 @@
|
||||
{{if and .IsGlobal .InboundEnabled}}
|
||||
{{if eq .Active "inbound"}}<span aria-current="page">{{template "icon-inbound"}}Inbound</span>{{else}}<a href="/inbound">{{template "icon-inbound"}}Inbound</a>{{end}}
|
||||
{{end}}
|
||||
{{if .DMARCEnabled}}
|
||||
{{if eq .Active "dmarc"}}<span aria-current="page">{{template "icon-dmarc"}}DMARC</span>{{else}}<a href="/dmarc">{{template "icon-dmarc"}}DMARC</a>{{end}}
|
||||
{{end}}
|
||||
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
|
||||
{{if .IsGlobal}}
|
||||
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
|
||||
@@ -116,6 +119,7 @@
|
||||
{{define "icon-status"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></svg>{{end}}
|
||||
{{define "icon-domains"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></svg>{{end}}
|
||||
{{define "icon-inbound"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></svg>{{end}}
|
||||
{{define "icon-dmarc"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4.5h11v7H2.5z"/><path d="M5 7.5h6M5 9.5h4"/></svg>{{end}}
|
||||
{{define "icon-deliveries"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></svg>{{end}}
|
||||
{{define "icon-mail-queue"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></svg>{{end}}
|
||||
{{define "icon-system-log"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></svg>{{end}}
|
||||
|
||||
@@ -41,14 +41,15 @@
|
||||
<div class="card">
|
||||
<h2>DMARC aggregate reports</h2>
|
||||
<p class="muted">Optional default <code>rua=</code> address for every sending
|
||||
domain (can be overridden per domain). Use a mailbox on a domain that
|
||||
receives inbound mail. SelfPost does not receive inbound mail — point
|
||||
<code>rua=</code> at a mailbox elsewhere.</p>
|
||||
domain (can be overridden per domain).{{if .DMARCIngestEnabled}} When set to
|
||||
an address on <code>{{.HostedReportAddress}}</code>, SelfPost receives and
|
||||
parses aggregate reports on port 25.{{else}} Use a mailbox on a domain
|
||||
that receives inbound mail elsewhere.{{end}}</p>
|
||||
|
||||
<label for="dmarc_report_email">Default report address</label>
|
||||
<input id="dmarc_report_email" name="dmarc_report_email" type="email"
|
||||
autocomplete="email" autocapitalize="none" spellcheck="false"
|
||||
value="{{.FormDMARCEmail}}" placeholder="reports@your-mail-domain.com">
|
||||
value="{{.FormDMARCEmail}}" placeholder="{{if .DMARCIngestEnabled}}{{.HostedReportAddress}}{{else}}reports@your-mail-domain.com{{end}}">
|
||||
|
||||
{{if .FormDMARCEmail}}
|
||||
<p class="muted">When <code>rua=</code> points at another domain, that hub
|
||||
|
||||
@@ -222,7 +222,7 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
|
||||
wide := map[string]bool{
|
||||
"settings": true, "deliveries": true, "delivery": true, "mail_queue": true,
|
||||
"status": true, "system_log": true, "domain_detail": true,
|
||||
"inbound": true, "inbound_domain": true,
|
||||
"inbound": true, "inbound_domain": true, "dmarc": true,
|
||||
}
|
||||
for name, page := range engine.Pages() {
|
||||
var buf bytes.Buffer
|
||||
@@ -324,6 +324,8 @@ func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
|
||||
"domain_delete.html": true,
|
||||
"inbound_domain.html": true,
|
||||
"inbound_delete.html": true,
|
||||
"dmarc_domain.html": true,
|
||||
"dmarc_report.html": true,
|
||||
"delivery.html": true,
|
||||
}
|
||||
forEachTemplate(t, func(name, body string) {
|
||||
|
||||
@@ -22,6 +22,7 @@ type Engine struct {
|
||||
fragments map[string]*template.Template
|
||||
version string
|
||||
inboundEnabled bool
|
||||
dmarcEnabled bool
|
||||
}
|
||||
|
||||
// pageFiles maps a logical page name to its template files. Every page
|
||||
@@ -45,6 +46,9 @@ var pageFiles = map[string][]string{
|
||||
"inbound": {"templates/inbound.html"},
|
||||
"inbound_domain": {"templates/inbound_domain.html"},
|
||||
"inbound_delete": {"templates/inbound_delete.html"},
|
||||
"dmarc": {"templates/dmarc.html"},
|
||||
"dmarc_domain": {"templates/dmarc_domain.html"},
|
||||
"dmarc_report": {"templates/dmarc_report.html"},
|
||||
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
|
||||
"delivery": {"templates/delivery.html"},
|
||||
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
|
||||
@@ -92,6 +96,11 @@ func (e *Engine) SetInboundEnabled(v bool) {
|
||||
e.inboundEnabled = v
|
||||
}
|
||||
|
||||
// SetDMARCEnabled controls whether the DMARC nav item is shown.
|
||||
func (e *Engine) SetDMARCEnabled(v bool) {
|
||||
e.dmarcEnabled = v
|
||||
}
|
||||
|
||||
// templateFuncs supplies helpers shared across page templates.
|
||||
func templateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
@@ -135,6 +144,7 @@ func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any
|
||||
m["Copyright"] = legal.CopyrightLine
|
||||
m["SourceURL"] = legal.SourceURL
|
||||
m["InboundEnabled"] = e.inboundEnabled
|
||||
m["DMARCEnabled"] = e.dmarcEnabled
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
||||
|
||||
+13
-2
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/app"
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/legal"
|
||||
@@ -85,6 +86,8 @@ type Config struct {
|
||||
RetryPolicy postfix.RetryPolicy
|
||||
// InboundEnabled mirrors INBOUND_RELAY_ENABLE.
|
||||
InboundEnabled bool
|
||||
// DMARCEnabled mirrors DMARC_REPORTS_ENABLE.
|
||||
DMARCEnabled bool
|
||||
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start.
|
||||
SendLogRetentionEnvDefault int
|
||||
}
|
||||
@@ -101,19 +104,20 @@ type Server struct {
|
||||
// that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM);
|
||||
// apps owns application SASL accounts and the Postfix sender map
|
||||
// (architecture.md § Mail path).
|
||||
func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, cfg Config, setupTokenPath string) (*Server, error) {
|
||||
func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, dmarcSvc *dmarc.Service, cfg Config, setupTokenPath string) (*Server, error) {
|
||||
v, err := view.New(cfg.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.SetInboundEnabled(cfg.InboundEnabled)
|
||||
v.SetDMARCEnabled(cfg.DMARCEnabled)
|
||||
a := auth.New(st, auth.Config{
|
||||
CookieSecure: cfg.CookieSecure,
|
||||
Hostname: cfg.Hostname,
|
||||
SessionIdleDays: cfg.SessionIdleDays,
|
||||
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
|
||||
}, v, setupTokenPath)
|
||||
h := handlers.New(st, domains, apps, inboundSvc, handlers.Config{
|
||||
h := handlers.New(st, domains, apps, inboundSvc, dmarcSvc, handlers.Config{
|
||||
Hostname: cfg.Hostname,
|
||||
SubmissionEnabled: cfg.SubmissionEnabled,
|
||||
MailLogPath: cfg.MailLogPath,
|
||||
@@ -128,6 +132,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc
|
||||
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
|
||||
RetryPolicy: cfg.RetryPolicy,
|
||||
InboundEnabled: cfg.InboundEnabled,
|
||||
DMARCEnabled: cfg.DMARCEnabled,
|
||||
SendLogRetentionEnvDefault: cfg.SendLogRetentionEnvDefault,
|
||||
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
|
||||
return &Server{cfg: cfg, auth: a, handlers: h}, nil
|
||||
@@ -188,6 +193,12 @@ func (s *Server) Handler() http.Handler {
|
||||
authed.HandleFunc("POST /inbound/{id}/delete", h.HandleInboundDelete)
|
||||
}
|
||||
|
||||
if s.cfg.DMARCEnabled {
|
||||
authed.HandleFunc("GET /dmarc", h.HandleDMARCList)
|
||||
authed.HandleFunc("GET /dmarc/reports/{id}", h.HandleDMARCReport)
|
||||
authed.HandleFunc("GET /dmarc/domains/{id}", h.HandleDMARCDomain)
|
||||
}
|
||||
|
||||
authed.HandleFunc("/settings", h.HandleSettings)
|
||||
authed.HandleFunc("/account", redirectSettings)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user