Repository navigation
Releases: dmarc-analyzer-net/DmarcAnalyzerApp
Release list
v0.15.0 — console branding, RDS IAM login, chart resource requests
Install
curl -fsSL -o compose.yml https://raw.githubusercontent.com/dmarc-analyzer-net/DmarcAnalyzerApp/main/deploy/compose.yml
echo "DMARC_ENCRYPTION_KEY=$(openssl rand -base64 32)" > .env
docker compose up -dghcr.io/dmarc-analyzer-net/dmarc-analyzer:0.15.0dmarcanalyzernet/dmarc-analyzer:0.15.0- Helm:
oci://ghcr.io/dmarc-analyzer-net/charts/dmarc-analyzer --version 0.15.0
What's in it
-
Your brand on the console, not just the digest. The
Branding__*
settings that 0.14.0 put on the digest now brand the console too. That is
what a client sees through aclient_viewerlogin.- The logo replaces ours on the sign-in page and in the header. A name with
no logo is shown as text, without our shield. Tab titles read
Domains · <your name>. - The accent colours primary buttons, links, focus rings and the active
navigation item. Any six-digit colour stays legible: text on a button
turns dark when white would not read, and brand-coloured text is darkened
until it clears WCAG AA. - A small "Powered by DMARC Analyzer" line appears once you brand the
instance.Branding__HidePoweredBy=trueremoves it. - A new public endpoint,
GET /api/v1/branding, serves the brand to the
sign-in page before anyone logs in.
- The logo replaces ours on the sign-in page and in the header. A name with
-
Postgres on AWS without a password.
Database__IamAuth=truelogs in
to Amazon RDS, Aurora or RDS Proxy with a short-lived IAM token from the
AWS credential chain (ECS task role, EC2 instance profile, EKS with IRSA).
TLS is required and the server is verified, and you choose the CA. The
recipe for Compose and Helm is
docs/ops/aws-rds-iam.md. -
The Helm chart sets resource requests. Every container now has a
memory and CPU request, and none has a limit:Container Memory CPU app and worker 384Mi 100m bundled Postgres 256Mi 100m migration Job 128Mi 50m database wait 64Mi 50m Requests let the scheduler reserve room for each pod and make it a
lower-priority candidate for eviction when a node runs short of memory. -
A stronger focus ring. Focused buttons and inputs now get a solid
outline. It used to be a faint tint, at 1.4:1 contrast on a white card. -
Action errors stay visible. A failed save, delete or status change
used to be wiped by the reload that followed it. It now stays until your
next action. -
Frontend moved to Tailwind CSS 4. No visual change is intended
beyond the focus ring. It is also the only fix for GHSA-rj75-hqrm-r3gf in
the frontend build. -
Security and dependencies: MailKit 4.18.2, a
zodadvisory in the
frontend toolchain, and Node 24 for the frontend build stage. Node only
builds the bundle, so the runtime image is unchanged. Routine patch and
minor dependency updates.
Not in this release
Client magic-link sharing, CSV/JSON export, branded PDF reports, per-client
branding, and M365/Google Workspace connectors are still not implemented.
See the backlog.
Upgrade notes
No migrations. The schema is the same as 0.14.0.
Branded instances look different on first load. If you set
Branding__* for the digest, the console now uses it too. The
"Powered by" line comes with it unless you set Branding__HidePoweredBy.
Unbranded instances look as before, apart from the stronger focus ring.
Helm: the new requests apply on upgrade. On a cluster where no node
has 384Mi of memory left to request, the app pod now stays Pending
rather than being scheduled. Raise app.resources for many
mailboxes or a large backfill. To drop a request, set requests: null;
resources: {} does not work, because Helm merges maps.
New optional configuration. Everything has a default and nothing is
required: Branding__HidePoweredBy, Database__IamAuth, and in the chart
waitForDatabase.resources. See the
configuration reference.
v0.14.0 — digest rework, RFC 9990 report fields
Install
curl -fsSL -o compose.yml https://raw.githubusercontent.com/dmarc-analyzer-net/DmarcAnalyzerApp/main/deploy/compose.yml
echo "DMARC_ENCRYPTION_KEY=$(openssl rand -base64 32)" > .env
docker compose up -dghcr.io/dmarc-analyzer-net/dmarc-analyzer:0.14.0dmarcanalyzernet/dmarc-analyzer:0.14.0
What's in it
- The monthly digest is now worth reading. It used to be one plain-text
mail per client, sent to every recipient, with ragged columns and a "needs
attention" list that had no threshold. Now:- HTML with a plain-text part, and the verdict in the subject line
([DMARC] September 2026: 1 of 7 clients need attention). - Attention triggers you can tune: a low pass rate, a drop on the
previous month, and reports that stopped (a domain that is always
quiet is not flagged). Domains with too little mail aren't judged on
their rate. A domain that has held a high pass rate atp=noneor
p=quarantinelong enough gets a suggestion to tighten. Instance
defaults come fromDigest__*; each client can override them on the
Clients page. - Failing sources grouped by IP across the client's domains, with PTR
hostname and a "new this month" flag. Every domain listed with its
effective policy. - Your agency's brand, not ours:
Branding__Name,
Branding__LogoUrl,Branding__AccentColor.
- HTML with a plain-text part, and the verdict in the subject line
- Per-recipient routing. An address covering several clients sets each
client to roll-up, own mail or off, and chooses what happens to
clients added later. The roll-up is one mail with an overview of every
client and detail only where there are findings. Off also stops that
client's alerts to that address, so a contact covering two of your clients
never hears about a third. The Notifications page previews every mail an
address would get, rendered. - RFC 9990 aggregate reports are read properly. The format,
np,
testing,discovery_method,generatorand reporter-sent errors are
stored per report, and a conformant RFC 9990 report no longer counts as
having validation errors. - The parser's own notes are kept and shown. Repairs and schema problems
found while reading a report are stored with a severity and appear on the
domain page, instead of being discarded. - Record inspection compares
npandt, and an absentpct(RFC 9990
dropped it from reports) is "not reported" rather than a false mismatch
against an assumed 100. A record that still publishespct=gets a note
that RFC 9989 replaced it witht. - Backfill from the report archive. If you run with
Backup__Bucket
set, a new card on the Backup page re-reads archived report mail to fill
the new fields on reports ingested before this release. It is started by
hand, resumable, and tells you up front how many reports it cannot fill. - Security:
System.Security.Cryptography.Pkcspinned to 10.0.13
(GHSA-846f-f764-76mx, GHSA-2fmv-rv2c-59x7, pulled in transitively via
MimeKit), plus a frontend build dependency fix (GHSA-68fv-2mgg-jv7q).
Routine patch/minor dependency updates.
Not in this release
Client magic-link sharing, CSV/JSON export, branded PDF reports, and
M365/Google Workspace connectors are still not implemented — see
the backlog.
Upgrade notes
Four new migrations, applied on startup as usual. They add nullable
columns and new tables; nothing rewrites existing report data.
Visible changes on the first digest after upgrading:
- Agency-wide recipients get one roll-up instead of one mail per client.
That is the point of the change, but it will look different. Check the
Notifications page if you want some clients sent separately or not at
all. - Nothing is re-sent: a month already delivered under the old scheme still
counts as delivered. - Clients with no domains no longer get a digest.
API change: GET /api/v1/admin/digest/preview now returns
{ "mails": [...] } (subject, text and HTML per mail) instead of
{ summary, body }, and also accepts recipientId.
Reports ingested before 0.14.0 show their RFC 9990 fields and parser
notes as "not recorded" until backfilled. Without a report archive
(Backup__Bucket), they stay that way; new reports get them on arrival.
New optional configuration — all have defaults, nothing is required:
Digest__LowCompliancePercent, Digest__ComplianceDropPoints,
Digest__MinMessages, Digest__NoReports, Digest__TightenAfterDays,
Digest__TightenCompliancePercent, Branding__Name, Branding__LogoUrl,
Branding__AccentColor. See the
configuration reference.
v0.13.2 — registrable-domain grouping fix
Install
curl -fsSL -o compose.yml https://raw.githubusercontent.com/dmarc-analyzer-net/DmarcAnalyzerApp/main/deploy/compose.yml
echo "DMARC_ENCRYPTION_KEY=$(openssl rand -base64 32)" > .env
docker compose up -dghcr.io/dmarc-analyzer-net/dmarc-analyzer:0.13.2dmarcanalyzernet/dmarc-analyzer:0.13.2- Helm:
oci://ghcr.io/dmarc-analyzer-net/charts/dmarc-analyzer --version 0.13.2
What's in it
A small patch release: one UI fix and dependency updates.
- Domains are grouped only under a registrable domain. The domain list
grouped by parent domain, but accepted any remainder with two labels, so
example.co.ukproduced a group headedco.ukand unrelated domains under a
multi-label public suffix were bundled under a heading for the suffix. Grouping
now resolves against the Public Suffix List. - Dependency updates. Patch and minor bumps for the frontend (Vite,
lucide-react) andAWSSDK.S3.
Not in this release
Client magic-link sharing, CSV/JSON export, branded PDF reports, and
M365/Google Workspace connectors are still not implemented — see
the backlog.
Upgrade notes
No migrations and no configuration changes. Users pinned to 0.13.1 can move to
0.13.2 by changing the tag.
v0.13.1 — S3 IRSA fix, locale-independent backup keys
Install
curl -fsSL -o compose.yml https://raw.githubusercontent.com/dmarc-analyzer-net/DmarcAnalyzerApp/main/deploy/compose.yml
echo "DMARC_ENCRYPTION_KEY=$(openssl rand -base64 32)" > .env
docker compose up -dghcr.io/dmarc-analyzer-net/dmarc-analyzer:0.13.1dmarcanalyzernet/dmarc-analyzer:0.13.1- Helm:
oci://ghcr.io/dmarc-analyzer-net/charts/dmarc-analyzer --version 0.13.1
What's in it
A patch release: one fix that matters to some operators, and maintenance.
- S3 report sources and backup offload now work with IRSA on EKS. With empty
key fields the app uses the ambient AWS credential chain, which under IRSA
resolves to web identity credentials. The SDK loadsAWSSDK.SecurityTokenby
reflection to call STS, and the image did not ship that assembly, so every
sync run failed with "Assembly AWSSDK.SecurityToken could not be found". It
is now included. - Backup object keys no longer depend on the host's locale. They are
storage identifiers, and under a non-Gregorian-calendar locale such as
th-THthe year in a key would have come out differently. Keys now format
with the invariant culture. The default image sets noLANG, so it already
ran invariant; this matters if you set one. Digest and alert text still
follow the host culture on purpose. - Dependency updates. Patch and minor bumps for backend and frontend
packages. This clears sixnpm auditadvisories, all in dev and build
tooling that does not ship in the image. - Internal: alignment tags are read from DmarcRua's properties again (no
behaviour change), and CI and the test suite were tidied.
Not in this release
Client magic-link sharing, CSV/JSON export, branded PDF reports, and
M365/Google Workspace connectors are still not implemented — see
the backlog.
Upgrade notes
No migrations and no configuration changes. Users pinned to 0.13.0 can move to
0.13.1 by changing the tag.
v0.13.0 — TLS-RPT unnested from MTA-STS
Install
curl -fsSL -o compose.yml https://raw.githubusercontent.com/dmarc-analyzer-net/DmarcAnalyzerApp/main/deploy/compose.yml
echo "DMARC_ENCRYPTION_KEY=$(openssl rand -base64 32)" > .env
docker compose up -dghcr.io/dmarc-analyzer-net/dmarc-analyzer:0.13.0dmarcanalyzernet/dmarc-analyzer:0.13.0
What's in it
- TLS-RPT is its own card, and the domain's
_smtp._tlsrecord is now
looked up rather than inferred from ingested reports. MTA-STS and TLS-RPT
are independent mechanisms — either can be published without the other —
so nesting one under the other implied a dependency that doesn't exist. An
empty TLS-RPT panel used to read as "no reporters yet"; for a domain with
no_smtp._tlsrecord, the count is structurally zero and stays that way,
and the card now says so. The record parser follows RFC 8460 closely,
including which malformed records a strict reporter would discard versus
which still work. - Source tables with hundreds of entries now get hostnames past the first
100. Reverse-DNS lookups were capped at the batch endpoint's own
per-request limit and never paged further, so a domain with over a
thousand sources only had hostnames on the first page. Rows are now
resolved as they scroll into view. A second, IPv6-only bug is fixed
alongside it: an uppercase or non-compressed address could be resolved but
never matched back to its row. - A null envelope sender (
<>) now reads as "null sender<>" instead
of the bare RFC 5321 token, with a note that this is what bounces and
delivery status notifications look like. A related display bug is fixed
too: sources with no envelope sender at all could be pushed out of the
per-source top-ten list without any sign a value had been displaced. - A misconfigured boolean or number now names the actual environment
variable and a valid value, instead of an unhandled exception naming the
setting in its internalAuth:Oidc:Enabledform. Checked across all
APP_MODEvalues at startup, so a typo a console pod would reject can't
let amigrateJob start instead. - Security: .NET bumped to 10.0.11, the August 2026 servicing release,
closing a diagnostics IPC socket permission bypass and a WebSocket DoS. - DmarcRua upgraded to 2.1.0, fixing a null-reference crash on aggregate
reports that omit<adkim>/<aspf>.
Not in this release
Client magic-link sharing, CSV/JSON export, branded PDF reports, and
M365/Google Workspace connectors are still not implemented — see
the backlog.
Upgrade notes
No configuration changes and no new migrations.
v0.12.1 — drop empty aggregate report records, fix table visuals
Install
curl -fsSL -o compose.yml https://raw.githubusercontent.com/dmarc-analyzer-net/DmarcAnalyzerApp/main/deploy/compose.yml
echo "DMARC_ENCRYPTION_KEY=$(openssl rand -base64 32)" > .env
docker compose up -dghcr.io/dmarc-analyzer-net/dmarc-analyzer:0.12.1dmarcanalyzernet/dmarc-analyzer:0.12.1
What's in it
- Empty aggregate report records are no longer stored. wp.pl, o2.pl and
similar reporters sometimes send a whole report whose one row is blank
throughout — no source IP, zero count, no policy or auth results. That row
was being kept, which inflated a domain's source count with a fake
"reporter" and broke source-detail (which needs an IP to query by). The
report itself is still ingested — a reporter that saw nothing did still
report, and its record count still reflects that — it just no longer leaves
a blank row behind. A blank IP with a real count is left untouched, since
that is mail that genuinely arrived. - Fixed table corner-clipping on the Domains, Dashboard and Sources
screens. Card backgrounds now clip to their rounded corners instead of
poking sharp square corners past them, and the Sources table's expanded
hostname row no longer leaves a blank white gap in the unused columns.
Not in this release
Client magic-link sharing, CSV/JSON export, branded PDF reports, and
M365/Google Workspace connectors are still not implemented — see
the backlog.
Upgrade notes
No configuration changes and no new migrations. Existing report rows that
match the dropped pattern are not retroactively removed — this only changes
what gets stored going forward.
v0.12.0 — POP3 mailboxes and S3 buckets as report sources
Reports no longer have to arrive over IMAP. POP3 is polled — properly this time, by code that exists rather than by a protocol value nothing read — and an S3 bucket can be a report source in its own right, for reports a provider or an SES rule drops in object storage instead of a mailbox. All three run one shared pass, so what a mailbox source already gets, a bucket gets too: the same drain budget, checkpoints, archive-before-parse, run rows and retention deletion.
📖 Documentation · ⚙️ Configuration reference · ⬆️ Upgrading · 📬 Connecting a source
Install
ghcr.io/dmarc-analyzer-net/dmarc-analyzer:0.12.0 # canonical, no pull rate limits
dmarcanalyzernet/dmarc-analyzer:0.12.0 # Docker Hub mirror
oci://ghcr.io/dmarc-analyzer-net/charts/dmarc-analyzer --version 0.12.0
What's in it
POP3 mailboxes are polled. For the mailbox that offers nothing else — a small host, a legacy account, a provider that never enabled IMAP. Port 995 with TLS, checkpointed on the message's UIDL, and everything downstream of the transport is the code IMAP already used.
Two things POP3 genuinely cannot do as well, both stated rather than hidden:
- UIDL is required. RFC 1939 makes it optional, and without it nothing identifies a message across sessions — there would be no durable position, so every pass would re-read the whole mailbox for ever. The sync refuses instead, and says so on the source's
mailbox-healthrow. - Retention deletion costs more. With no server-side date search the pass reads every message's headers to find what has aged out, and logs how many it read. IMAP asks the server. Deletion also only takes effect when the session ends with
QUIT, which is the protocol, not a choice.
If the checkpointed message disappears from the mailbox — deleted by hand, or by another client reading the same account — there is no position left to recover and the next pass re-reads everything. Deduplication keeps the data correct; the cost is the work, and the worker logs a warning rather than leaving it looking like a loop.
An S3 bucket is a report source. A bucket and a key prefix instead of a host and a port, and objects that may be bare report files or whole RFC822 messages — each classified on its own content, so a bucket filled by an SES delivery rule and one filled by a provider both work with no configuration saying which is which.
- Credentials are per source, and optional. Access key id and secret, or leave both empty and the ambient chain supplies them — an instance role, or IRSA on Kubernetes, which is the better answer where it exists. Half a credential is refused at create time rather than failing at the first poll. A read-only source needs exactly
s3:ListBucketands3:GetObject;s3:DeleteObjectonly if you turn retention deletion on. - Anything S3-compatible works. Set an endpoint for MinIO, R2 or B2; path-style addressing is the default because it is what those need and what AWS tolerates.
- The checkpoint is a (last-modified, key) pair, not a key. S3's own
StartAfterresumes on key order, which is the obvious implementation and silently wrong: nothing makes a key sort in arrival order, so a bucket using hashed or random prefixes would have every new object sorting below the checkpoint skipped for ever. - A pass lists at most 100,000 keys, and a prefix bigger than that is covered across several passes rather than truncated — each pass resumes its listing where the last one stopped and starts a fresh lap at the end. Set a prefix anyway: it is what separates a cheap poll from reading a data lake.
Because the classifier reads each object on its own terms, a source pointed at this application's own report-mail archive prefix replays it. That is not a restore feature — no scoping, no preview, and it goes through live routing and deduplication — but it does mean the archive is no longer only evidence.
One pass behind all three. The protocol-specific part of a sync turns out to be small: addressing, the checkpoint, how you resume, and how you delete. Everything valuable — the drain budget, batched checkpoints, archive-before-parse, the partial-versus-failed distinction, the run rows the console draws, retention deletion with its grace margin and legal-hold suspension — is now shared behind one transport interface. Adding the next protocol is a transport, not another branch in the sync service.
The console shows whichever checkpoint a source has. The Checkpoint column reads the IMAP UID, the POP3 UIDL, or the S3 object key, so a POP3 or S3 source no longer reads as "never synced" while working. Protocol-specific fields appear and disappear with the protocol you pick, and the port follows it unless you typed something non-standard.
Verified against real servers, not only mocks. The POP3 work is covered by integration tests against GreenMail and a real database, and the S3 work against MinIO — including a test that shrinks the per-pass key cap and proves a capped listing eventually covers the whole prefix. That is deliberate: the first POP3 attempt validated a protocol value while nothing read it, and the gap was between the pieces, where no unit test was looking.
Six npm audit advisories closed in the frontend build tooling — postcss, vite, js-yaml, nanoid, brace-expansion, @babel/core, all within existing semver ranges. Dev and build dependencies only; nothing shipped in the image changes.
Not in this release
- No OAuth for mailboxes. POP3 widens which servers work, but Microsoft 365 tenants with basic auth disabled are still out — that needs OAuth via Microsoft Graph, which is not here.
- No supervised replay from the archive. Pointing an
s3source at the archive prefix re-ingests it, but there is no scoping to one client, domain or window, and no preview of what a run would ingest. - No view of when a pushed source last received anything.
mailbox-healthstill deliberately excludesapisources, because a pushed source has no sync run and no checkpoint; including it would leave a row permanently reading "never synced" while working perfectly. - S3 event notifications are not supported — a bucket is polled on the same schedule as a mailbox, so a report can sit for up to one
Worker__ScheduleIntervalSeconds(an hour, shipped) before it is read. - A large backfill can still fill the disk with the application's own log, because every statement is logged at the default level. Cap your log driver before a bulk import, whether it reads a mailbox or a bucket.
- A manual "Sync now" is still tied to the HTTP request — the checkpoint means nothing is lost if the browser gives up, but a large backfill is better driven from
curlor left to the scheduler. - No password recovery, no admin-driven identity linking, and no per-domain trust list for
TrustUnverifiedEmail— all as described in 0.10.0.
Upgrade notes
Nothing is renamed and nothing is removed. A script written against 0.11.1 keeps working; every new field is additive and null on the source types that don't have one.
Read this one if you have ever created a POP3 source. pop3 was accepted for a long time while nothing polled it, and rows from that period may still exist. They start syncing on the next pass after this upgrade, which is what they were always meant to do — and has two consequences worth checking before you upgrade rather than after:
- The first pass is a full backfill of that mailbox, oldest first, in batches of
Worker__MaxMessagesPerSyncuntil the mailbox is drained orWorker__MailboxDrainBudgetMinutesstops the pass. Expect real ingestion volume from a source that has produced nothing until now. - If that row also has
deleteAfterRetentionon, deletion becomes live for it. The usual hedges all apply — the widest retention window among the clients the source serves, plusWorker__MailboxRetentionGraceDayson top, legal hold suspending it entirely, and archive-before-delete when the report archive is on — but the pass now actually reaches this mailbox.GET /api/v1/admin/mailbox-retention/previewshows exactly what it would do, per source, without deleting anything. Check it first.
List what you have before upgrading:
docker compose exec -T postgres psql -U postgres -d dmarc_analyzer -c \
'select "Name", "Protocol", "IsActive", "DeleteAfterRetention" from report_source'The archive key layout gained two shapes. With Backup__ArchiveReportMail on, the last key segment names the message in its own protocol's terms: <uidvalidity>-<uid> for IMAP as before, pop3-<uidl>, or s3-<key> — hashed to h-<sha256> when the name holds a character a key cannot take verbatim, which is the common case for S3 keys. Everything stays under the same date-partitioned prefix, so a lifecycle rule written against it still matches. Nothing existing is renamed or moved.
Three migrations, and the upgrade is fast regardless of data volume. The count moves 30 → 33: AddReportSourcePop3Checkpoint, AddS3ReportSource, AddS3ListingCursor. All three are additive — nullable columns on report_source, plus one boolean NOT NULL DEFAULT true, which PostgreSQL applies as metadata. None rewrites a table. Verified upgrading a populated 0.11.1 database in place on PostgreSQL 18: three migrations applied, the console healthy about nine seconds after the container started, zero restarts, no row touched, and a mailbox source created before the upgrade still decrypting its stored credential on the next sync.
v0.11.1 — push reports in, and stop trusting what arrives
External systems can now push DMARC reports straight in over HTTP, authenticated by a machine credential, instead of this app polling a mailbox for them. Decompression is bounded on both paths, a truncated report is refused rather than silently stored empty, and the entity that holds all this is renamed from "mailbox source" to "report source" — five breaking changes in all, listed under Upgrade notes.
📖 Documentation · ⚙️ Configuration reference · ⬆️ Upgrading
Install
ghcr.io/dmarc-analyzer-net/dmarc-analyzer:0.11.1 # canonical, no pull rate limits
dmarcanalyzernet/dmarc-analyzer:0.11.1 # Docker Hub mirror
oci://ghcr.io/dmarc-analyzer-net/charts/dmarc-analyzer --version 0.11.1
0.11.0 was tagged in error and is not a release. Its tag landed on the commit before its own version bumps, so the chart never published and the images that did report themselves as
0.10.0. Those images are public andlatestpointed at them briefly. If you pulled0.11.0orlateston 14 August, re-pull. There is no 0.11.0 chart and no 0.11.0 release notes. See Upgrade notes.
What's in it
Reports can be pushed in rather than polled for. Until now the only way a report reached this app was for it to open a mailbox over IMAP and read one. If something upstream already holds your reports — a mail gateway, an archive, another system that receives them on your behalf — it can now hand them over directly:
curl -X POST "$BASE/api/v1/reports" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/gzip' \
--data-binary @report.xml.gzThe bytes go through exactly the extractor, parsers and deduplication the mailbox loop uses, so the two paths cannot drift on what has already been ingested. A report source now carries a protocol of imap or api, and the design is in ADR 0010.
Machine credentials, issued from the console. A bearer token scoped to one report source, which can do nothing but hand that source report bytes — it cannot read a thing. The source decides which client the data lands under, which is why there is no source id in the path: a caller cannot ask for one tenant and be given another. Admin-only to issue or revoke, because handing one out is the same class of act as creating a user. The token is shown once and stored only as a SHA-256 hash.
The two credential types do not substitute for each other in either direction — a machine token on a read endpoint is 403, and so is an admin session on the ingestion endpoint.
Retrying a push is safe. A SHA-256 of the request body is recorded per source, so posting identical bytes again is answered "replay": true with nothing ingested. That is the answer to a response lost in transit: retry blindly and get a definite yes rather than a pile of duplicates. It is distinct from duplicate, which means the report was already stored — what you get when the same report is re-encoded or arrives by a second route.
Rate limiting, per credential. POST /api/v1/reports is the only rate-limited route in the API: 60 requests per 60 seconds by default, partitioned by credential rather than by address so a pipeline behind NAT cannot starve an unrelated tenant. Exceeding it is a 429 with Retry-After.
Decompression is bounded. A rua=mailto: address is published in DNS — the whole point of the record is to tell strangers where to send attachments — and there is exactly one ingestion worker per database, so exhausting it stopped ingestion for every client at once, not just the mailbox that was targeted. Three absolute caps now apply on both paths, each naming itself when it trips: per decompressed payload, across everything one attachment expands to, and on the archive walk itself. Defaults sit far above any real reporting pipeline.
A truncated report is refused instead of being stored empty. Half a gzip decompresses without error. The parser built a report carrying the real report id and window with zero records, and because deduplication keys on that row, the complete report was permanently shadowed by the broken one and never backfilled. This is reachable from the mailbox path, not just the new endpoint, so it affects installs that never touch pushed ingestion.
Worth knowing that this is not rare. Backfilling a real archive of roughly 250,000 attachments for this release refused 78 of them — 0.03% — and all but four came from a single reporting organisation, consistently, across more than a year of mail. Every one of those would previously have become an empty report shadowing a good one.
RFC 9990 pass dispositions survive. RFC 9990 separates a record's action disposition from the published-policy disposition and adds pass to the former. A conformant namespaced report carrying <disposition>pass</disposition> was being treated as unknown and rewritten to none, losing the reported value before storage. It is now preserved end to end and has its own bucket in the analytics and the console.
This is not theoretical either: the same backfill found pass arriving from two live reporters today. Unnamespaced and v1 reports keep the previous fallback.
A per-source foreign-domain switch, and ingest provenance. allowForeignDomains on a report source, when off, refuses a report for a domain already owned by a different client before anything is written. It is deliberately not a domain allow-list: a domain nobody owns yet is created under the source's own client and is therefore never foreign. Existing sources default to on, preserving today's routing exactly.
An optional X-Report-Provenance header — a JSON object carrying an integer v that declares its shape — is stored as jsonb beside the receipt, so "which relay handed us this" is answerable in SQL.
Immutable image digests in the chart. image.digest renders every application image consumer as repository@digest, for deployments whose policy requires digests rather than mutable tags. Tag and digest together are refused rather than silently resolved, as is a malformed digest.
Retention and mailbox health no longer reach for mailboxes that aren't there. The retention pass was planning IMAP connections to sources that have never been polled — live on any install carrying a legacy pop3 row with deletion enabled. mailbox-health now covers polled sources only, and the console says what a pushed source is rather than describing it as a mailbox that has never synced.
Contribution and security policy. CONTRIBUTING.md, SECURITY.md, and GitHub private vulnerability reporting are enabled.
Not in this release
- No view of when a pushed source last received anything.
mailbox-healthdeliberately excludesapisources, because a pushed source has no mailbox, no sync run and no checkpoint — including it would put a row in that list permanently reading "never synced" while working perfectly. Monitor it from the sending side for now. - Rate limiting bounds authenticated requests only. A flood of well-formed but wrong tokens still costs one indexed lookup each. That is reverse-proxy territory, and worth knowing if you expose the endpoint directly.
- POP3 is still unimplemented, and is now refused rather than accepted-and-ignored. See Upgrade notes.
- A large backfill can fill the disk with the application's own log. Ingesting ~250,000 messages produced ~10GB of container log against ~4GB of database, because every statement is logged at the default level. Nothing warns you before the database runs out of space. Cap your log driver before a bulk import.
- A manual "Sync now" is tied to the HTTP request. If the browser gives up on a long sync, the sync is cancelled with it. The checkpoint means nothing is lost and the next scheduled pass resumes, but a large backfill is better driven from
curlor left to the scheduler. - No password recovery, no admin-driven identity linking, no per-domain trust list for
TrustUnverifiedEmail, and report data is still not replayable from the S3 archive — all as described in 0.10.0.
Upgrade notes
Five breaking changes. One of them fails silently — check that one first.
1. mailboxSourceId is now reportSourceId, as both a response field and a query parameter on /mailbox-sync-runs and /mailbox-health. Unknown query parameters are ignored, so a client still sending ?mailboxSourceId= gets 200 with every row, unfiltered. A filter that quietly stopped filtering looks like the source suddenly got busy, not like a broken script. Grep your automation for the old name.
2. /api/v1/mailbox-sources is now /api/v1/report-sources — list, create, patch and manual sync. The old path does not return 404: the API and console are one process, so an unmatched route falls through to the single-page app and returns 200 with HTML to a signed-in caller, or 401 to one that is not. Assert on the shape of the response rather than on the status.
3. pop3 is no longer accepted as a protocol when creating a source or changing one. Existing rows are untouched and stay editable — only a change to pop3 is refused. Nothing is lost: a POP3 source has never ingested anything, because the worker has only ever polled IMAP.
4. POST /api/v1/reports is live and enabled by default. Nothing authenticates against it until an admin issues a credential, so the practical exposure is a 401 surface. If you would rather it were not reachable at all, block it at your proxy.
5. allowForeignDomains is new on report sources and defaults to true for exis...
v0.10.0 — a console that works on a phone, and instances that know their own version
The console works on a phone, every instance can now tell you which version it is running, and a parser upgrade rescues reports that previously failed ingestion outright. No schema change.
📖 Documentation · ⚙️ Configuration reference · ⬆️ Upgrading
Install
ghcr.io/dmarc-analyzer-net/dmarc-analyzer:0.10.0 # canonical, no pull rate limits
dmarcanalyzernet/dmarc-analyzer:0.10.0 # Docker Hub mirror
oci://ghcr.io/dmarc-analyzer-net/charts/dmarc-analyzer --version 0.10.0
What's in it
The console works on a phone. The shell had no breakpoint at all: a permanent 230px sidebar plus a flat px-8 left roughly 96px of usable content at 390px, and every authenticated page inherited it. Because the login page always worked, the app looked reachable on a phone and then collapsed the moment you signed in.
Below lg (1024px) the sidebar is now an off-canvas drawer behind a top bar — backdrop, Escape, body scroll lock, focus moved in on open and returned to the trigger on close, and invisible while closed so the hidden menu stays out of the tab order. At lg and above the desktop layout is byte-for-byte unchanged. Page headers and action rows stack, search fields go fluid, every table sits in a horizontal scroll container, and dialogs cap at calc(100dvh - 2rem) and scroll internally rather than putting their submit button below an unscrollable fold.
Several of those only showed up by driving a real browser rather than reading the CSS. Input and Select now render at 16px below sm, because iOS Safari zooms the page on any focused field smaller than that, and controls gain 4px of height for touch. One 33-character unbreakable config key was setting the min-content width of the whole /backup page, overflowing all four cards by 3px.
Every instance can say what it is running. Nothing set a version at all before this — every release shipped an assembly reporting 1.0.0, and the only real version lived in Chart.yaml and the image tags, neither of which is readable from inside a running container. Someone on :latest had no way to find their own release notes (#150).
It now appears three ways: at the foot of the console sidebar as a link to the matching release notes, in GET /api/v1/system/status, and as service.version on the OpenTelemetry resource — so "did this process start after the upgrade" is answerable from telemetry rather than from deploy timestamps.
| Build | Reports |
|---|---|
| release tag | 0.10.0 |
edge, or a local build |
0.10.0+a1b2c3d — the release it is past, plus the commit |
Read from the assembly, never from an environment variable: an image whose version can be set at run time can be made to lie about itself, and that is the one question it has to answer honestly.
/api/v1/system/status also stops reporting mode: "api" unconditionally — which was the one mode an APP_MODE=all container is not running in, and all is what both the shipped Compose file and the chart's default deploy.
Reports that used to fail now ingest. The DMARC parser moves to DmarcRua 2.0.1, which fixes a trusted_forwarded → trusted_forwarder typo that had been rejecting entire <feedback> documents over one token, and adds enum values seen in the wild.
2.0.1 also introduces a crash of its own — reading adkim/aspf throws when a reporter omits them, which is 1.5% of the 3,241 real reports vendored in the library's own test corpus, Mail.Ru and Fastmail among them. That is worked around here rather than waited on (reported upstream), so absent alignment tags fall back to the RFC 7489 §6.3 default of relaxed as they did before.
And SPF scope=helo is now recorded as the reporter sent it. 2.0.0 modelled only mfrom, so this parser used to rewrite helo to mfrom to save the document — storing a value the reporter never reported, in 82 auth results across the test corpus. A wrong value rather than a missing one, which is worse.
Signing out works with local login disabled. With Auth__Oidc__DisableLocalLogin=true the login page has no form to fall back to, so it redirected straight to the provider — walking into the IdP's still-live browser session and getting silently re-authenticated. Clicking "Sign out" looked like it did nothing. An explicit logout is now a distinct state from an expired session: you get a "Signed out" confirmation and a manual sign-in button. Found on a production instance.
Dependencies. EF Core and its design/relational packages to 10.0.10, the Npgsql provider to 10.0.3, AWSSDK.S3 to 4.0.102. Both packages.lock.json files are regenerated, which is required rather than tidy — CI restores in locked mode.
Distribution surfaces. The Docker Hub page's content was still telling people to pin 0.2.2, seven releases back, because it was never in the list the version test looks at; it is now tracked and rewritten. Twelve links from this repo to the website pointed at URLs that redirect — every one on a high-visibility surface — and now use their canonical form.
Not in this release
- No password recovery. Still deliberate: there is no reset endpoint and no forgot-password flow. Losing the admin password is a database operation.
- No admin-driven identity linking, and no per-domain trust list for
TrustUnverifiedEmail— both still as described in 0.9.0. - Report data is not replayable from the S3 archive. The archive remains evidence, not a restore path.
- Dense tables scroll sideways on a phone rather than restacking as cards. That keeps them consistent with the pattern 13 of the 16 table call sites already used; restacking is a design decision rather than part of making the shell fit.
- The mobile work was verified in headless Chrome, not on a device. Overflow was measured on all 11 routes at 320px and 390px, and the desktop layout asserted numerically at 1024px and 1280px — but the iOS Safari input-zoom fix follows from Safari's documented 16px threshold rather than from a run on real hardware. Reports from actual phones welcome.
- Everything already listed under Not in this release for 0.8.0 still applies.
Upgrade notes
No schema change and no migration — the count stays at 25.
Auth results already stored keep the mfrom scope they were written with, including the ones that were really helo. The parser fix applies to reports ingested from here on; nothing rewrites history.
If you pin a version, note the jump is 0.9.0 → 0.10.0 and not 0.9.0 → 0.1.0 — sort your tags with sort -V if anything automated reads them.
Version note: a minor rather than a patch because it adds behaviour and an API field, per the project's versioning rules.
Feedback
v0.9.0 — Entra ID sign-in for people who already have an account
If you sign in with Microsoft Entra ID and already had a DMARC Analyzer account, this is the release that lets you in. Everyone else can upgrade at leisure — nothing else changed.
📖 Documentation · ⚙️ Configuration reference · 🔑 Entra ID setup
Install
ghcr.io/dmarc-analyzer-net/dmarc-analyzer:0.9.0 # canonical, no pull rate limits
dmarcanalyzernet/dmarc-analyzer:0.9.0 # Docker Hub mirror
oci://ghcr.io/dmarc-analyzer-net/charts/dmarc-analyzer --version 0.9.0
What was wrong
DMARC Analyzer attaches an SSO login to an existing local account by email, and only when the identity provider vouches for the address — otherwise a provider that lets people type any address into their own profile could be used to walk into an administrator's account. Every provider we had tested answers that with the standard email_verified claim.
Entra ID never sends it. Not in any token, not for any account type. Microsoft declines to assert it because the attributes behind its email claim are editable and not uniformly verified.
So a missing claim was read as "not verified", and anyone with a pre-existing account was refused at every attempt, permanently, with a log line saying their email was not verified at the IdP — when nothing whatsoever was wrong with their email (#140).
Deployments running AutoProvision=true never saw this: provisioning a brand-new account never consulted email verification, because a new account lands on DefaultRole and has nothing to take over. Only people who already had an account were locked out.
What's in it
Entra's xms_edov claim is now accepted. Microsoft's own answer to this gap is an optional claim — "email domain owner verified" — saying the address sits in a domain your tenant has proven it owns. That is a genuine assertion, so DMARC Analyzer treats it exactly like email_verified. Turn it on and Entra SSO works with no new setting at all. How to add it — two clicks in Token configuration, or three lines of manifest.
A provider saying nothing is now distinct from a provider saying no. Email assurance has three states rather than two:
| Provider says | Result |
|---|---|
| verified | links to the existing account |
| nothing at all | refused, with its own message — or allowed, if you opt in below |
| not verified | refused, always |
Auth__Oidc__TrustUnverifiedEmail (default false) accepts the middle row, for tenants that cannot get the optional claim configured. It trusts silence only — a provider that explicitly answers "not verified" is still refused, so turning it on for Entra cannot quietly weaken a different provider you configure later. Safe on a single-tenant Entra authority, where every mailbox is administered by the same organisation that runs the tenant. Not safe on /common, where any tenant anywhere could then assert any address; get xms_edov configured instead.
The failure now explains itself. The refusal has its own error code and login-page message, because "your email address is not verified" sends an operator to check a mailbox that is fine. The server log names both claims it looked for and both ways out:
OIDC login for user@example.com refused: the provider asserted neither email_verified nor xms_edov, so the address cannot be trusted to identify the existing account. Configure the xms_edov optional claim (Entra ID), or set Auth:Oidc:TrustUnverifiedEmail=true if this provider's addresses are administered.
Entra gets the runbook the other four providers already had — docs/ops/oidc-entra.md, collecting the client-secret requirement, the response-mode fix from 0.7.1, the multi-tenant caveat, and how to reproduce Entra's token shape on a local Keycloak so this path can be tested without a tenant.
Not in this release
- No admin-driven identity linking. An administrator still cannot link an OIDC identity to a user by hand from the console; linking is by email, on an assertion from the provider. That would remove email trust from the question entirely and is the better long-term answer.
- No per-domain trust list.
TrustUnverifiedEmailis all-or-nothing for the configured provider — there is no "trust these email domains only", which would be a safer middle ground for multi-tenant authorities. - Not verified against a live Entra tenant. The claim handling was reproduced and tested end to end against a Keycloak realm doctored into Entra's exact token shape, and against the 0.8.1 image to confirm the original failure. What no test here can cover is whether your tenant emits
xms_edovonce you configure it. Reports welcome. - Everything already listed under Not in this release for 0.8.0 still applies.
Upgrade notes
No schema change and no migration — the count stays at 25, verified by upgrading a 0.8.1 instance in place rather than from an empty database.
If you use Entra ID, add the email and xms_edov optional claims while you upgrade — instructions. Without them, affected users stay refused; the message they see just becomes accurate about why.
If you use any other provider, there is nothing to do. Providers that send email_verified behave exactly as before, which is covered by a regression test.
Version note: this is a minor rather than a patch because it adds a configuration key and changes an existing refusal, per the project's versioning rules — not because it is a large release.