← Files

hMailServer ClamAV Cleanup#

On-demand antivirus sweep for an hMailServer message store. ClamAV is used strictly as a detector; every deletion is performed through the hMailServer COM API, so the database row and the .eml file are removed together and the store never drifts out of sync.


Table of Contents#


Why This Exists#

Mail stores accumulate messages that were clean at delivery time and turn malicious only after signatures catch up. Resident protection does not retroactively re-inspect an archive, so a periodic on-demand sweep is required.

The first implementation drove the locally installed Kaspersky console stub, avp.com. On the target host (consumer build 21.26.4.406) the command line interface turned out to be fully disabled: every command, including HELP and STATUS, returned exit code 3 with no output. This was confirmed with a twelve-variant probe matrix executed from the product directory, with and without a settings password, through cmd.exe and directly.

ClamAV replaced it as the detection backend because it is free, actively maintained on Windows, ships both a daemon client and a standalone scanner, and emits a stable, machine-readable verdict format:

C:\path\to\message.eml: Trojan.Foo-1 FOUND

The resident Kaspersky installation keeps running; ClamAV only reads files, so the two coexist without conflict.


Features#


How It Works#

  ┌─────────────────────────┐
  │ 1. Resolve environment  │  clamdscan / clamscan, clamd state, signature age
  └───────────┬─────────────┘
              │
  ┌───────────▼─────────────┐
  │ 2. Resolve credentials  │  parameter → path → env var → auto-discovery
  └───────────┬─────────────┘
              │
  ┌───────────▼─────────────┐
  │ 3. Locate Data dir      │  registry InstallLocation → hMailServer.INI → Data
  └───────────┬─────────────┘
              │
  ┌───────────▼─────────────┐
  │ 4. Build scan scope     │  *.eml by age, UTF-8 file list for --file-list
  └───────────┬─────────────┘
              │
  ┌───────────▼─────────────┐
  │ 5. Scan (detect only)   │  --no-summary --log, OEM-decoded stdout capture
  └───────────┬─────────────┘
              │
  ┌───────────▼─────────────┐
  │ 6. Parse verdicts       │  FOUND / ERROR / OK via global regex
  └───────────┬─────────────┘
              │
  ┌───────────▼─────────────┐
  │ 7. Match through COM    │  full path first, unique leaf name as fallback
  └───────────┬─────────────┘
              │
  ┌───────────▼─────────────┐
  │ 8. Report               │  CSV + JSON + debug log
  └───────────┬─────────────┘
              │
  ┌───────────▼─────────────┐
  │ 9. Quarantine & delete  │  only with -Delete, guarded by ShouldProcess
  └─────────────────────────┘

Messages are matched by absolute path. When a path cannot be matched, the file name is used only if it is unique among all detections; ambiguous names are skipped and reported.

Step 2 runs early on purpose. A full-store sweep can take hours, and discovering a bad password afterwards wastes the entire window.


Requirements#

Component Version Notes
Windows 10 / Server 2016+ Registry and COM access required
PowerShell 7.2 or newer Tested on 7.6.3
ClamAV 1.x Tested on 1.5.3
hMailServer 5.x hMailServer.Application COM object registered
Privileges Administrator Needed for the store, the service and COM

Installation#

1. Install ClamAV#

winget install ClamAV.ClamAV

2. Configure it for this workload#

git clone https://github.com/paulmann/hmailserver-clamav-cleanup.git
cd hmailserver-clamav-cleanup
pwsh.exe -NoProfile -File .\Install-ClamAvForHMail.ps1 -ConfigSource .

Why winget and Install-ClamAvForHMail.ps1?#

These two steps solve different problems and are intentionally not merged:

Step Responsibility
winget install ClamAV.ClamAV Obtains and installs the upstream product: downloads the signed vendor package, unpacks binaries into C:\Program Files\ClamAV, registers uninstall metadata, and stays idempotent and upgradable through winget upgrade.
Install-ClamAvForHMail.ps1 Performs the post-install integration that no package manager can know about: creates C:\ProgramData\ClamAV\logs and ...\tmp, validates the configuration files and refuses an active Example line, runs freshclam for the first signature download, registers and starts the clamd service, sets it to automatic start, and verifies that clamdscan can actually reach the daemon.

Repackaging the vendor installer inside a project script would mean shipping download logic, hash verification and upgrade handling that winget already does correctly and safely. Conversely, winget cannot create the directories this configuration expects, and that omission is fatal: freshclam and clamd initialize their logger before anything else, so a missing log directory aborts them with Failed to open log file ... No such file or directory and the service never registers.

3. Store the hMailServer credential#

Export the credential once so that no password ever appears on a command line or in a task definition:

$dir = Join-Path $env:ProgramData 'HMailClamAvCleanup'
New-Item -ItemType Directory -Force -Path $dir | Out-Null

Get-Credential -UserName 'Administrator' -Message 'hMailServer administrator' |
    Export-Clixml -LiteralPath (Join-Path $dir 'hmail.cred.xml')

The script discovers this file automatically, so -AdminPassword becomes optional. A PSCredential also carries the account name, which overrides -AdminUser.

Export-Clixml protects the secret with DPAPI and binds it to one user on one machine. Create the file under the same account that will run the sweep — see Unattended Operation for the full scheduled-task walkthrough.


Configuration#

clamd.conf and freshclam.conf in this repository are tuned for a mail store. The settings that matter most:

Setting Value Rationale
TCPSocket 3310 The Windows daemon supports TCP sockets only
TCPAddr 127.0.0.1 localhost may resolve to ::1 while the client connects over IPv4
ScanMail yes Without it, attachments inside .eml are never unpacked
ScanArchive yes Nested archive traversal
AlertOLE2Macros no Otherwise every macro-enabled document becomes a detection
AlertEncryptedArchive no Password-protected archives are routine in email
AlertExceedsMax yes Files that hit a limit are flagged instead of passing as clean
MaxFileSize 100M Keep aligned with -MaxFileSizeMB
ExcludePath quarantine The value is a regular expression; separators must be escaped
NotifyClamd clamd.conf freshclam reloads the daemon after an update, no restart needed

Add these to your resident antivirus exclusions:


Verification Before First Use#

1. Daemon reachable

Test-NetConnection -ComputerName 127.0.0.1 -Port 3310
Get-Service -Name clamd | Select-Object Name, Status, StartType

2. Environment diagnostics

.\Invoke-HMailClamAvCleanup.ps1 -DiagnoseOnly -Debug

Expect engine Clamd, clamd instances: 1, three signature files with a recent timestamp, and probe exit code: 0. -DiagnoseOnly never touches the COM API, so it needs no credential at all.

3. Parser sanity — the critical check

.\Invoke-HMailClamAvCleanup.ps1 -SinceDays 1 -Debug

clean files must equal files to scan. A mismatch means output parsing is broken and a result of 0 detections proves nothing.

4. Detection sanity

$dir = 'C:\mail\scripts\av\eicar-test'
New-Item -ItemType Directory -Force -Path $dir | Out-Null
$body = -join (@(88,53,79,33,80,37,64,65,80,91,52,92,80,90,88,53,52,40,80,94,41,55,67,67,41,55,125,36,69,73,67,65,82,45,83,84,65,78,68,65,82,68,45,65,78,84,73,86,73,82,85,83,45,84,69,83,84,45,70,73,76,69,33,36,72,43,72,42) | ForEach-Object { [char] $_ })
[System.IO.File]::WriteAllText("$dir\test.eml", $body)
& 'C:\Program Files\ClamAV\clamdscan.exe' --no-summary "$dir\test.eml"
"exit=$LASTEXITCODE"
Remove-Item -LiteralPath "$dir\test.eml" -Force -ErrorAction Ignore

Expect Eicar-Test-Signature FOUND and exit=1. Exclude the test directory from resident protection first, or the file disappears before ClamAV sees it.


Usage#

With a stored credential in place, no password argument is needed:

Report-only sweep of the last day

.\Invoke-HMailClamAvCleanup.ps1 -SinceDays 1 -Debug

Full store sweep with fresh signatures

.\Invoke-HMailClamAvCleanup.ps1 -UpdateDatabase -TimeoutMinutes 480 -Debug

Explicit credential from any location

$cred = Import-Clixml -LiteralPath 'C:\mail\scripts\av\hmail.cred.xml'
.\Invoke-HMailClamAvCleanup.ps1 -SinceDays 1 -AdminPassword $cred

Include the delivery queue

.\Invoke-HMailClamAvCleanup.ps1 -SinceDays 1 -IncludeQueue

Preview deletions without touching anything

.\Invoke-HMailClamAvCleanup.ps1 -SinceDays 1 -Delete -WhatIf

Delete for real

.\Invoke-HMailClamAvCleanup.ps1 -SinceDays 1 -Delete

Fully unattended, no prompts under any circumstances

.\Invoke-HMailClamAvCleanup.ps1 -NonInteractive -Delete -DeleteOrphan -Confirm:$false

No daemon available

.\Invoke-HMailClamAvCleanup.ps1 -SinceDays 1 -Engine Clamscan

Daemon cannot read the store (different service account)

.\Invoke-HMailClamAvCleanup.ps1 -SinceDays 1 -Engine Clamd -Stream

Parameter Reference#

Component locations#

Parameter Type Default Description
-ClamRoot string C:\Program Files\ClamAV ClamAV installation directory
-ClamdScanPath string auto Explicit path to clamdscan.exe
-ClamScanPath string auto Explicit path to clamscan.exe
-FreshclamPath string auto Explicit path to freshclam.exe
-DataDirectory string registry hMailServer Data directory

Credentials#

Parameter Type Default Description
-AdminPassword object auto-discovery Accepts a string, a securestring or a PSCredential, so Import-Clixml output can be passed straight through
-AdminUser string Administrator Account used for COM authentication; ignored when a PSCredential carries a user name
-CredentialPath string auto-discovery Explicit path to a credential file produced by Export-Clixml
-NonInteractive switch off Never prompt; throw instead when no credential can be resolved

-AdminPassword is deliberately typed as object rather than string. Binding a SecureString to a string-typed parameter silently converts it to the literal text System.Security.SecureString, which authenticates against nothing and sends the script back to an interactive prompt.

Scan behaviour#

Parameter Type Default Description
-Engine Auto | Clamd | Clamscan Auto Backend selection
-SinceDays 0–3650 0 Only messages newer than N days; 0 scans everything
-IncludeQueue switch off Include files in the Data root (the delivery queue)
-Stream switch off Send file contents to the daemon instead of paths
-MaxFileSizeMB 1–4096 100 Size limits for the Clamscan engine
-TimeoutMinutes 0–1440 240 Per-invocation timeout; 0 waits indefinitely
-UpdateDatabase switch off Run freshclam before scanning

Reporting and removal#

Parameter Type Default Description
-ReportDirectory string C:\mail\reports\clamav Root for per-run artifacts
-QuarantineDirectory string C:\mail\quarantine Root for quarantine copies
-SkipQuarantine switch off Delete without keeping copies
-Delete switch off Delete matched messages through COM
-DeleteOrphan switch off Also delete detections with no database record
-DiagnoseOnly switch off Environment check and probe scan only
-MaxDebugLines 10–5000 120 Trace dump depth

Standard CmdletBinding switches apply: -Debug, -Verbose, -WhatIf, -Confirm. ConfirmImpact is High, so unattended runs need -Confirm:$false.


Unattended Operation#

Interactive runs prompt for the hMailServer password. A scheduled run must supply it without a console and must fail loudly when it cannot, rather than block forever on a hidden prompt.

Credential resolution order#

Resolve-HMailAdminCredential walks these sources and stops at the first hit:

  1. The value passed to -AdminPassword — string, securestring or PSCredential.

  2. An explicit -CredentialPath.

  3. The HMAIL_CRED_PATH environment variable.

  4. Auto-discovery of hmail*.cred.xml, cred.xml or credential.xml in the script directory, .\creds\, %ProgramData%\HMailClamAvCleanup\ and %LOCALAPPDATA%\HMailClamAvCleanup\. The newest file by LastWriteTimeUtc wins.

  5. An interactive prompt — suppressed by -NonInteractive, which throws instead.

Every decision is traced under the [auth] category in debug.log, so a surprising choice is easy to audit after the fact.

Step 1 — Create a dedicated service account#

A purpose-built local account keeps the DPAPI scope narrow and the audit trail readable:

$password = Read-Host -AsSecureString -Prompt 'Password for svc_clamav'

New-LocalUser -Name 'svc_clamav' -Password $password `
    -FullName 'ClamAV cleanup service' `
    -Description 'Runs Invoke-HMailClamAvCleanup.ps1' `
    -PasswordNeverExpires -AccountNeverExpires

Add-LocalGroupMember -Group 'Administrators' -Member 'svc_clamav'

Local administrator rights are not optional: the script reads the message store and instantiates the hMailServer.Application COM object.

Step 2 — Export the credential as that account#

DPAPI ties the file to one user on one machine, so it must be written from a session running as svc_clamav:

# Start a shell as the service account first:
#   runas /user:MAILSERVER\svc_clamav "pwsh.exe -NoExit"

$dir = Join-Path $env:ProgramData 'HMailClamAvCleanup'
New-Item -ItemType Directory -Force -Path $dir | Out-Null

Get-Credential -UserName 'Administrator' -Message 'hMailServer administrator' |
    Export-Clixml -LiteralPath (Join-Path $dir 'hmail.cred.xml')

Step 3 — Restrict the ACL#

The script warns when the file is readable by Everyone or BUILTIN\Users, comparing well-known SIDs so the check also works on a localized Windows installation. Break inheritance and grant only what is required:

$dir = Join-Path $env:ProgramData 'HMailClamAvCleanup'
$acl = Get-Acl -LiteralPath $dir
$acl.SetAccessRuleProtection($true, $false)

'SYSTEM', 'Administrators', 'MAILSERVER\svc_clamav' | ForEach-Object {
    $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
        $_, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
}

Set-Acl -LiteralPath $dir -AclObject $acl

Step 4 — Register the scheduled task#

$script = 'C:\mail\scripts\av\Invoke-HMailClamAvCleanup.ps1'
$pwsh   = 'C:\Program Files\PowerShell\7\pwsh.exe'

$argument = @(
    '-NoProfile'
    '-NonInteractive'
    '-ExecutionPolicy', 'Bypass'
    '-File', "`"$script`""
    '-NonInteractive'
    '-SinceDays', '2'
    '-UpdateDatabase'
    '-Delete'
    '-TimeoutMinutes', '600'
    '-Confirm:$false'
) -join ' '

$action = New-ScheduledTaskAction -Execute $pwsh -Argument $argument `
    -WorkingDirectory (Split-Path -Parent $script)

$trigger = New-ScheduledTaskTrigger -Daily -At '03:30'

$settings = New-ScheduledTaskSettingsSet `
    -ExecutionTimeLimit (New-TimeSpan -Hours 12) `
    -MultipleInstances IgnoreNew `
    -StartWhenAvailable `
    -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
    -RestartCount 1 -RestartInterval (New-TimeSpan -Minutes 30)

$runAs = Get-Credential -UserName 'MAILSERVER\svc_clamav' -Message 'Task run-as account'

Register-ScheduledTask -TaskName 'hMailServer ClamAV cleanup' `
    -Description 'Detect-only ClamAV sweep of the hMailServer store, then delete infected messages through COM.' `
    -Action $action -Trigger $trigger -Settings $settings `
    -User $runAs.UserName `
    -Password $runAs.GetNetworkCredential().Password `
    -RunLevel Highest

Four details decide whether this actually survives production:

-SinceDays 2 overlaps deliberately, so a single missed run leaves no gap in coverage.

Step 5 — Verify the first run#

Start-ScheduledTask -TaskName 'hMailServer ClamAV cleanup'

Get-ScheduledTaskInfo -TaskName 'hMailServer ClamAV cleanup' |
    Select-Object LastRunTime, LastTaskResult, NumberOfMissedRuns

LastTaskResult of 0 means success. Then confirm how the credential was obtained:

$run = Get-ChildItem 'C:\mail\reports\clamav' -Directory |
    Sort-Object LastWriteTime -Descending | Select-Object -First 1

Select-String -LiteralPath (Join-Path $run.FullName 'debug.log') -Pattern '\[auth\]'

Expected output:

[auth] credential file discovered: C:\ProgramData\HMailClamAvCleanup\hmail.cred.xml
[auth] credential loaded from C:\ProgramData\HMailClamAvCleanup\hmail.cred.xml as System.Management.Automation.PSCredential
[auth] COM identity resolved before scanning: Administrator

For a low-impact rehearsal, drop -Delete and narrow the scope with -SinceDays 1.

Password rotation#

Re-export the file as svc_clamav; the task needs no change because the path is unchanged:

Get-Credential -UserName 'Administrator' -Message 'hMailServer administrator' |
    Export-Clixml -LiteralPath "$env:ProgramData\HMailClamAvCleanup\hmail.cred.xml"

.\Invoke-HMailClamAvCleanup.ps1 -NonInteractive -SinceDays 1 -Debug

Alternative credential stores#

Store Use when Caveat
Export-Clixml (DPAPI) Single host with one dedicated service account Bound to one user on one machine; unusable after a profile reset or OS reinstall
Protect-CmsMessage The task runs as SYSTEM, or several accounts need the same secret Requires a document-encryption certificate and an ACL on its private key
SecretManagement + SecretStore Several secrets with scheduled rotation The vault is user-scoped too; register it as the task account with authentication and interaction disabled
Group Managed Service Account Domain with multiple mail hosts Active Directory only; the password is never exposed to the script

Run Artifacts#

Each run creates <ReportDirectory>\run-<timestamp>\:

File Contents
debug.log Full trace, always written regardless of -Debug
transcript.log PowerShell session transcript
clamav-scan.log Scanner log produced by --log
clamav-scan.stdout Captured console output
scan-scope.lst UTF-8 list of scanned paths
infected-messages.csv Account, folder, message id, date, sender, subject, size, verdict, path
infected-messages.json Same data as JSON

The three lines worth monitoring:

files to scan: 1 015
clean files: 1015; detections: 0; error lines: 0; elapsed 00:00:03
matched messages: 0; unmatched detections: 0

A non-zero error lines means those files were not proven clean. A suspiciously fast repeat scan is normal: clamd caches results by file hash.


Exit Codes#

ClamAV code Meaning Script behaviour
0 No threats found Continue
1 Threats found Continue and parse detections
2 Scan error Warn if at least one detection parsed, otherwise terminate

Troubleshooting#

Symptom Cause Resolution
Could not connect to clamd ... Connection refused Daemon stopped or crashed at startup Check Get-Service clamd and C:\ProgramData\ClamAV\logs\clamd.log; use -Engine Clamscan meanwhile
Failed to open log file ... No such file or directory C:\ProgramData\ClamAV\logs missing Create the directory; ClamAV never creates it
net start clamd → service name is invalid clamd.exe --install failed, usually after the logger error Fix directories, rerun elevated, confirm with Get-Service -Name '*clam*'
clean files ≠ files to scan Output parsing broken Ensure the delimiter-agnostic parser is in place; clamdscan on Windows does not always separate results with CR, LF or NUL
Cannot create hMailServer.Application COM not registered or bitness mismatch Verify registration; 0x80040154 usually means the wrong process architecture
A password prompt appears although -AdminPassword was supplied An older build declared the parameter as securestring or string, so the value was stringified Update to a build where -AdminPassword is object
Cannot read ...cred.xml ... bound to one user on one machine The file was exported by another account or on another host Re-export while logged on as the account that runs the task
No hMailServer password was supplied, no credential file was found Auto-discovery found nothing and prompting is disabled Pass -CredentialPath, set HMAIL_CRED_PATH, or place the file in a discovered directory
hMailServer authentication failed for the ... account Wrong password, or a PSCredential carrying an unexpected user name Re-export the credential, or pass -AdminUser explicitly
Scheduled task never starts while logged off Registered with -Principal instead of -User and -Password Re-register as shown in Unattended Operation
Sweep killed part-way through -ExecutionTimeLimit shorter than -TimeoutMinutes Raise the task limit above the script timeout
Excessive false positives Aggressive heuristics enabled Disable AlertOLE2Macros, AlertEncryptedArchive and DetectPUA in clamd.conf
Scan finishes in seconds clamd result cache Expected; set DisableCache yes temporarily for a cold measurement

Restoring a Deleted Message#

Quarantine copies live in <QuarantineDirectory>\<timestamp>\ and are named account_at_domain_<id>_<original>.bad.

  1. Locate the row in infected-messages.csv for that run.

  2. Copy the file out of quarantine and drop the .bad suffix.

  3. Re-inject the message through hMailServer rather than copying it into an account folder.

Copying an .eml back into the account directory does not make it visible: hMailServer serves clients from the database, not from directory contents.


Security Considerations#


Limitations#


Roadmap#


Contributing#

Issues and pull requests are welcome. Please make sure that:


License#

Released under the PolyForm Noncommercial License 1.0.0. See LICENSE.


Author#

Mikhail Deynekin

Repository: https://github.com/paulmann/hmailserver-clamav-cleanup