
Weekly Mail Store Integrity Checks with Checksums
Before you run this
This sets up a weekly job that walks your Maildir tree, computes a SHA-256 for every message file, and compares this week's checksums against last week's to catch silent corruption (bit rot, a bad disk, a botched restore) in files that should never have changed once written.
It is read-only. It never touches, moves, or repairs mail — it only reads files and writes its own manifest and reports under /var/lib. There is nothing destructive here, so there is no data to lose. The worst it can do to your data is nothing.
It needs root, because on an iRedMail-style box the mail is owned by the vmail user and root is the simplest identity that can read the whole tree. It runs as a systemd oneshot service, which runs as root by default.
Two honest cautions before you wire it into cron or a timer:
- Test it on a copy first. Point
MAILSTOREat a throwaway directory (I'll show this at the end), run it by hand, deliberately damage a file, and confirm it flags it — before you aim it at/var/vmailand before you schedule it. - It is I/O heavy. Reading and hashing an entire mail store competes with live delivery and IMAP. Schedule it off-peak, and expect the first run on a large store to take a while.
One limitation to understand up front, because it shapes how you read the reports: each run rebuilds the manifest from the current files. So a corrupt file is flagged once, and after that its (bad) checksum becomes the new baseline. Keep the dated reports and your monitoring, and don't ignore an alert planning to "look next week" — next week it's the new normal. For continuous, at-rest guarantees, filesystem-level checksumming (ZFS, Btrfs) is the stronger tool; this script is the portable, filesystem-agnostic version.
Assumptions
- Debian 12, systemd, GNU coreutils (
sha256sum,find,xargs— all present by default). - Dovecot with Maildir storage under
/var/vmail. If yours lives elsewhere (some builds use/home/vmail), change the path. - You have
sudo/root.
If you're on RHEL/Alma the script is identical; only the store path and how you install packages differ.
The script
Save this as /usr/local/sbin/mail-integrity.sh, then chmod 750 it.
#!/usr/bin/env bash
#
# Weekly mail store integrity check via SHA-256 manifests.
# Read-only: it never modifies mail. It records checksums and, each week,
# re-verifies files that existed last week to catch silent corruption.
set -euo pipefail
MAILSTORE="/var/vmail" # root of your Maildir tree — change to yours
STATE_DIR="/var/lib/mail-integrity" # where manifests and reports are kept
MANIFEST="${STATE_DIR}/manifest.sha256"
REPORT="${STATE_DIR}/report-$(date +%F).txt"
mkdir -p "$STATE_DIR"
# First ever run: build a baseline and stop. Nothing to compare against yet.
if [[ ! -f "$MANIFEST" ]]; then
find "$MAILSTORE" -type f -print0 | xargs -0 sha256sum > "$MANIFEST"
echo "Baseline manifest created: $MANIFEST"
exit 0
fi
# Verify current files against last week's manifest.
# --quiet suppresses the "OK" lines, leaving only problems.
# sha256sum returns non-zero on any failure, so relax errexit around it.
set +e
sha256sum --quiet -c "$MANIFEST" > "$REPORT" 2>&1
set -e
# A checksum mismatch is real corruption: the line ends in ": FAILED".
# A missing file ends in "FAILED open or read" — that is just deleted mail, expected.
corrupt=$(grep -c ': FAILED$' "$REPORT" || true)
# Rebuild the manifest so next week compares against today's files
# (new mail added since last week, old mail deleted).
find "$MAILSTORE" -type f -print0 | xargs -0 sha256sum > "$MANIFEST"
if [[ "$corrupt" -gt 0 ]]; then
echo "INTEGRITY FAILURE on $(hostname): $corrupt file(s) failed checksum verification."
grep ': FAILED$' "$REPORT"
exit 2
fi
echo "Integrity check clean. Report: $REPORT"
Why the : FAILED$ filter matters: sha256sum -c reports three outcomes. Files that verify print OK (hidden by --quiet). Files whose contents changed print path: FAILED — that is corruption. Files that no longer exist print path: FAILED open or read — that is just mail the user deleted since last week, which is normal churn and not an error. Matching lines that end in : FAILED keeps the real problems and drops the noise.
The -print0 / xargs -0 pairing is there because mail filenames can contain characters that break naive whitespace splitting; NUL-separation is the standard-safe way.
Schedule it with a systemd timer
This is the mainstream path on Debian 12. (A cron entry works too — a single weekly line calling the script — but I'll use a timer so failures land in the journal and systemctl shows the last result.)
Create /etc/systemd/system/mail-integrity.service:
[Unit]
Description=Weekly mail store integrity check
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/mail-integrity.sh
Create /etc/systemd/system/mail-integrity.timer:
[Unit]
Description=Run mail store integrity check weekly
[Timer]
OnCalendar=Sun 03:00
Persistent=true
[Install]
WantedBy=timers.target
Persistent=true means if the box was off at 03:00 Sunday, the job runs at next boot rather than being skipped.
Enable it:
sudo systemctl daemon-reload
sudo systemctl enable --now mail-integrity.timer
Getting alerted
Because the script exits 2 when it finds corruption, the service unit is marked failed and any monitoring you already point at systemd unit state will catch it. If you want an email instead, the cleanest standard mechanism is a separate OnFailure= unit that mails you; see the systemd.unit(5) man page for the OnFailure= directive and the systemd.service(5) page for a mail-on-failure example. Don't bolt a mail command inside the timer blindly — check the exact invocation for whatever MTA/mailx you run.
Verify it works — on a test copy first
Do not trust it against live mail until you've watched it catch a fault. Build a small copy and point the script at it:
# 1. Make a throwaway tree with a couple of files.
mkdir -p /tmp/mailtest/cur
echo "message one" > /tmp/mailtest/cur/msg1
echo "message two" > /tmp/mailtest/cur/msg2
# 2. Run the script against it (edit MAILSTORE and STATE_DIR to the test paths,
# or copy the script to a test copy). Expect: "Baseline manifest created".
sudo MAILSTORE=/tmp/mailtest /usr/local/sbin/mail-integrity.sh # if you templated the vars
If you hard-coded the paths, just temporarily edit MAILSTORE="/tmp/mailtest" and STATE_DIR="/tmp/mailtest-state" in a copy of the script for this test.
Now simulate corruption and run again:
# 3. Corrupt one file's contents.
echo "tampered" > /tmp/mailtest/cur/msg1
# 4. Second run should report exactly one failure.
sudo .../mail-integrity.sh ; echo "exit code: $?"
You should see INTEGRITY FAILURE ... 1 file(s) failed, the line /tmp/mailtest/cur/msg1: FAILED, and exit code: 2. Delete msg2 and run a third time — it should come back clean, proving a deleted file is not mistaken for corruption.
Confirm the scheduled job
systemctl list-timers mail-integrity.timer # shows next and last run
systemctl status mail-integrity.service # last result: success or failed
journalctl -u mail-integrity.service # full output history
sudo cat /var/lib/mail-integrity/manifest.sha256 | head # the live baseline exists
Force an out-of-cycle run any time with sudo systemctl start mail-integrity.service.
Undoing it
Nothing here modifies mail, so there is nothing to reverse in your data. To remove the automation entirely:
sudo systemctl disable --now mail-integrity.timer
sudo rm /etc/systemd/system/mail-integrity.timer /etc/systemd/system/mail-integrity.service
sudo systemctl daemon-reload
sudo rm -rf /var/lib/mail-integrity # the manifests and reports
sudo rm /usr/local/sbin/mail-integrity.sh
A note on the SQL side
If by "mail server databases" you also mean the relational store behind your accounts, aliases, and quotas (PostgreSQL or MySQL/MariaDB in an iRedMail setup), file checksums are the wrong tool for that — those files change constantly and legitimately. Use the database engine's own verification instead: for PostgreSQL, the amcheck extension / pg_amcheck for index and heap checks (and pg_checksums if you enabled data-page checksums at initdb time); for MySQL/MariaDB, mysqlcheck or CHECK TABLE. Look those up in the respective official docs for the exact invocation on your version — that's a separate, weekly-cron-able job worth writing on its own terms.
Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.
More about the author →Was this article helpful?
Tap a star — no sign-in needed.
Be the first to rate this article.
Related guides
Automate Mail Backup Verification With a Periodic Restore Test
A backup you have never restored is a hope, not a backup. The cheapest way to turn hope into evidence is to restore something small on a schedule and check it landed. Here I restore one sample Maildir from the latest snapshot into a throwaway directory, confirm it looks like a real mailbox, log the result, and clean up. Run it weekly and you will know your mail backups are restorable before the day you need them.
Monitor Dovecot Process Memory and Alert on Runaway Processes
This guide sets up a small bash script, run on a schedule, that reads the resident memory (RSS) of every process inside the dovecot.service cgroup and warns you when one crosses a threshold you set. It only reads and reports — it writes a line to syslog and optionally sends an email. It does not kill processes, change Dovecot config, or touch any mailbox.
Report Mail Users Over Quota with Cron
This is a read-and-report job. It runs doveadm quota get to list every mail user's quota usage, picks out the ones over a percentage you set, and emails you a summary. It does not change quotas, delete mail, or touch mailboxes — nothing here is destructive.
Correlate Fail2ban, Postfix and Dovecot Logs Into One Report
This is a read-only reporting script . It reads your Postfix/Dovecot mail log and your Fail2ban log, extracts the source IPs behind SMTP SASL failures, Postfix rejects and Dovecot auth failures, cross-references them against the IPs Fail2ban actually banned, and prints one ranked summary. It does not touch your firewall, your jails, your mail queue, or any config. There is nothing to undo except deleting the report file and removing the cron entry you add at the end.




