Stage 2, step 4. Written by Sonnet, reviewed by Opus.
- Five pages named after their mockups: out-log, out-message, dmarc,
dmarc-domain, dmarc-report, with typed page data
(view/outbound_log.go, dmarc.go), fixtures and tests. The engine
pages deliveries and delivery are gone with their templates; the
three DMARC templates are written anew. All five old names are off
legacy_pages.txt.
- The log polls its table (GET /outbound/log/fragment, as before); the
page count in the box foot follows out of band, and the filter in
the head is no longer swapped away while it is being chosen.
- Handlers pass typed data; routes, query parameters, filters, paging
and authorization are unchanged.
- Kit: Foot.ID / Foot.OOB (attributes only, as on Head) and Fact.Small,
the mockup's "sp-mono sp-small" value, for a report id and an rua
that wrapped mid-word in their column.
- test/e2e status scrapers match the new tag markup. Not run: no
Docker here.
Fixed on the way
- The DMARC domain page read a field its source rows do not have and
answered 500 as soon as the domain had a source. It builds the rows
from the store's rollup now, with a test.
Differences from the mockups, for the owner:
- the log has no Status filter (the store filters by domain and
application only); a refused message is "rejected", a neutral tag as
the mockup's "rate-limited" is;
- the message history keeps the panel's own wording with full dates and
has no "accepted from <IP>" step - the client address is not stored;
- dmarc: before the first report the box says "None yet" with what to
check; a quiet mailbox is an amber "quiet" tag;
- dmarc-domain: the stamp is pass / (pass + fail) rounded down, amber
on any failure, red when nothing passed, absent without mail;
- dmarc-report: the title has no year; "this relay" marks record rows
as well as sources.
Known and left as it is (not introduced here): the DMARC hub answers 404
to a domain administrator while the menu shows the entry and the plan
gives that role the reports of its own domains. Opening it needs the
report list to refuse an empty domain filter first - for the stage-3
security review.
Evidence
- docs/assets/panel-redesign/evidence/out-log.png, out-message.png,
dmarc.png, dmarc-domain.png, dmarc-report.png: 1440 px wide, headless
Edge, real templates served locally with their fixtures - NOT from
the container (no Docker on this machine).
- panel/audit.js on the five pages at 1440 and 1280: 784 texts checked,
problems: [].
- Polling of the log was exercised in a browser against a local
fixture server with document.hidden overridden: four polls, one
table and one foot after each, the filter untouched.
- go test -count=1 ./internal/web/... ./cmd/panel
ok internal/web
ok internal/web/auth
ok internal/web/handlers
ok internal/web/validate
ok internal/web/view
ok cmd/panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
310 lines
17 KiB
Go
310 lines
17 KiB
Go
package view
|
||
|
||
import (
|
||
"strconv"
|
||
"time"
|
||
)
|
||
|
||
// pageFixtures is the data each redesigned page is rendered with by the guard
|
||
// tests (guard_panel_test.go): one entry per page that has left
|
||
// legacy_pages.txt, keyed by the page's engine name. The guards render the page
|
||
// through Engine.Render with this value and hold the result to the design
|
||
// contract — its class vocabulary, its page structure and the component
|
||
// skeleton recorded for the mockup of the same name in
|
||
// docs/assets/panel-redesign/panel/outlines.json.
|
||
//
|
||
// A fixture therefore has to fill the page the way its mockup is filled: the
|
||
// same boxes, the same number of records. It is also what the evidence
|
||
// screenshots are compared against, so use plausible values, not "foo".
|
||
//
|
||
// This file belongs to the implementation, not to the guards: the step that
|
||
// restyles a page adds its fixture here in the same commit.
|
||
var pageFixtures = map[string]func() any{
|
||
"components": func() any { return KitPage() },
|
||
"login": func() any { return NewLogin("mail.example.org", "") },
|
||
"setup": func() any { return NewSetup("0123456789abcdef", "") },
|
||
"overview": func() any { return overviewFixture() },
|
||
"health": func() any { return healthFixture(false) },
|
||
"account": func() any { return accountFixture() },
|
||
"settings": func() any { return settingsFixture() },
|
||
|
||
"out-domains": func() any { return outDomainsFixture() },
|
||
"out-domain": func() any { return outDomainFixture() },
|
||
"out-domain-settings": func() any { return outDomainSettingsFixture() },
|
||
"out-app": func() any { return outAppFixture() },
|
||
"out-app-created": func() any { return outAppCreatedFixture() },
|
||
"out-domain-delete": func() any { return outDomainDeleteFixture() },
|
||
|
||
"out-log": func() any { return outLogFixture() },
|
||
"out-message": func() any { return outMessageFixture() },
|
||
"dmarc": func() any { return dmarcHubFixture() },
|
||
"dmarc-domain": func() any { return dmarcDomainFixture() },
|
||
"dmarc-report": func() any { return dmarcReportFixture() },
|
||
}
|
||
|
||
// admin is the signed-in global administrator every fixture is rendered for.
|
||
func admin() Meta { return Meta{User: "admin", IsGlobal: true} }
|
||
|
||
// overviewFixture is the mockup's Overview: one certificate warning among six
|
||
// checks, three outbound domains and two inbound.
|
||
func overviewFixture() *Overview {
|
||
cards := []HealthCard{
|
||
{Name: "Machine", Value: "CPU 12%", Sub: "RAM 41%", Icon: "ti-cpu", Href: "/server/health#machine"},
|
||
{Name: "Processes", Value: "5 of 5 running", Sub: "all programs", Icon: "ti-server-cog", Href: "/server/health#processes"},
|
||
{Name: "TLS certificate", Value: "Expires in 12 days", Sub: "mail.example.org", Icon: "ti-certificate", Href: "/server/health#certificate", Level: LevelWarn},
|
||
{Name: "Queue", Value: "Empty", Sub: "outbound", Icon: "ti-stack-2", Href: "/outbound/queue"},
|
||
{Name: "Milter sockets", Value: "2 of 2 answering", Sub: "OpenDKIM, send-log", Icon: "ti-plug-connected", Href: "/server/health#sockets"},
|
||
{Name: "Reverse DNS", Value: "Forward = reverse", Sub: "mail.example.org", Icon: "ti-arrows-exchange", Href: "/server/health#hostname"},
|
||
}
|
||
o := NewOverview(admin(), "mail.example.org", cards, time.Date(2026, 10, 9, 14, 2, 31, 0, time.UTC), 30, true)
|
||
dns := func(dkim, spf, dmarc string) []Tag {
|
||
return []Tag{{Status: dkim, Label: "DKIM"}, {Status: spf, Label: "SPF"}, {Status: dmarc, Label: "DMARC"}}
|
||
}
|
||
o.OutboundRows = []OverviewDomain{
|
||
{Name: "example.org", Href: "/outbound/domains/1", DNS: dns("ok", "error", "ok"), Apps: 2, Messages: FormatMessages(1284)},
|
||
{Name: "shop.example.org", Href: "/outbound/domains/2", DNS: dns("ok", "ok", "ok"), Apps: 1, Messages: FormatMessages(18920)},
|
||
{Name: "notify.acme.io", Href: "/outbound/domains/3", DNS: dns("ok", "ok", "warn"), Apps: 3, Messages: FormatMessages(402)},
|
||
}
|
||
o.InboundRows = []OverviewInbound{
|
||
{Name: "lists.example.org", Href: "/inbound/domains/1", MX: Tag{Status: "ok", Label: "MX"}, Upstream: "mx.internal:25"},
|
||
{Name: "acme.io", Href: "/inbound/domains/2", MX: Tag{Status: "ok", Label: "MX"}, Upstream: "10.0.4.12:2525"},
|
||
}
|
||
return o
|
||
}
|
||
|
||
// healthFixture is the mockup's Health: a healthy machine and five processes,
|
||
// a certificate close to expiry, the two milter sockets and a good reverse
|
||
// record. refresh renders it as the polled fragment.
|
||
func healthFixture(refresh bool) *Health {
|
||
h := NewHealth(admin(), "")
|
||
h.Refresh = refresh
|
||
h.Machine.End = Verdict("ok")
|
||
h.MachineRows = []MachineRow{
|
||
{Resource: "CPU", Gauge: &Gauge{Percent: 12, Text: "12%", Level: LevelOK}, Detail: []string{"2 cores · 2 threads"}},
|
||
{Resource: "Memory", Gauge: &Gauge{Percent: 41, Text: "41%", Level: LevelOK}, Detail: []string{"812.0 MiB used of 1.9 GiB"}},
|
||
{Resource: "Network", Usage: "↓ 18.0 KiB/s · ↑ 44.0 KiB/s", Detail: []string{"eth0: 2.1 GiB in, 6.4 GiB out"}},
|
||
}
|
||
h.Processes.End = Verdict("ok")
|
||
for i, name := range []string{"postfix", "opendkim", "journal-milter", "panel", "dmarc-ingest"} {
|
||
h.ProcessRows = append(h.ProcessRows, ProcessRow{Name: name, State: Tag{Status: "ok", Label: "running"},
|
||
Detail: "pid " + strconv.Itoa(41-i) + ", uptime 18 days"})
|
||
}
|
||
h.Certificate.End = Verdict("warn")
|
||
h.CertFacts = []Fact{
|
||
{Label: "Expires", Value: Plain("2026-10-21 09:14 UTC"), Mono: true},
|
||
{Label: "Names", Value: Plain("mail.example.org"), Mono: true},
|
||
}
|
||
h.CertDetail, h.CertProblem = "Expires in 12 day(s). Check that renewal on the host still works.", true
|
||
h.Sockets.End = Verdict("ok")
|
||
h.SocketRows = []SocketRow{
|
||
{Name: "OpenDKIM", Path: "inet:127.0.0.1:8891", State: Tag{Status: "ok"}},
|
||
{Name: "send-log", Path: "unix:/run/selfpost/journal.sock", State: Tag{Status: "ok"}},
|
||
}
|
||
h.Hostname.End = Verdict("ok")
|
||
h.HostFacts = []Fact{
|
||
{Label: "Hostname", Value: Plain("mail.example.org"), Mono: true},
|
||
{Label: "Lookup", Value: Rich("→ 203.0.113.25", Br(), "← mail.example.org"), Mono: true},
|
||
}
|
||
return h
|
||
}
|
||
|
||
// accountFixture is the mockup's Account: the global administrator, their
|
||
// e-mail as the DMARC default, and the authorization record that default needs.
|
||
func accountFixture() *Account {
|
||
m := admin()
|
||
a := NewAccount(m, "global", true, "mix@example.org")
|
||
a.Username, a.Email = "admin", "mix@example.org"
|
||
a.DMARCSelected = DMARCChoiceAccount
|
||
a.WithDomainUse(2, 3)
|
||
return a.WithAuthorization("*._report._dmarc.example.org", "v=DMARC1;", "ok", "", nil)
|
||
}
|
||
|
||
// settingsFixture is the mockup's Settings: thirty days of log, a limit of 600
|
||
// messages an hour.
|
||
func settingsFixture() *Settings {
|
||
return NewSettings(admin(), "30", FormatRate(600, 3600))
|
||
}
|
||
|
||
// outDomainsFixture is the mockup's Outbound domains: three domains, the first
|
||
// with a wrong SPF record and the last with a weak DMARC one.
|
||
func outDomainsFixture() *OutDomains {
|
||
dns := func(dkim, spf, dmarc string) []Tag {
|
||
return []Tag{{Status: dkim, Label: "DKIM"}, {Status: spf, Label: "SPF"}, {Status: dmarc, Label: "DMARC"}}
|
||
}
|
||
row := func(id, name string, tags []Tag, selector string, apps int, total, peak int64) OutDomainRow {
|
||
return OutDomainRow{Name: name, Href: "/outbound/domains/" + id, DeleteHref: "/outbound/domains/" + id + "/delete",
|
||
DNS: tags, Selector: selector, Apps: apps, Activity: FormatActivity(total, peak)}
|
||
}
|
||
return NewOutDomains(admin(), true, 30).WithRows([]OutDomainRow{
|
||
row("1", "example.org", dns("ok", "error", "ok"), "sp2026", 2, 1284, 96),
|
||
row("2", "shop.example.org", dns("ok", "ok", "ok"), "sp2026", 1, 18920, 410),
|
||
row("3", "notify.acme.io", dns("ok", "ok", "warn"), "sp2025", 3, 402, 22),
|
||
})
|
||
}
|
||
|
||
// outDomainFixture is the mockup's domain page: the DKIM and DMARC records
|
||
// published, the SPF one pointing at the wrong server, two applications.
|
||
func outDomainFixture() *OutDomain {
|
||
p := NewOutDomain(admin(), 1, "example.org", true)
|
||
p.WithStats(1284, 96, "1.8", 30)
|
||
p.WithRecords([]Record{
|
||
DKIMRecord("sp2026", "sp2026._domainkey.example.org",
|
||
"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB",
|
||
DNSCheck{Status: "ok"}),
|
||
SPFRecord("example.org", "v=spf1 ip4:203.0.113.25 ~all", DNSCheck{Status: "error",
|
||
Detail: "The published record does not authorize 203.0.113.25. Add ip4:203.0.113.25 before ~all.",
|
||
Found: []string{"v=spf1 include:_spf.google.com ~all"}}),
|
||
DMARCRecord(DMARCRecordInput{Host: "_dmarc.example.org", Value: "v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org",
|
||
Check: DNSCheck{Status: "ok"}, Source: "admin's default", SettingsHref: "/outbound/domains/1/settings",
|
||
ReportsHref: "/outbound/dmarc/domains/1"}),
|
||
}, "3 min ago")
|
||
p.WithApplications([]OutAppRow{
|
||
{Login: "prod-server", Senders: []string{"*@example.org"}, Activity: FormatActivity(1102, 96),
|
||
Limits: []Tag{{Status: "ok", Label: "200 / h"}}, Edit: "/outbound/domains/1/applications/1"},
|
||
{Login: "alerts", Senders: []string{"alerts@example.org", "noc@example.org"}, Activity: FormatActivity(182, 14),
|
||
Limits: []Tag{{Label: "domain"}, {Status: "ok", Label: "2 IPs"}}, Edit: "/outbound/domains/1/applications/2"},
|
||
})
|
||
p.WithConnection("mail.example.org", true)
|
||
p.WithSeeAlso("/outbound/log?domain=example.org", "/outbound/dmarc/domains/1")
|
||
p.WithRateLimit(true, false)
|
||
return p
|
||
}
|
||
|
||
// outDomainSettingsFixture is the mockup's Domain settings: reports go to the
|
||
// default of the signed-in administrator, the rate limit is automatic.
|
||
func outDomainSettingsFixture() *OutDomainSettings {
|
||
p := NewOutDomainSettings(admin(), 1, "example.org", 2, true)
|
||
p.WithReportAddress([]Option{
|
||
{Value: "inherit", Label: "admin's default — mix@example.org"},
|
||
{Value: "hosted", Label: "SelfPost hosted (example.org@dmarc.mail.example.org)"},
|
||
{Value: "none", Label: "No aggregate reports"},
|
||
{Value: "custom", Label: "Custom address"},
|
||
}, "inherit", "", Rich("A default belongs to a user and is set under their ", Link("/account#dmarc", "Account"),
|
||
"; a domain follows one named user, so two people sharing a domain never pull it two ways. Changing this changes the DMARC record to publish."))
|
||
p.WithExport(12)
|
||
return p.WithRateLimit(DomainRateLimit{
|
||
Active: true, Auto: true, MaxMessages: "288", Window: "3600", Multiplier: "2.5",
|
||
Computed: "288", Peak: 96, Updated: "2026-10-09 03:00 UTC",
|
||
L1Messages: 600, L1Window: 3600, MinMultiplier: "1.5", MaxMultiplier: "5", DefaultMultiplier: "2.5",
|
||
})
|
||
}
|
||
|
||
// outAppFixture is the mockup's application form, filled with prod-server: any
|
||
// address of the domain, no client IPs, a manual limit of 200 an hour.
|
||
func outAppFixture() *OutApp {
|
||
p := NewOutApp(admin(), 1, "example.org", "prod-server")
|
||
p.WithApplication(1, 1, 1102, 96, "1.5", 30)
|
||
return p.WithForm(OutAppForm{
|
||
Login: "prod-server", Mode: AddressWildcard, LimitMode: LimitManual, MaxMessages: "200", Window: "3600", Multiplier: "2.5",
|
||
}, OutAppState{Limit: true}, AppRateLimit{L1Messages: 600, L1Window: 3600, MinMultiplier: "1.5", MaxMultiplier: "5",
|
||
DefaultMultiplier: "2.5", DomainLimit: "288 / h", Peak: 96})
|
||
}
|
||
|
||
// outAppCreatedFixture is the mockup's password page: a new password for
|
||
// prod-server, which may send as the whole domain.
|
||
func outAppCreatedFixture() *OutAppCreated {
|
||
return NewOutAppCreated(admin(), 1, "example.org", "prod-server", "kT7v-Qm2x-Lp9d-Ue4s-Hn6b-Rz3w", false,
|
||
"mail.example.org", true, []string{"*@example.org"})
|
||
}
|
||
|
||
// outDomainDeleteFixture is the mockup's confirmation: the domain has the two
|
||
// applications that go with it.
|
||
func outDomainDeleteFixture() *OutDomainDelete {
|
||
return NewOutDomainDelete(admin(), 1, "example.org", []string{"prod-server", "alerts"})
|
||
}
|
||
|
||
// outLogFixture is the mockup's Outbound log: six messages of three domains on
|
||
// the first of 412 pages, one deferred, one bounced, one refused by a rate limit.
|
||
func outLogFixture() *OutLog {
|
||
p := NewOutLog(admin(), 30, true,
|
||
[]string{"example.org", "shop.example.org", "notify.acme.io"}, []string{"prod-server", "alerts"}, "", "")
|
||
row := func(id int64, at, from, to, subject, status string) OutLogRow {
|
||
return OutLogRow{Time: at, From: from, To: to, Subject: subject, Status: Tag{Status: status},
|
||
Href: p.DetailHref(id, 1)}
|
||
}
|
||
return p.WithRows([]OutLogRow{
|
||
row(184223, "09-21 14:01:52", "shop@shop.example.org", "m.keller@gmx.de", "Your order #48213 has shipped", "sent"),
|
||
row(184222, "09-21 14:01:50", "shop@shop.example.org", "anna.lind@outlook.com", "Your order #48212 has shipped", "sent"),
|
||
row(184220, "09-21 13:58:07", "alerts@example.org", "noc@example.org", "[FIRING] disk usage above 90 % on db-2", "deferred"),
|
||
row(184211, "09-21 13:41:19", "no-reply@notify.acme.io", "j.doe@nonexistent.example", "Reset your password", "bounced"),
|
||
row(184209, "09-21 13:40:02", "prod@example.org", "billing@partner.example", "Invoice 2026-0917", "rejected"),
|
||
row(184198, "09-21 13:12:44", "shop@shop.example.org", "p.novak@seznam.cz", "We received your return request", "sent"),
|
||
}, 1, 412)
|
||
}
|
||
|
||
// outMessageFixture is the mockup's message page: a deferred alert, with the
|
||
// four lines Postfix and OpenDKIM wrote about it.
|
||
func outMessageFixture() *OutMessage {
|
||
at := time.Date(2026, 9, 21, 13, 58, 7, 0, time.UTC)
|
||
p := NewOutMessage(admin(), MessageInput{
|
||
ID: 184220, QueueID: "4XcB7k2Jm9z1", Domain: "example.org", DomainHref: "/outbound/domains/1",
|
||
App: "alerts", AppHref: "/outbound/domains/1/applications/2",
|
||
From: "alerts@example.org", To: "noc@example.org", Subject: "[FIRING] disk usage above 90 % on db-2", Status: "deferred",
|
||
Accepted: at, Reported: at.Add(2 * time.Second), BackHref: "/outbound/log",
|
||
})
|
||
p.WithHistory([]Step{
|
||
{Time: StepTime(at), Strong: "Accepted and queued", Text: Plain(" — Postfix accepted the message over an authenticated submission and the journal-milter recorded it.")},
|
||
{Time: StepTime(at.Add(2 * time.Second)), Strong: "Deferred, will be retried", Level: LevelWarn,
|
||
Text: Plain(" — the receiving server could not take the message yet. Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days.")},
|
||
{Time: StepTime(time.Time{}), Strong: "Waiting for a delivery report", Level: LevelPending,
|
||
Text: Plain(" — the next attempt is made by Postfix on its own; the queue shows what it is still holding.")},
|
||
})
|
||
return p.WithDeliveryLog([]LogLine{
|
||
{Time: "13:58:07", Text: "postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts"},
|
||
{Time: "13:58:07", Text: "postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=<a81f0c@alertmanager>"},
|
||
{Time: "13:58:07", Text: "opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org)"},
|
||
{Time: "13:58:09", Level: "warn", Text: "postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, relay=mx1.example.org[192.0.2.10]:25, delay=1.9, status=deferred (host mx1.example.org said: 451 4.7.1 Greylisted, try again in 5 minutes)"},
|
||
}, "")
|
||
}
|
||
|
||
// dmarcHubFixture is the mockup's DMARC hub: ingest healthy, four recent
|
||
// reports, one with failures.
|
||
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,
|
||
})
|
||
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,
|
||
Window: window, Pass: pass, Fail: fail, Href: DMARCReportHref(id)}
|
||
}
|
||
return p.WithReports([]DMARCReportRow{
|
||
row(41, "09-21 06:12", "example.org", "google.com", "20 Sep", 412, 0),
|
||
row(40, "09-21 04:40", "shop.example.org", "Outlook.com", "20 Sep", 1903, 7),
|
||
row(39, "09-20 23:58", "example.org", "Yahoo", "19 Sep", 38, 0),
|
||
row(38, "09-20 06:09", "notify.acme.io", "google.com", "19 Sep", 61, 0),
|
||
})
|
||
}
|
||
|
||
// dmarcDomainFixture is the mockup's domain page: seven days of shop.example.org
|
||
// with one source that is not this relay failing, and three reports.
|
||
func dmarcDomainFixture() *DMARCDomain {
|
||
p := NewDMARCDomain(admin(), 2, "shop.example.org", true, 11204, 41, 7,
|
||
"A third-party source is not aligned. Do not tighten p= until that sender is fixed or removed.")
|
||
row := func(id int64, at, reporter, window string, pass, fail int) DMARCReportRow {
|
||
return DMARCReportRow{Received: at, Reporter: reporter, Window: window, Pass: pass, Fail: fail, Href: DMARCReportHref(id)}
|
||
}
|
||
return p.WithSources([]DMARCSourceRow{
|
||
{Source: "203.0.113.25", ThisRelay: true, Pass: 11204, Fail: 0, Disposition: "none"},
|
||
{Source: "198.51.100.44", Pass: 0, Fail: 41, Disposition: "none"},
|
||
}).WithReports([]DMARCReportRow{
|
||
row(40, "09-21 04:40", "Outlook.com", "20 Sep", 1903, 7),
|
||
row(37, "09-21 02:15", "google.com", "20 Sep", 6410, 0),
|
||
row(35, "09-20 04:38", "Outlook.com", "19 Sep", 1877, 34),
|
||
})
|
||
}
|
||
|
||
// dmarcReportFixture is the mockup's report: Outlook.com's day for
|
||
// shop.example.org, two sources, one of them failing both checks.
|
||
func dmarcReportFixture() *DMARCReport {
|
||
return NewDMARCReport(admin(), ReportInput{
|
||
ID: 40, Reporter: "Outlook.com", ReportID: "5f1c9a0e7b2d4e61a3", Domain: "shop.example.org", DomainID: 2,
|
||
Pass: 1903, Fail: 7, Window: "20 Sep", Period: "20 Sep 00:00 – 21 Sep 00:00 UTC", Received: "2026-09-21 04:40 UTC",
|
||
PolicyP: "none", PolicySP: "none", PolicyPct: 100, PolicyADKIM: "r", PolicyASPF: "r",
|
||
Recipient: "dmarc@mail.example.org", Hub: true,
|
||
}).WithRecords([]DMARCRecordRow{
|
||
{Source: "203.0.113.25", ThisRelay: true, Count: 1903, Disposition: "none", SPF: "pass", DKIM: "pass", HeaderFrom: "shop.example.org"},
|
||
{Source: "198.51.100.44", Count: 7, Disposition: "none", SPF: "fail", DKIM: "fail", HeaderFrom: "shop.example.org"},
|
||
})
|
||
}
|