• Halloween is coming! To celebrate the occasion, we dusted off a couple of old skins, and even created a brand new one (Pixel).
    To get into the festive spirit, install the Skins extension.
  • The new Python extension is now available. It allows customers to deploy and manage WSGI-based Python applications on their websites directly from Plesk.
  • The Plesk Mobile apps for iOS and Android and the Plesk Mobile Center extension have been deprecated, and are no longer available for new installations. In Plesk Obsidian 18.0.84, the Plesk Mobile Center extension will be removed from servers during the upgrade, and the Plesk Mobile apps will lose their server management features.
  • The Social Login extension has been deprecated and will be removed in Plesk Obsidian 18.0.84. After that, it will no longer be possible to log in to Plesk with a Google, Facebook, GitHub account, or with a passkey. Plesk usernames and passwords, as well as logging in with a WebPros Account, are not affected.

Issue Incoming Zoho Campaigns emails fail DKIM: psa-pc-remote removes a space from a folded Content-Type header

ollosolucions

New Pleskian
Server operating system version
AlmaLinux 8.10 x86_64
Plesk version and microupdate number
18.0.82.1
Hello,

I am investigating an issue where incoming emails from Zoho Campaigns consistently end up in the Spam folder on a Plesk server.

After checking DNS, verifying the signatures locally, and capturing the Postfix–milter communication, I traced a whitespace change to the processing performed by psa-pc-remote or one of the mail handlers it invokes.

Has anyone encountered this behavior, or is there a known bug or supported fix?

Environment​

Code:
OS: AlmaLinux 8.10 x86_64
Plesk Obsidian: 18.0.82.1

plesk-mail-pc-driver:
18.0-2.redhat.8+p18.0.82.0+t261015.0936.x86_64

psa-mail-driver-common:
18.0-2.redhat.8+p18.0.82.0+t261015.0936.x86_64

Postfix:
3.5.28-2.redhat.8+p18.0.82.0+t261013.0859.x86_64

sendmail-milter:
8.15.2-34.el8.x86_64

Incoming mail follows this pipeline:

Code:
External SMTP sender
↓
Postfix / Plesk milter (psa-pc-remote, TCP 12768)
    ↓
Amavis (TCP 10024)
    ↓
Postfix reinjection (TCP 10025)
    ↓
Plesk DKIM and DMARC delivery handlers
    ↓
Dovecot

Symptoms​

Affected messages contain two DKIM signatures: one for the sender’s domain and one for a Zoho domain. Both use:

Code:
a=rsa-sha256
c=simple/simple

On the VPS, both signatures fail:

DKIM verification (...) failed: signature verification failed

SPF passes for the Zoho envelope sender domain, but it is not aligned with the visible From domain. As a result, the DKIM failure also causes DMARC to fail:

Code:
align_dkim=fail
align_spf=fail
spfres=pass
dkimres=fail
dmarccheck=DMARC_POLICY_QUARANTINE

Dovecot subsequently records:

stored mail into mailbox 'INBOX.Spam'

In the original message investigated, Amavis allowed the message through a whitelist, but the subsequent Plesk DMARC handler still caused delivery to Spam.

A separate recipient copy of the same campaign, delivered directly from Zoho to Microsoft 365 without passing through this VPS, passed both DKIM signatures and DMARC. This was a different recipient-specific message, not an identical copy of the VPS message.

Local cryptographic verification​

For an affected message stored on the VPS:
  1. Both calculated DKIM body hashes match the corresponding bh= values.
  2. The sender-domain DKIM TXT record matches between the local resolver, a public resolver, and both authoritative DNS servers. The Zoho DKIM record also matches between the local and public resolvers.
  3. Both signatures fail when verifying the stored message.
  4. Restoring exactly one space in the folded Content-Type header makes both original signatures pass using Mail::DKIM.
The stored header has this structure:

b'Content-Type: multipart/alternative;\r\n\tboundary="..."'

Both signatures validate when the header is changed, in memory only, to:

b'Content-Type: multipart/alternative; \r\n\tboundary="..."'

The only difference is the single space after the semicolon, immediately before the folded line break.

No body changes, DNS changes, replacement signatures, or other header changes are required.

Packet capture: the space is present when Postfix calls the milter​

I captured the loopback TCP conversation between Postfix and psa-pc-remote on port 12768 during a new Zoho Campaigns test delivery.

The relevant connection contained exactly one SMTP MAIL transaction.

After reconstructing the TCP streams and parsing the binary milter frames, the Content-Type value passed from Postfix to the milter was:

b'multipart/alternative; \n\tboundary="..."'

The space is present at this point.

The milter then returned:

Code:
Command: SMFIR_CHGHEADER ('m')
Header: Content-Type
Index: 1
Value: empty

This deletes the existing header.

Later in the same response stream, it returned:

Code:
Command: SMFIR_ADDHEADER ('h')
Header: Content-Type

With this value:

b'multipart/alternative;\n\tboundary="..."'

The space has disappeared.

The milter deleted and re-added multiple headers during this transaction. It also issued a body-replacement operation, although that operation alone does not establish that the body content changed.

The \n sequences shown above are the line breaks observed inside the milter header values. The stored-message comparison uses reconstructed SMTP \r\n line endings.

Amavis input/output comparison​

In a preceding test delivery, I also captured the message on:

Code:
TCP 10024: Postfix → Amavis
TCP 10025: Amavis → Postfix

For the same test Message-ID on those two ports, Content-Type already lacked the space when entering Amavis and retained the same format on output.

Therefore, Amavis was not the component removing that space in that test.

The later milter capture located the change more precisely: Postfix supplied the space to psa-pc-remote, and the milter requested that the header be re-added without it.

Relevant configuration checks​

The following global settings are empty:

Code:
header_checks =
body_checks =
milter_header_checks =
smtp_header_checks =
smtp_mime_header_checks =
smtp_nested_header_checks =
smtp_body_checks =
canonical_maps =
sender_canonical_maps =
smtp_generic_maps =

The incoming SMTP milter is:

smtpd_milters = , inet:127.0.0.1:12768

The listener on that port was confirmed to be psa-pc-remote.

The Plesk handler list includes the incoming DKIM verification handler followed by the DMARC handler in the before-local queue. No obvious custom header-rewriting handler was found.

Current conclusion​

The captures show that the space is removed during processing by psa-pc-remote and/or a mail handler it invokes.

For the historical message examined, restoring that one space is sufficient for both DKIM signatures to validate.

I have not yet identified whether the normalization occurs in the milter’s message reconstruction code or in a specific Plesk mail handler. I have also not established when this behavior started, so I cannot attribute it to a particular Plesk update.

No mail configuration changes, service restarts, or handler disabling have been performed during the investigation.

Questions​

  1. Is this whitespace normalization a known issue in psa-pc-remote or the Plesk mail-handler framework?
  2. Is there an existing bug ID, supported hotfix, or release that addresses it?
  3. Is there a supported way to preserve the original whitespace in signed headers without disabling the Plesk milter or globally weakening SPF, DKIM, or DMARC checks?
  4. If further diagnostics are needed, which targeted logs or traces would help identify the responsible internal component?
This is a production server hosting multiple domains, so I would prefer a targeted, supported solution rather than disabling mail security globally or adding a blanket header-rewriting rule.

Packet captures are available privately, but they may contain sensitive email data and will not be posted publicly.

Thank you.
 
What does the actual full header of the email look like?

And since you indicate you're using amavis, can your confirm using the Plesk Email Security extension?
 
Hi ollosolucions,

very nice analysis, the packet capture is exactly the kind of evidence support needs. A few thoughts, and please take them as hints, not as confirmed facts:

1) Why only some mails are hit
Zoho signs with c=simple/simple, and with "simple" the header must stay byte-identical. That includes a space before a folded line break. With c=relaxed (what Gmail, Microsoft and most others use) the whitespace is normalized before hashing, so "; \r\n\tboundary" and ";\r\n\tboundary" produce the same result and the signature survives. So you will only see this with senders that use c=simple AND a Content-Type that is long enough to be folded. That fits your symptoms perfectly, and it would explain why it went unnoticed for a long time. It would be interesting to know if other c=simple senders with folded headers fail the same way on your server.

2) Possible relation to an older bug
There is an old thread here about forwarded mails failing DKIM when the attachment had a long (wrapped) Content-Type, while attachments with a short one like "application/pdf;" were fine. According to that thread it was fixed in Plesk Obsidian 18.0.59 (ticket PPPM-14166, only listed in the changelog). Same symptom class, header re-written during processing. I can't tell if it is the same code path, but if it is, you are on 18.0.82.1 and this could be a regression. Mention that ticket number when you contact support.

The older thread I mentioned is this one:

The post saying it was fixed in Plesk Obsidian 18.0.59 (ticket PPPM-14166) is here:

Note that the ticket number is only listed in the public changelog, and I can't say for sure that it's the same root cause as yours. It just looks like the same symptom class (a wrapped Content-Type header getting re-written and breaking DKIM), so it's worth mentioning to support.

3) Kaspar's questions
Kaspar asks for the full header and whether you use the Email Security extension. For the first one it would help to post the raw header block of an affected mail (anonymized), especially the Content-Type lines, both DKIM-Signature headers (c=, h=, bh=) and the Received lines. For checking what happens to one message on your server, take the queue ID of a test mail and look at it in the log:
Code:
grep "<queue-id>" /var/log/maillog

and to see which handlers are active on the server:
Code:
plesk bin mailhandler --list

4) What I would do now
- Open a Plesk support ticket with your two captures (support can look at them privately, so no need to post them here), the exact package versions you posted, and the reference to PPPM-14166. A reproducible case with exact byte offsets is rare, that should speed things up a lot.
- As a temporary and targeted measure, there is a KB article about excluding a single domain from the DMARC check ("exclude a domain from DMARC check in Plesk for Linux"). You would only do that for the sender domains you trust, so the global DKIM/DMARC protection stays on. I haven't tried it on your version, and one user reported in another thread that it didn't work for him on 18.0.56, so please test it with one domain first and keep the old setting noted down.
- Please don't disable "Verify incoming mail" for DKIM as a workaround: DMARC would then see no DKIM result at all and the mails would still land in Spam (or get dropped, depending on the sender's policy).

Please keep us posted, I'm curious what support says.

Regards
Tessa
 
Hi Kaspar and Tessa,

Thank you both for your help.

Yes, I can confirm that I am using the Plesk Email Security extension.

We have already correlated an affected message through the rotated mail log and listed the registered Plesk mail handlers. Below are the key findings and anonymized extracts.

Milter capture: the whitespace change​

Postfix supplies this Content-Type value to psa-pc-remote:

b'multipart/alternative; \n\tboundary="..."'

The milter then requests deletion of that header using SMFIR_CHGHEADER with an empty value, followed by SMFIR_ADDHEADER with:

b'multipart/alternative;\n\tboundary="..."'

The space before the folded line break is removed. The relevant captured connection contains exactly one MAIL transaction.

A separate capture of a preceding test delivery shows that Amavis already receives the header without that space and returns it in the same format.

For the original affected message:
  • Both calculated DKIM body hashes match their respective bh= values.
  • Both original signatures fail verification.
  • Restoring only that single space, in memory, makes both original signatures pass using Mail::DKIM::Verifier, without changing the body, any other header, or the public keys.
This locates the whitespace change within the processing performed by psa-pc-remote or one of the Plesk mail handlers it invokes. I have not yet identified the responsible internal component.

Affected message: anonymized header extracts​

Domains, addresses, IPs, queue IDs and message identifiers have been replaced. Signature values and intervening headers are omitted.

These are diagnostic extracts, not the complete original header block or a message suitable for cryptographic verification.

Received: from vps.example.net (vps.example.net [127.0.0.1])
by vps.example.net (Postfix) with ESMTP id QUEUE_AFTER_FILTER
for <[email protected]>; Fri, 30 Oct 2026 14:03:41 +0100

X-Spam-Flag: NO
X-Spam-Score: 0
X-Spam-Status: No, score=x tagged_above=-9999 required=3 WHITELISTED tests=[]
autolearn=unavailable

Authentication-Results: vps.example.net (amavis) (amavis);
dkim=fail (1024-bit key) reason="fail (message has been altered)"
header.d=sender.example.org;
dkim=fail (1024-bit key) reason="fail (message has been altered)"
header.d=mailb2.zgph.zcsend.eu

Received: from vps.example.net ([127.0.0.1])
by vps.example.net (amavis, port 10024)
with ESMTP id AMAVIS_ID for <[email protected]>;
Fri, 30 Oct 2026 14:03:41 +0100

Received: from mail33.zcsend.eu (mail33.zcsend.eu [192.0.2.33])
by vps.example.net (Postfix) with ESMTPS id QUEUE_BEFORE_FILTER
for <[email protected]>; Fri, 30 Oct 2026 14:03:40 +0100

Authentication-Results: vps.example.net;
dmarc=fail (p=QUARANTINE sp=QUARANTINE)
smtp.from=mail2.zgph.zcsend.eu header.from=sender.example.org;
dkim=fail header.d=sender.example.org;
dkim=fail header.d=mailb2.zgph.zcsend.eu;
spf=pass

Received: from 10.167.13.128 by mail33.zcsend.eu
with SMTP id REDACTED; Fri, 30 Oct 2026 14:03:39 +0100

The two DKIM signatures contain these parameters. These are parameter extracts rather than complete DKIM-Signature headers:

Signature 1:
a=rsa-sha256
q=dns/txt
c=simple/simple
s=181051
d=sender.example.org
v=1
bh=5WDGHX0qzC+jSxnKwQ18Cr9cnpmjYlmW3Do22HlpRZs=

Signature 2:
a=rsa-sha256
q=dns/txt
c=simple/simple
s=k1
d=mailb2.zgph.zcsend.eu
v=1
bh=5WDGHX0qzC+jSxnKwQ18Cr9cnpmjYlmW3Do22HlpRZs=

Both signatures use exactly this h= list:

h=date:date:from:from:to:to:message-id:subject:subject:mime-version:content-type:list-unsubscribe:list-unsubscribe:list-unsubscribe-post:x-csa-complaints;

The stored top-level header, with the boundary value anonymized, is:

b'Content-Type: multipart/alternative;\r\n\tboundary="REDACTED"'

Restoring this single space makes both original signatures pass:

b'Content-Type: multipart/alternative; \r\n\tboundary="REDACTED"'

The original signatures, boundary value and all other message data were retained during verification. Only the public examples above are redacted. The \n line breaks in the earlier examples are those observed inside the milter header values; the examples here use reconstructed SMTP \r\n line endings.

Correlated log results​

The original message was found in a rotated mail log. Correlating its queue IDs and Message-ID showed:

amavis:
Passed CLEAN {RelayedInbound}
Hits: -
queued_as: QUEUE_AFTER_FILTER

dk_check:
DKIM verification (d=sender.example.org, 1024-bit key) failed:
signature verification failed

DKIM verification (d=mailb2.zgph.zcsend.eu, 1024-bit key) failed:
signature verification failed

dmarc:
align_dkim=fail align_spf=fail
spfres=pass dkimres=fail
dmarccheck=DMARC_POLICY_QUARANTINE
dmarcstatus=DEFER

dovecot:
stored mail into mailbox 'INBOX.Spam'

Thus, Amavis allows the message through the whitelist, but the subsequent Plesk DMARC delivery handler causes it to be stored in Spam.

Registered mail handlers​

We obtained the handler list using:

/usr/local/psa/admin/sbin/mail_handlers_control --list

Relevant enabled entries are:

Queue Priority Handler Scope
before-queue 5 limit-out global
before-queue 10 check-quota global
before-queue 10 spf global

before-local 10 dd52-domainkeys global
before-local 20 dmarc global
before-local 30 arc-sign recipient-domain

before-remote 10 dd51-domainkeys sender-domain

before-sendmail 4 add-from global
before-sendmail 5 limit-out global
before-sendmail 10 check-quota global

Per-domain entries are summarized rather than listing the hosted domains. This list alone does not identify which handler or reconstruction routine removes the space.

Related issue and next steps​

Tessa, thank you for the PPPM-14166 reference. I will include it in the escalation as a potentially related issue, without assuming the same root cause or a confirmed regression. One distinction is that our affected field is the top-level Content-Type header, and both body hashes match.

I have not tested other senders using simple header canonicalization yet. I have not applied any DMARC exclusions or disabled incoming DKIM verification.

Plesk’s support portal directs me to my license provider, Arsys, so I am taking the escalation through them. The unmodified message and packet captures will remain private and can be supplied through an agreed secure support channel if required.

Thanks again for the useful pointers.
 
Hi ollosolucions,

thanks for the detailed data, this is a very good basis for the escalation. A few observations, partly based on your new extracts, partly pure speculation (I'll mark which is which):

1) You can already narrow down the responsible part
Your capture on 10024 shows that Amavis already receives the header without the space. The before-local handlers (dd52-domainkeys, dmarc, arc-sign) only run later, at delivery, so they can't be the ones that remove it. That leaves the before-queue handlers (limit-out, check-quota, spf) or the psa-pc-remote framework itself. In logs posted elsewhere on this forum, limit-out and check-quota usually just log SKIP for ordinary inbound mail, and spf only evaluates. So my guess would be the framework and not one of the handlers, but that's a guess. You can check what the handlers report for your message with the queue ID from the first Received line (the one before the filter):
Code:
grep "<queue-id-before-filter>" /var/log/maillog*

2) A hypothesis for the "delete and re-add" behavior (speculation)
It's unusual that a milter deletes and re-adds a header that no handler has a reason to touch, and that it does this for several headers. One possible explanation: the framework hands the message to the handlers, takes the result back, compares it with the original and sends every difference back to Postfix as change/add header commands. If anything on that way strips trailing whitespace from lines, every header with a space before a fold looks "changed" and is re-added without the space. That would match what you captured (tab kept, trailing space gone) and would also explain why more than one header was touched. The fact that your body hash still matches suggests the body lines keep their bytes. I can't prove any of this, but it's a concrete question for the developers: "Is the message re-serialized between the milter and the handlers, and is trailing whitespace removed there?"

3) A minimal reproduction without Zoho
For the escalation, a test that anybody can repeat is worth more than private captures. Send a crafted message from an external host (not from the Plesk server itself, so it takes the normal inbound path) with a folded Content-Type that has a space before the line break, plus a second header with the same pattern, to see whether it only affects Content-Type or every header. The file (replace the placeholders):
Code:
printf 'From: <[email protected]>\r\nTo: <[email protected]>\r\nSubject: fold test\r\nDate: <rfc-2822-date>\r\nMessage-ID: <[email protected]>\r\nMIME-Version: 1.0\r\nX-Fold-Test: aaa; \r\n\tbbb\r\nContent-Type: multipart/alternative; \r\n\tboundary="XYZ"\r\n\r\n--XYZ\r\nContent-Type: text/plain\r\n\r\nhello\r\n--XYZ--\r\n' > /root/fold-test.eml

Send it (swaks syntax from memory, please check the man page if it complains):
Code:
swaks --server <your-server> --from <[email protected]> --to <[email protected]> --data @/root/fold-test.eml

Then look at the stored message. "cat -A" makes the trailing space visible: if the space is there you see "; $", if it was removed you see ";$":
Code:
grep -A1 -i -E "^(X-Fold-Test|Content-Type: multipart)" /var/qmail/mailnames/<domain>/<user>/Maildir/new/<file> | cat -A

(Adjust the folder, the message may be in cur/ or in the .Spam folder.) If both headers lose the space, the framework strips trailing whitespace in general, and every sender using c=simple with a folded header is affected, which is a much stronger bug report than "Zoho mails go to Spam". If only Content-Type is affected, that points to code that handles MIME headers specially.

4) Escalation via your license provider
Good that you're going through Arsys. I'd give them the minimal reproduction from point 3, the exact package versions you posted, the observation that the space is already gone before Amavis, and the reference to PPPM-14166 as "possibly related, same symptom class". That should save a few round trips between the reseller and Plesk.

5) In the meantime
Nothing here needs you to weaken anything globally. If the Zoho campaigns are important, the least invasive step is still the per-domain DMARC exclusion from the KB article I mentioned earlier, only for the sender domains you trust, tested with one domain first. And don't switch off the incoming DKIM verification: DMARC would then have no DKIM result at all and the mails would still go to Spam.

Keep us posted.

Regards
Tessa
 
Hi Tessa,

I have now completed the minimal reproduction from an external Windows machine, independently of Zoho.

The message was sent directly to the VPS on TCP port 25, without SMTP authentication. Before sending, I verified that both test headers contained exactly one space before CRLF, followed by a tab on the continuation line.

Both headers lost that space:

Code:
X-Fold-Test

Sent:
b'X-Fold-Test: aaa; \r\n\tbbb'

Stored:
b'X-Fold-Test: aaa;\r\n\tbbb'


Code:
Content-Type

Sent:
b'Content-Type: multipart/alternative; \r\n\tboundary="XYZ"'

Stored:
b'Content-Type: multipart/alternative;\r\n\tboundary="XYZ"'

There is exactly one instance of each header in the stored message. In both cases, the trailing space is removed while the continuation tab is preserved.

The logs for this synthetic message show:

Code:
limit-out:   SKIP
check-quota: SKIP
spf:         PASS
dk_sign:     SKIP — external, unauthenticated connection

The message was accepted and delivered to Spam. Amavis assigned it a spam score, but the purpose of this unsigned synthetic test was to observe header preservation, not DKIM or DMARC results.

This confirms that the behavior is reproducible without Zoho and is not limited to the top-level Content-Type header: a custom X-Fold-Test header is affected too.

To keep the evidence precise: this synthetic test compares the prepared outbound bytes with the stored message. I did not capture its milter conversation. The earlier Zoho test capture separately demonstrated that Postfix supplied Content-Type with the space intact and psa-pc-remote requested deletion and reinsertion without that space.

Your hypothesis about message reserialization between the framework and handlers is therefore worth investigating, but the exact internal mechanism remains unconfirmed.

No configuration changes, DMARC exclusions, or disabling of security checks were required.

Thank you for suggesting this test—it provides a much simpler reproduction.



NOTES​

----
1) You can already narrow down the responsible part
Your capture on 10024 shows that Amavis already receives the header without the space. The before-local handlers (dd52-domainkeys, dmarc, arc-sign) only run later, at delivery, so they can't be the ones that remove it. That leaves the before-queue handlers (limit-out, check-quota, spf) or the psa-pc-remote framework itself. In logs posted elsewhere on this forum, limit-out and check-quota usually just log SKIP for ordinary inbound mail, and spf only evaluates. So my guess would be the framework and not one of the handlers, but that's a guess. You can check what the handlers report for your message with the queue ID from the first Received line (the one before the filter):
Yes, we have checked this for both the original Zoho message and the independent synthetic test, using their pre-filter queue IDs.

Both show:

limit-out: SKIP
check-quota: SKIP
spf: PASS

For the synthetic test, dk_sign also explicitly reports that the connection is external and unauthenticated, so signing is skipped.

Combined with the captures, this rules out the later before-local handlers as the source of the earlier whitespace removal.

Your framework/reserialization hypothesis is consistent with the evidence, but we cannot yet distinguish the framework itself from a handler or shared processing routine based only on these statuses.


----

2) A hypothesis for the "delete and re-add" behavior (speculation)
It's unusual that a milter deletes and re-adds a header that no handler has a reason to touch, and that it does this for several headers. One possible explanation: the framework hands the message to the handlers, takes the result back, compares it with the original and sends every difference back to Postfix as change/add header commands. If anything on that way strips trailing whitespace from lines, every header with a space before a fold looks "changed" and is re-added without the space. That would match what you captured (tab kept, trailing space gone) and would also explain why more than one header was touched. The fact that your body hash still matches suggests the body lines keep their bytes. I can't prove any of this, but it's a concrete question for the developers: "Is the message re-serialized between the milter and the handlers, and is trailing whitespace removed there?"

Your reserialization hypothesis is consistent with the evidence, and the independent synthetic test strengthens it: both the custom X-Fold-Test header and Content-Type lose the trailing space before the fold, while the continuation tab is preserved.

However, we have not established the internal comparison mechanism. The milter deletes and re-adds several headers, including headers we have not shown to change. It could therefore be rebuilding a larger set of headers rather than emitting changes only for fields that compare differently.

Also, the matching body hashes confirm that the historical message's body still matches the signed hash, not that no body processing occurs. The captured test transaction includes a body-replacement command.

So the question for the developers is:

“Does psa-pc-remote, or the interface used to pass messages to and from the handlers, parse and reserialize the headers in a way that removes trailing whitespace before folded line breaks? Is this normalization performed before the headers are returned to Postfix?”

We can demonstrate the byte change and the resulting DKIM failure, but the exact internal routine remains unconfirmed.

----

3) A minimal reproduction without Zoho
For the escalation, a test that anybody can repeat is worth more than private captures. Send a crafted message from an external host (not from the Plesk server itself, so it takes the normal inbound path) with a folded Content-Type that has a space before the line break, plus a second header with the same pattern, to see whether it only affects Content-Type or every header. The file (replace the placeholders):
Code:
printf 'From: <[email protected]>\r\nTo: <[email protected]>\r\nSubject: fold test\r\nDate: <rfc-2822-date>\r\nMessage-ID: <[email protected]>\r\nMIME-Version: 1.0\r\nX-Fold-Test: aaa; \r\n\tbbb\r\nContent-Type: multipart/alternative; \r\n\tboundary="XYZ"\r\n\r\n--XYZ\r\nContent-Type: text/plain\r\n\r\nhello\r\n--XYZ--\r\n' > /root/fold-test.eml
Send it (swaks syntax from memory, please check the man page if it complains):
Code:
swaks --server <your-server> --from <[email protected]> --to <[email protected]> --data @/root/fold-test.eml
Then look at the stored message. "cat -A" makes the trailing space visible: if the space is there you see "; $", if it was removed you see ";$":
Code:
grep -A1 -i -E "^(X-Fold-Test|Content-Type: multipart)" /var/qmail/mailnames/<domain>/<user>/Maildir/new/<file> | cat -A
(Adjust the folder, the message may be in cur/ or in the .Spam folder.) If both headers lose the space, the framework strips trailing whitespace in general, and every sender using c=simple with a folded header is affected, which is a much stronger bug report than "Zoho mails go to Spam". If only Content-Type is affected, that points to code that handles MIME headers specially.

Regarding point 3: yes, we completed this reproduction from an external Windows machine, using PowerShell instead of printf/swaks. The message was sent directly to the VPS on port 25, without SMTP authentication or Zoho involvement.

Before sending, we verified that both Content-Type and the custom X-Fold-Test header contained exactly one trailing space before CRLF, followed by a tab on the continuation line. We then located the stored message by its unique Message-ID and inspected the header bytes using Python repr() rather than grep | cat -A.

Result: both headers lost the trailing space, while the continuation tab was preserved. This reproduces the behavior independently of Zoho and shows that it is not limited to MIME headers in this test. It does not establish that every header or every sender is affected.

The synthetic message was unsigned, so this test demonstrates the header modification, not a DKIM failure by itself. The cryptographic impact was established separately using the historical Zoho message: restoring only that one space made both original DKIM signatures pass, with both body hashes already matching.
 
Hi ollosolucions,

uff... that’s a clean result. A space bar is the smallest key on the keyboard, and it’s currently taking down a whole email campaign.

What your data already shows (everything below is straight from your captures and tests, nothing added by me):
  1. Postfix hands the header to the milter with the space.
  2. The milter then asks Postfix to delete the header (CHGHEADER, index 1, empty value) and re-add it (without the space).
  3. Amavis already receives it without the space (earlier test).
  4. It reproduces without Zoho and is not limited to Content-Type, because X-Fold-Test loses its space too.
  5. Restoring that one space fixes both signatures. Since only that single space was needed, every other signed header in that message must have arrived intact.
Point 5 is the interesting one for the mechanism. The milter re-adds several headers, but only the ones with a trailing space before a fold are actually damaged. The undamaged ones come back byte-identical, so you can’t see that they were touched. That doesn’t say why they were re-added, so the next step is to find that out.

What is still open
  • Your synthetic test was compared on the file level only, without a milter capture. The Zoho capture and the Amavis capture are also two different messages. Right now the evidence is three data points from three different mails.
  • We don’t know whether the milter rebuilds all headers or only some.
  • We don’t know whether this is “strips trailing whitespace” in general, or only “before a fold”.
One test message and one capture can answer all three. Here is a message with seven headers, each with a different whitespace pattern. It’s Python, so it runs on your Windows box as it is. It sends raw bytes, so smtplib doesn’t touch your spaces. I tested it against a local dummy server, and the wire bytes came out exactly as written.
Code:
import smtplib, email.utils, sys
SERVER = sys.argv[1] if len(sys.argv) > 1 else “your-server”
SENDER, RCPT = “[email protected]”, “[email protected]”
msg = (
f"From: <{SENDER}>\r\n"
f"To: <{RCPT}>\r\n"
“Subject: fold test 2\r\n”
f"Date: {email.utils.formatdate(localtime=True)}\r\n"
“Message-ID: [email protected]\r\n”
“MIME-Version: 1.0\r\n”
“X-T1: aaa; \r\n\tbbb\r\n”
“X-T2: aaa;   \r\n\tbbb\r\n”
“X-T3: aaa;\t\r\n\tbbb\r\n”
“X-T4: aaa \r\n”
“X-T5: aaa;\r\n\tbbb\r\n”
“X-T6: aaa  bbb\r\n”
“X-T7: aaa;\r\n\tbbb \r\n”
‘Content-Type: multipart/alternative; \r\n\tboundary="XYZ"\r\n’
“\r\n”
“--XYZ\r\nContent-Type: text/plain\r\n\r\nhello\r\n--XYZ--\r\n”
).encode(“ascii”)
with smtplib.SMTP(SERVER, 25) as s:
s.sendmail(SENDER, [RCPT], msg)

What each header tests:
  • T1: one space before a fold (your baseline)
  • T2: three spaces before a fold
  • T3: a tab before a fold
  • T4: a trailing space, no fold
  • T5: a fold with nothing trailing (control)
  • T6: a double space inside a value, no fold
  • T7: a trailing space on the last continuation line
Run this on the server right before you send, so one capture covers the milter and both Amavis ports for the same message:
Code:
tcpdump -i lo -s 0 -w /root/fold-test-2.pcap ‘tcp port 12768 or tcp port 10024 or tcp port 10025’

Stop it after the message has been delivered. Then dump the stored message with its header lines in order. The script prints each line with Python’s repr():
Code:
import re, sys
raw = open(sys.argv[1], “rb”).read()
head = re.split(rb”\r?\n\r?\n”, raw, maxsplit=1)[0]
for line in head.splitlines(True):
print(repr(line))

(Find the file by its Message-ID, for example with
Code:
grep -rl “[email protected]” /var/qmail/mailnames/<domain>/<user>/Maildir/
)

How to read the result
  • T1–T3 lose the whitespace: it’s a general strip of trailing whitespace at line ends before a fold.
  • T1–T3 lose it, but T4 doesn’t: it’s specific to folds.
  • T5 comes back byte-identical but shows up in a different position in the stored header block: the headers are being rebuilt and not only changed. The milter protocol distinguishes ADDHEADER (append) from INSHEADER (insert at a position), and your capture shows ADDHEADER. So compare the header order of the sent file with the stored one.
  • T6 changes: more than trailing whitespace is being normalized.
The pcap then tells you where each change happens. For each header, compare what Postfix sent to the milter (the header events from Postfix), what the milter returned (CHGHEADER/ADDHEADER), what arrived on 10024, and what was stored. If a header is already altered in the first column, Postfix is the culprit and not Plesk. If it’s intact there and altered in the second column, it’s the milter side. That table, header by header, is what I’d hand to Arsys.

One bonus: this test mail contains nothing private, so the pcap could go to the ticket without the confidentiality worries. Your production server will have captured other mail too, so cut the pcap down to the streams of this message first (or just attach the decoded table).

One more idea, only if you have a spare test VM: the same test on a second Plesk install with an older version would answer the “when did this start / is it a regression” question, which you’ve said is still open. I wouldn’t touch the production server for that.

Looking forward to the table! ️

Regards
Tessa
 
Back
Top