From 98123e22d53ae828587b3488c7c2442d6c65b834 Mon Sep 17 00:00:00 2001 From: Rawvoid Date: Fri, 17 Apr 2026 10:44:05 +0800 Subject: [PATCH] fix: normalize folded headers before SMTP send --- .../io/vertx/ext/mail/impl/SMTPSendMail.java | 2 +- .../java/io/vertx/ext/mail/impl/Utils.java | 14 +++++++ .../vertx/ext/mail/impl/dkim/DKIMSigner.java | 9 +++-- .../mail/client/MailHeaderLineBreakTest.java | 37 +++++++++++++++++++ .../mail/internal/dkim/DKIMSignerTest.java | 6 +-- 5 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 src/test/java/io/vertx/tests/mail/client/MailHeaderLineBreakTest.java diff --git a/src/main/java/io/vertx/ext/mail/impl/SMTPSendMail.java b/src/main/java/io/vertx/ext/mail/impl/SMTPSendMail.java index 2a1a159a..f3794cba 100644 --- a/src/main/java/io/vertx/ext/mail/impl/SMTPSendMail.java +++ b/src/main/java/io/vertx/ext/mail/impl/SMTPSendMail.java @@ -287,7 +287,7 @@ private Future sendMailData(boolean includeData) { private Future sendMailHeaders(MultiMap headers) { StringBuilder sb = new StringBuilder(); - headers.forEach(header -> sb.append(header.getKey()).append(": ").append(header.getValue()).append("\r\n")); + headers.forEach(header -> sb.append(header.getKey()).append(": ").append(Utils.normalizeSmtpLineBreaks(header.getValue())).append("\r\n")); final String headerLines = sb.toString(); return connection.writeLineWithDrain(headerLines, written.getAndAdd(headerLines.length()) < 1000); } diff --git a/src/main/java/io/vertx/ext/mail/impl/Utils.java b/src/main/java/io/vertx/ext/mail/impl/Utils.java index 13ac89ec..5b09e162 100644 --- a/src/main/java/io/vertx/ext/mail/impl/Utils.java +++ b/src/main/java/io/vertx/ext/mail/impl/Utils.java @@ -129,4 +129,18 @@ public static List asList(T element) { return Collections.singletonList(element); } + /** + * Normalizes embedded header line breaks to CRLF so folded headers are sent + * on the wire with RFC-compliant separators. + * + * @param value the header value + * @return the normalized header value + */ + public static String normalizeSmtpLineBreaks(String value) { + if (value == null || value.indexOf('\n') == -1 && value.indexOf('\r') == -1) { + return value; + } + return value.replace("\r\n", "\n").replace('\r', '\n').replace("\n", "\r\n"); + } + } diff --git a/src/main/java/io/vertx/ext/mail/impl/dkim/DKIMSigner.java b/src/main/java/io/vertx/ext/mail/impl/dkim/DKIMSigner.java index 125eacff..bdabfa7e 100644 --- a/src/main/java/io/vertx/ext/mail/impl/dkim/DKIMSigner.java +++ b/src/main/java/io/vertx/ext/mail/impl/dkim/DKIMSigner.java @@ -16,6 +16,8 @@ package io.vertx.ext.mail.impl.dkim; +import static io.vertx.ext.mail.impl.Utils.normalizeSmtpLineBreaks; + import io.vertx.codegen.annotations.Nullable; import io.vertx.core.*; import io.vertx.core.buffer.Buffer; @@ -414,7 +416,7 @@ private String copiedHeaders(List headers, EncodedPart encodedMessage) { return headers.stream().map(h -> { String hValue = encodedMessage.headers().get(h); if (hValue != null) { - return h + ":" + dkimQuotedPrintableCopiedHeader(hValue); + return h + ":" + dkimQuotedPrintableCopiedHeader(normalizeSmtpLineBreaks(hValue)); } throw new RuntimeException("Unknown email header: " + h + " in copied headers."); }).collect(Collectors.joining("|")); @@ -444,11 +446,12 @@ private String dkimQuotedPrintableCopiedHeader(String value) { * @return the canonicalization email header in format of 'Name':'Value'. */ public String canonicHeader(String emailHeaderName, String emailHeaderValue) { + String normalizedHeaderValue = normalizeSmtpLineBreaks(emailHeaderValue); if (this.dkimSignOptions.getHeaderCanonAlgo() == CanonicalizationAlgorithm.SIMPLE) { - return emailHeaderName + ": " + emailHeaderValue; + return emailHeaderName + ": " + normalizedHeaderValue; } String headerName = emailHeaderName.trim().toLowerCase(); - return headerName + ":" + canonicalLine(emailHeaderValue, this.dkimSignOptions.getHeaderCanonAlgo()); + return headerName + ":" + canonicalLine(normalizedHeaderValue, this.dkimSignOptions.getHeaderCanonAlgo()); } public String dkimMailBody(String mailBody) { diff --git a/src/test/java/io/vertx/tests/mail/client/MailHeaderLineBreakTest.java b/src/test/java/io/vertx/tests/mail/client/MailHeaderLineBreakTest.java new file mode 100644 index 00000000..af556f34 --- /dev/null +++ b/src/test/java/io/vertx/tests/mail/client/MailHeaderLineBreakTest.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2011-2026 The original author or authors + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Apache License v2.0 which accompanies this distribution. + * + * The Eclipse Public License is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * The Apache License v2.0 is available at + * http://www.opensource.org/licenses/apache2.0.php + * + * You may elect to redistribute this code under either of these licenses. + */ + +package io.vertx.tests.mail.client; + +import io.vertx.ext.mail.MailClient; +import io.vertx.ext.mail.MailMessage; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(VertxUnitRunner.class) +public class MailHeaderLineBreakTest extends SMTPTestWiser { + + @Test + public void testFoldedSubjectUsesCrLf(TestContext testContext) { + this.testContext = testContext; + MailClient mailClient = mailClientLogin(); + + MailMessage message = exampleMessage().setSubject("Unicode folding test ⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐"); + testSuccess(mailClient, message); + } +} diff --git a/src/test/java/io/vertx/tests/mail/internal/dkim/DKIMSignerTest.java b/src/test/java/io/vertx/tests/mail/internal/dkim/DKIMSignerTest.java index b96633f0..e0cff07d 100644 --- a/src/test/java/io/vertx/tests/mail/internal/dkim/DKIMSignerTest.java +++ b/src/test/java/io/vertx/tests/mail/internal/dkim/DKIMSignerTest.java @@ -172,7 +172,7 @@ public void testDKIMHeaderTemplate() { public void testSimpleHeader() { DKIMSignOptions dkimOps = dkimOps().setHeaderCanonAlgo(CanonicalizationAlgorithm.SIMPLE); DKIMSigner signer = new DKIMSigner(dkimOps, null); - // there will be possible to have \n in the header value, keep it as it is. + // folded headers are normalized to CRLF before signing. String name = "from"; String value = "user@example.com"; String canonicHeaderLine = signer.canonicHeader(name, value); @@ -191,14 +191,14 @@ public void testSimpleHeader() { name = " from "; value = " user@example.com \n "; canonicHeaderLine = signer.canonicHeader(name, value); - assertEquals(" from : user@example.com \n ", canonicHeaderLine); + assertEquals(" from : user@example.com \r\n ", canonicHeaderLine); } @Test public void testRelaxedHeader() { DKIMSignOptions dkimOps = dkimOps().setHeaderCanonAlgo(CanonicalizationAlgorithm.RELAXED); DKIMSigner signer = new DKIMSigner(dkimOps, null); - // there will be possible to have \n in the header value + // folded headers are normalized to CRLF before relaxed canonicalization. String name = "From"; String value = "user@example.com"; String canonicHeaderLine = signer.canonicHeader(name, value);