---
title: "DKIM rotation, DMARC and TLS reports, complaints, bounces and the reputation watch"
url: https://www.hmailserver.co.uk/topic/28258/dkim-rotation-dmarc-and-tls-reports-complaints-bounces-and-the-reputation-watch
author: "Progressiverobot"
published: 2026-09-27T19:40:13.731Z
updated: 2026-09-27T19:40:13.731Z
category: "TLS, certificates & deliverability"
tags: ["dkim","dmarc","deliverability","dns","diagnostics"]
site: hMailServer Community Forum
---

# DKIM rotation, DMARC and TLS reports, complaints, bounces and the reputation watch

*Written for 6.3.4. hMailServer 6.3.7 (17 October 2026) no longer signs mail from outside whose From names a domain hosted here with that domain's DKIM key, and signs an authenticated submission as another hosted domain only when its account may send as the address its From names (CVE-2026-107567); the upgrade stops the signing, and only rotating the domain's key, as this topic describes, ends the signatures already made. See [the 6.3.7 announcement](/topic/28277).*

Five things new in 6.3.4 keep your signing keys fresh or read how the world is answering the mail your domains send: DKIM key rotation, the DMARC and TLS reports your domains receive, the providers' complaint feedback loops, bounce classification with a suppression list, and a daily check of your own addresses against block lists. The one thing to know first: each is its own switch and each is off until you turn it on - the reputation watch is the exception, on in a new installation and off after an upgrade - and only one of them, the suppression list, ever stops a message. The DKIM and DMARC records themselves are in [Being trusted by the servers you send to](/topic/28188).

The Control Panel and the COM API named below are Windows-only. The Control Deck, the REST API and `hmctl` work against a Windows or a Linux server; the Linux packages install `hmctl`, and the Windows installer does not carry it.

### DKIM keys that rotate

*Schema 6048.* A rotation policy per domain does, on a schedule or on *Rotate now*, what used to be done by hand. **It is off on every domain until switched on, and it never edits DNS**: it says what to publish, checks it, and waits.

**The walk.** Three slots, each a selector with a key file: *current* (the only key that signs), *next* (published and waiting; it never signs before the switch) and *retiring* (kept for mail still on its way).

1. The schedule starts a rotation when the current key is older than the interval, or somebody presses *Rotate now*. An RSA key is generated under a new selector: the one you give, or `s` and today's UTC date (`s20260915`, then `s20260915b` if that is taken).
2. You publish a TXT record at `<selector>._domainkey.<domain>` with the value `v=DKIM1; k=rsa; p=...`.
3. Every pass - every fifteen minutes, and on *Check DNS now* - looks the record up. The first pass that finds exactly the new key records the moment. Any pass that does not find it matching sends that moment back to zero.
4. Signing moves once the record has matched for the whole propagation period and the retiring slot is free.
5. The old selector's record stays published for the retention period.
6. The old selector is retired. A key file the rotation generated is deleted; one it did not generate is left. The status lists the record with the action `remove`, and removing it is yours.

Signing itself is still the domain's *DKIM signing* switch: the rotation moves keys, it does not switch signing on.

| Setting | REST key | Default | Range |
|---|---|---|---|
| Rotate on a schedule | `dkim_rotation_enabled` | off | |
| Days between rotations | `dkim_rotation_interval_days` | 180 | 1 to 3650 |
| Propagation period, hours | `dkim_rotation_propagation_hours` | 72 | 1 to 720 |
| Retention period, days | `dkim_rotation_retention_days` | 7 | 1 to 365 |
| Key size, bits | `dkim_rotation_key_bits` | 2048 | 2048, 3072 or 4096 |

Every interface refuses a value out of range rather than clamping it. Set them in the Control Panel's domain dialog (*DKIM* tab), the Deck's domain editor, `PUT /api/v1/domains/{domain}`, or `hmctl domain update example.com --set dkim_rotation_enabled=true`. Nothing is written to `hMailServer.ini`.

**What the status says.** `GET /api/v1/domains/{domain}/dkim/rotation` answers the phase (`off`, `idle`, `awaiting_dns`, `propagating`, `ready`, `waiting_for_retiring_slot`) and each slot's last check: `unchecked`, `matches`, `not_found`, `different`, `lookup_failed` or `key_unreadable`. `different` means a record exists and none carries this key, and waiting will not fix it. A match proves that this server's resolver sees the key, not that every receiver's does.

**Where the keys are, and who can read them.** `<DataDirectory>/DKIM`, as `<selector>._domainkey.<domain>.pem`.

| | Windows | Linux |
|---|---|---|
| Default directory | `C:\Program Files\hMailServer\Data\DKIM` | `/var/lib/hmailserver/DKIM` |
| Protection | A protected security descriptor naming only SYSTEM, the Administrators group and the service account; it does not inherit the data directory's permissions | Created with mode 0600, never widened |

On Windows, a Control Panel that is not elevated cannot read a generated key; the records it shows come from the status document.

**A managed domain refuses a blind save.** While rotation is on, or a next or retiring selector is in flight, a save that would change the selector or key file is refused: over COM with an error, over REST with `409`.

**When it goes wrong.** The `dkim.rotation.stalled` alert, on by default and held for the daily digest, is raised when a next key's record has not been seen for the rule's threshold (7 days by default) or the last attempt to move a running rotation failed. A record that is missing or different is never an error. The error log is for faults, codes `6610` to `6615`.

**A key that has been compromised.**

1. If an earlier old selector is still being kept, *Retire the old key now* first.
2. *Rotate now*, and publish the new record at once.
3. *Check DNS now* until the record matches, then *Switch now* with the box that skips the propagation period (`hmctl dkim switch example.com --force`). Force skips the period and never the check.
4. *Retire the old key now* again, which deletes the compromised key's file if the rotation made it.
5. Do not remove the old record. Publish it with an empty key, `v=DKIM1; p=`, which says the key is revoked rather than missing.

**Backups.** A [backup](/topic/28221) that includes the data directory includes the key files. Since 6.3.4 an archive can be encrypted (7-Zip AES-256, headers included), but **encryption is off until a backup passphrase is set**, and until then the archive holds the private keys in the clear: treat it as you treat the keys. Set it on the Control Panel's **Backup & restore → Encryption**, the Deck's Backup view, `PUT /api/v1/settings/backup` (`encryption_passphrase`) or `hmctl backup passphrase set`. Lose the passphrase and everything in every archive written with it is lost.

**Not done:** generating Ed25519 keys; signing twice during the switch; publishing a revocation.

### The reports your domains receive

*Schema 6070.* Receivers send a DMARC aggregate report once a day, and senders that honour TLS-RPT report on their sessions to your MX hosts.

**Name the mailbox.** Each domain has one **report mailbox**: the *Reports received* tab of the Control Panel's domain dialog, the *Reports the domain receives* group of the Deck's domain editor, or `report_mailbox` on `PUT /api/v1/domains/{domain}`. It is empty by default, and empty means nothing is read. The address has to be in a domain this server hosts, and is refused otherwise. Then publish it:

```text
_dmarc.example.com.     TXT  "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"
_smtp._tls.example.com. TXT  "v=TLSRPTv1; rua=mailto:dmarc@example.com"
```

Keep the `p=` your domain already publishes: this step adds only the `rua=` address. The wiki's DNS page says to start at `p=none` and tighten only once the reports show all your legitimate mail passes SPF and DKIM.

**What is read.** A message is opened only when it is addressed to a report mailbox **and to nothing else** of this server's, and it is delivered to the mailbox unchanged, read or not. Each candidate part is unwrapped by its bytes (gzip, zip or plain), and the first character decides: `<` is a DMARC aggregate report, `{` a TLS-RPT report. The same report twice is one row.

**Nothing read here acts.** No list is fed, no mail is refused, no setting changes.

**What carried a report is kept too.** Nothing inside a report proves who sent it, so the `dmarc=` verdict and `header.from=` domain this server wrote on the carrying message are kept on the row (`message_dmarc`, `message_from`). Both are empty unless `AuthenticationResultsEnabled` is on; with it off, the reporter is shown as *Not checked*.

**The bounds.** A message is not read past 32 MB, and an attachment is not inflated past 16 MB. A report keeps at most 2,000 records, and the log says when the chart under-counts. A domain takes in at most 20,000 records and 1,000 TLS-RPT rows in any twenty-four hours; a report past either ceiling is refused whole.

**The chart.** `GET /api/v1/domains/{domain}/dmarc-reports/summary?days=30`, drawn by the Deck's DMARC page and `hmctl dmarc summary`: messages passed and failed, what receivers quarantined and rejected, the reporters, and up to 200 sources that sent as the domain. `DomainReportRetentionDays` (365 by default; 0 keeps for ever) decides how long a report is kept. The rows are not in the backup; the reports are still in the mailbox, which is.

**Not done:** no reverse DNS or geography for a source; no forensic (`ruf=`) reports; no alert when the pass rate falls. The chart is how you see whether it is safe to move from `p=none` to `p=quarantine` to `p=reject`.

### Complaints: the feedback loops

*Schema 6055.* The large mailbox providers post an abuse report (RFC 5965) to an address you registered with them whenever a reader presses *this is spam*. With processing on and a mailbox named, a report is recorded against the sending domain and account, counted, and past a threshold puts the address on the domain's suppression list. **It is off by default, and inert without a mailbox.**

**Why only a configured mailbox is an intake.** A feedback report carries nothing known only to the provider. What tells a genuine report from a forged one is that it arrived where the provider was told to send it. So use a dedicated address the loops know and nobody else does - `fbl@example.com` rather than `abuse@example.com` - and do not publish it.

| Setting | REST key | Default | Meaning |
|---|---|---|---|
| `ComplaintProcessingEnabled` | `processing_enabled` | off | The whole feature |
| `ComplaintMailbox` | `mailbox` | empty | The address the loops are registered against, or several separated by commas. Each must be one this server hosts |
| `ComplaintSuppressionThreshold` | `suppression_threshold` | `0` | 0 to 100. `0` records and counts every report and lists nobody. Raise it to 1 once the entries have been read |
| `ComplaintRetentionDays` | `retention_days` | `180` | 0 keeps for ever; at most 3650. It does not remove a suppression entry a complaint made |

They are keys of `/api/v1/settings/complaints`, a group on the Deck's Settings page, and `hmctl settings get complaints`.

**Three gates that are not settings.** The report must be addressed to the complaint mailbox and to nothing else. The domain it is about must be one this server hosts. And an `Original-Rcpt-To` that this server hosts lists nobody.

**A complaint is not a bounce.** It has a counter of its own, `hm_bouncecounts.bccomplaints`. Its list entry carries `source` `complaint`, and the refusal a sender meets uses `5.7.1`, never `5.1.1`. `not-spam` is recorded and counts towards neither the rate nor the list; every other feedback type counts. It is the same suppression list the bounces feed, and **the list is applied whenever either feature is on**.

**The rate** is complaints divided by messages delivered, over a seven-day window that is not a setting, in parts per million. A domain that has sent fewer than 100 messages in the window is not judged. The `complaints.rate.high` alert ships on, seeded at 1000, with a cooldown of a day, held for the digest. With `MetricsPerDomainEnabled` set, the gauge `hmailserver_domain_complaint_rate{domain}` is exposed with two others.

**Reading them.** The Deck's domain row has a *Complaints* button; `GET /api/v1/domains/{domain}/complaints` needs `view_configuration` and `view_message_trace`; `hmctl complaint list --domain example.com` and `hmctl complaints rates` do the same from a shell. A backup does not carry the complaints.

### Bounces read, and a suppression list

*Schema 6049.* With bounce processing on, every refusal met while delivering and every RFC 3464 delivery report that arrives later is classified against its enhanced status code and counted per recipient and per sending domain. An address that hard-bounces as many times in a row as the threshold says goes on that domain's suppression list. **It is off by default.**

| Setting | REST key | Default |
|---|---|---|
| `BounceProcessingEnabled` | `processing_enabled` | off |
| `BounceHardBounceThreshold` | `hard_bounce_threshold` | `1` (1 to 100) |
| `BounceMailboxFullIsHard` | `mailbox_full_is_hard` | off |
| `BounceSuppressionAction` | `suppression_action` | `0` refuse; `1` drop; `2` observe |
| `BounceSuppressionExpiryDays` | `suppression_expiry_days` | `0`, entries kept until removed |
| `BounceReadDeliveryReports` | `read_delivery_reports` | on |
| `BounceCountRetentionDays` | `count_retention_days` | `90` |
| `BounceReportWindowDays` | `report_window_days` | `7` (1 to 30) |

They are on the Control Panel's **Delivery of e-mail** page (the *Bounces and the suppression list* card), the Deck's Settings page, `/api/v1/settings/bounces` and `hmctl settings get bounces`.

**The safe way to start** is `suppression_action=observe` for a week. The server classifies, counts and lists, delivers everything, and logs every message that would have been stopped. Read the list, then switch to `refuse`.

**What counts as which.**

| Code | Category | Counts towards the list |
|---|---|---|
| `4.2.2` or `5.2.2` | `mailbox_full` | only with `BounceMailboxFullIsHard` |
| any other `4.x.x` | `soft` | no |
| `5.7.x` | `policy` | **never** |
| `5.1.1`, `5.1.2`, `5.1.3`, `5.1.6`, `5.1.10`, `5.2.1` | `hard` | yes |
| any other `5.x.x` | `other` | no |

**A bare 550 is not a dead address.** The classifier never believes a code this server worked out for itself. When a remote's reply carries no enhanced code of its own, the words decide, policy words first: `550 Recipient address rejected: Access denied` is `policy`, `550 No such user here` is `hard`, and `550 <gone@example.com>` is `other`. `hmctl bounces classify "550 5.1.1 User unknown"` shows how a reply would be read, and changes nothing.

**A delivery report has to prove itself.** It is counted only when it passes six checks, among them: its envelope sender is null, it was not classified as spam, it arrived over SMTP here and not from a signed-in user, and it reproduces a row of `hm_bouncereportkeys`, a SHA-256 of the Message-ID, recipient and envelope sender of a message this server really sent. What that does not stop: one recipient in To or Cc could forge a report for another visible recipient. Such an entry says `source: report`, and `read_delivery_reports` can be turned off.

**What the list does.**

| Action | At `RCPT TO` and on the webmail | At delivery |
|---|---|---|
| `refuse` | Refused with `550` and the status that listed the address, with a sentence saying why | The recipient is removed and returned to the sender in an ordinary non-delivery report |
| `drop` | Accepted | Removed silently; a log line says so |
| `observe` | Accepted | Delivered; a log line says it would have been stopped |

Mail already in the queue meets the list at its next attempt. If the list cannot be read, the message is sent as if the address were not listed, and `HM6622` is reported at most once an hour.

**Looking at it.** Control Panel → **Monitoring & troubleshooting → Suppression list**; Deck → *Domains* → **Suppressions ›**; `GET /api/v1/domains/{domain}/suppressions`; `hmctl suppression list|create|delete --domain`. The list's document says `processing_enabled` when bounces are read and `list_applies` when the entries act. Removing an entry lets the next message through at once and starts the run of hard bounces again from none.

### The reputation watch

*Schema 6057.* Once a day the server asks the block lists about the addresses it sends from, and raises `reputation.listed` when one is listed. **On in a new installation, off after an upgrade**: an upgraded server may already have a delivery bind address, and switching this on beneath it would send that address to four third parties every day without anybody choosing it. Turn it on with `reputation_watch_enabled`, on the Deck's Settings page, `PUT /api/v1/settings` or `hmctl settings set`; switching it off clears a standing `reputation.listed`.

**Which addresses.** `SMTPDeliveryBindToIP`, the *bind to address* action of any active global rule, and `ReputationWatchAddresses`, which exists for a server behind NAT. A private, loopback, link-local or shared address is never looked up. At most 32 addresses are checked; one past that is reported as `too_many`.

**Which lists.** `ReputationWatchLists` ships with `ipv4:zen.spamhaus.org`, `ipv4:bl.spamcop.net`, `ipv4:psbl.surriel.com` and `ipv4:bl.mailspike.net`. **Nothing shipped covers IPv6, on purpose**: an IPv6 outbound address reads `not_applicable` against all four until you name a zone whose IPv6 coverage you have read. A subscriber key put in this setting is readable by everybody who can read the configuration.

**A refusal is not a listing, and neither is clean.** Before any address is looked up, each list is asked about `127.0.0.2`, which must be listed, and `127.0.0.1`, which must not be.

| What comes back | Status |
|---|---|
| An answer in `127.255.255.0/24` | `refused` |
| The lookup failed | `lookup_failed` |
| `127.0.0.2` is not listed | `list_not_answering` |
| `127.0.0.1` is listed | `list_lists_everything` |
| An answer outside `127.0.0.0/8` | `unexpected_answer` |

If `DNSServer` is one of the large public resolvers, Spamhaus will be `refused` every day, and the report says so rather than calling the server clean. Point `DNSServer` at a resolver of your own.

**A lookup that fails never clears a listing.** After a week with no definite answer a pair becomes `unknown`, not clean: `hmctl` still exits 1, and the report answers `listed` as `null`.

**Where and when.** `GET /api/v1/reputation`; `POST /api/v1/reputation/check`, answered `409` while a check is running; the Deck's **Outbound reputation** view; `hmctl reputation status`. A check is bounded to 120 seconds; pairs it does not reach are written as `lookup_failed`. The alert is sent at once rather than held for the digest.

**Not done:** delisting; domain-based lists; watching between checks; seeing behind a smart host.

### Where to read more

- Operator documents at the v6.3.4 tag: [DkimKeyRotation.md](https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/DkimKeyRotation.md), [DmarcReports.md](https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/DmarcReports.md), [Complaints.md](https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/Complaints.md), [BounceSuppression.md](https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/BounceSuppression.md) and [ReputationWatch.md](https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/ReputationWatch.md).
- Wiki: [DNS for Your Domain](https://gitlab.com/hmailserver/hmailserver/-/wikis/DNS-for-Your-Domain), [Outbound Shaping](https://gitlab.com/hmailserver/hmailserver/-/wikis/Outbound-Shaping) and [Monitoring and Health](https://gitlab.com/hmailserver/hmailserver/-/wikis/Monitoring-and-Health).
- Public documentation: [8.5 DKIM](https://www.progressiverobot.com/hmailserver-documentation/#85-dkim--signing-your-mail) and [8.6 DMARC](https://www.progressiverobot.com/hmailserver-documentation/#86-dmarc--the-policy).
- On this forum: [Being trusted by the servers you send to](/topic/28188), [The Control Deck](/topic/28192), [Requiring TLS](/topic/28257) for the reports your server sends, and [Alerts, the audit trail, syslog](/topic/28273) for how an alert reaches you.
