This is an automated email from the ASF dual-hosted git repository.
Arsnael pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git
The following commit(s) were added to refs/heads/master by this push:
new 75a3c1e7a4 [ENHANCEMENT] Prevent FETCH fragmentation (#3094)
75a3c1e7a4 is described below
commit 75a3c1e7a4ae0656ffc8558c5853aba00a4f9009
Author: Benoit TELLIER <[email protected]>
AuthorDate: Wed Jul 22 08:43:07 2026 +0200
[ENHANCEMENT] Prevent FETCH fragmentation (#3094)
---
.../james/imap/api/process/ImapProcessor.java | 5 +
.../james/imap/encode/ImapResponseComposer.java | 3 +
.../imap/encode/base/ImapResponseComposerImpl.java | 37 +++++-
.../apache/james/imap/main/ResponseEncoder.java | 9 ++
.../james/imap/message/SequencedLiteral.java | 74 ++++++++++++
.../james/imap/processor/fetch/FetchProcessor.java | 3 +
.../imap/encode/ImapResponseComposerImplTest.java | 100 ++++++++++++++++-
.../james/imap/message/SequencedLiteralTest.java | 90 +++++++++++++++
.../fetch/FetchResponseLiteralSizeTest.java | 125 +++++++++++++++++++++
.../netty/ChannelImapResponseWriter.java | 47 +++++---
10 files changed, 474 insertions(+), 19 deletions(-)
diff --git
a/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapProcessor.java
b/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapProcessor.java
index 5917f6c81a..83cebcdb23 100644
---
a/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapProcessor.java
+++
b/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapProcessor.java
@@ -67,5 +67,10 @@ public interface ImapProcessor {
void respond(ImapResponseMessage message);
void flush();
+
+ /** Flushes iff the response just written deferred a literal, so its
short-lived source is read while live. */
+ default void flushIfNeeded() {
+
+ }
}
}
diff --git
a/protocols/imap/src/main/java/org/apache/james/imap/encode/ImapResponseComposer.java
b/protocols/imap/src/main/java/org/apache/james/imap/encode/ImapResponseComposer.java
index ebf546217b..c76e0f4624 100644
---
a/protocols/imap/src/main/java/org/apache/james/imap/encode/ImapResponseComposer.java
+++
b/protocols/imap/src/main/java/org/apache/james/imap/encode/ImapResponseComposer.java
@@ -34,6 +34,9 @@ public interface ImapResponseComposer {
void flush() throws IOException;
+ /** Flushes iff a literal was deferred (its source may be short-lived),
leaving literal-free responses to batch. */
+ void flushIfNeeded() throws IOException;
+
/**
* Writes an untagged NO response. Indicates that a warning. The command
may
* still complete sucessfully.
diff --git
a/protocols/imap/src/main/java/org/apache/james/imap/encode/base/ImapResponseComposerImpl.java
b/protocols/imap/src/main/java/org/apache/james/imap/encode/base/ImapResponseComposerImpl.java
index b489204c0c..dff62000a6 100644
---
a/protocols/imap/src/main/java/org/apache/james/imap/encode/base/ImapResponseComposerImpl.java
+++
b/protocols/imap/src/main/java/org/apache/james/imap/encode/base/ImapResponseComposerImpl.java
@@ -22,6 +22,8 @@ package org.apache.james.imap.encode.base;
import static java.nio.charset.StandardCharsets.US_ASCII;
import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Optional;
import jakarta.mail.Flags;
@@ -34,7 +36,9 @@ import org.apache.james.imap.api.message.IdRange;
import org.apache.james.imap.api.message.UidRange;
import org.apache.james.imap.encode.ImapResponseComposer;
import org.apache.james.imap.encode.ImapResponseWriter;
+import org.apache.james.imap.message.BytesBackedLiteral;
import org.apache.james.imap.message.Literal;
+import org.apache.james.imap.message.SequencedLiteral;
import org.apache.james.imap.utils.FastByteArrayOutputStream;
/**
@@ -64,6 +68,9 @@ public class ImapResponseComposerImpl implements
ImapConstants, ImapResponseComp
private boolean skipNextSpace;
+ // Text chunks and literals gathered to be emitted as a single
SequencedLiteral (one flush, no copy). Null until a literal is buffered.
+ private List<Literal> pendingLiteralParts;
+
public ImapResponseComposerImpl(ImapResponseWriter writer, int bufferSize)
{
skipNextSpace = false;
this.writer = writer;
@@ -146,12 +153,26 @@ public class ImapResponseComposerImpl implements
ImapConstants, ImapResponseComp
}
public void flush() throws IOException {
- if (buffer.size() > 0) {
+ if (pendingLiteralParts != null) {
+ snapshotBufferAsPart();
+ List<Literal> parts = pendingLiteralParts;
+ // Reset before writing: the writer flush callback may re-enter
flush(), which must then be a no-op.
+ pendingLiteralParts = null;
+ writer.write(new SequencedLiteral(parts));
+ } else if (buffer.size() > 0) {
writer.write(buffer.toByteArray());
buffer.reset();
}
}
+ @Override
+ public void flushIfNeeded() throws IOException {
+ // A deferred literal may wrap short-lived content (e.g. a DB blob
stream): flush it while its source is live.
+ if (pendingLiteralParts != null) {
+ flush();
+ }
+ }
+
@Override
public ImapResponseComposer tag(Tag tag) throws IOException {
writeASCII(tag.asString());
@@ -317,11 +338,23 @@ public class ImapResponseComposerImpl implements
ImapConstants, ImapResponseComp
buffer.write(BYTE_CLOSE_BRACE);
end();
if (size > 0) {
- writer.write(literal);
+ // Defer: snapshot accumulated text then the literal, coalesced
into one SequencedLiteral at flush().
+ if (pendingLiteralParts == null) {
+ pendingLiteralParts = new ArrayList<>();
+ }
+ snapshotBufferAsPart();
+ pendingLiteralParts.add(literal);
}
return this;
}
+ private void snapshotBufferAsPart() {
+ if (buffer.size() > 0) {
+
pendingLiteralParts.add(BytesBackedLiteral.of(buffer.toByteArray()));
+ buffer.reset();
+ }
+ }
+
@Override
public ImapResponseComposer closeSquareBracket() throws IOException {
closeBracket(BYTE_CLOSE_SQUARE_BRACKET);
diff --git
a/protocols/imap/src/main/java/org/apache/james/imap/main/ResponseEncoder.java
b/protocols/imap/src/main/java/org/apache/james/imap/main/ResponseEncoder.java
index 6524bd7de4..825abcd7e3 100644
---
a/protocols/imap/src/main/java/org/apache/james/imap/main/ResponseEncoder.java
+++
b/protocols/imap/src/main/java/org/apache/james/imap/main/ResponseEncoder.java
@@ -64,4 +64,13 @@ public class ResponseEncoder implements Responder {
throw new RuntimeException(e);
}
}
+
+ @Override
+ public void flushIfNeeded() {
+ try {
+ composer.flushIfNeeded();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
}
diff --git
a/protocols/imap/src/main/java/org/apache/james/imap/message/SequencedLiteral.java
b/protocols/imap/src/main/java/org/apache/james/imap/message/SequencedLiteral.java
new file mode 100644
index 0000000000..4623d7f25d
--- /dev/null
+++
b/protocols/imap/src/main/java/org/apache/james/imap/message/SequencedLiteral.java
@@ -0,0 +1,74 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one *
+ * or more contributor license agreements. See the NOTICE file *
+ * distributed with this work for additional information *
+ * regarding copyright ownership. The ASF licenses this file *
+ * to you under the Apache License, Version 2.0 (the *
+ * "License"); you may not use this file except in compliance *
+ * with the License. You may obtain a copy of the License at *
+ * *
+ * http://www.apache.org/licenses/LICENSE-2.0 *
+ * *
+ * Unless required by applicable law or agreed to in writing, *
+ * software distributed under the License is distributed on an *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
+ * KIND, either express or implied. See the License for the *
+ * specific language governing permissions and limitations *
+ * under the License. *
+ ****************************************************************/
+
+package org.apache.james.imap.message;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.SequenceInputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.Vector;
+
+/** A {@link Literal} concatenating an ordered sequence of {@link Literal}s,
so a whole response fragment (text + literal(s) + text) is emitted in one pass
and a single flush. */
+public class SequencedLiteral implements Literal {
+ private final List<Literal> parts;
+
+ public SequencedLiteral(List<Literal> parts) {
+ this.parts = parts;
+ }
+
+ public List<Literal> parts() {
+ return parts;
+ }
+
+ @Override
+ public long size() throws IOException {
+ long size = 0;
+ for (Literal part : parts) {
+ size += part.size();
+ }
+ return size;
+ }
+
+ @Override
+ public InputStream getInputStream() throws IOException {
+ Vector<InputStream> streams = new Vector<>(parts.size());
+ for (Literal part : parts) {
+ streams.add(part.getInputStream());
+ }
+ return new SequenceInputStream(streams.elements());
+ }
+
+ @Override
+ public Optional<byte[][]> asBytesSequence() {
+ List<byte[]> chunks = new ArrayList<>();
+ for (Literal part : parts) {
+ Optional<byte[][]> partChunks = part.asBytesSequence();
+ if (partChunks.isEmpty()) {
+ // A single non-in-memory part forces the whole sequence to be
streamed.
+ return Optional.empty();
+ }
+ Collections.addAll(chunks, partChunks.get());
+ }
+ return Optional.of(chunks.toArray(new byte[0][]));
+ }
+}
diff --git
a/protocols/imap/src/main/java/org/apache/james/imap/processor/fetch/FetchProcessor.java
b/protocols/imap/src/main/java/org/apache/james/imap/processor/fetch/FetchProcessor.java
index 3be25820c7..c7e399ff74 100644
---
a/protocols/imap/src/main/java/org/apache/james/imap/processor/fetch/FetchProcessor.java
+++
b/protocols/imap/src/main/java/org/apache/james/imap/processor/fetch/FetchProcessor.java
@@ -191,6 +191,9 @@ public class FetchProcessor extends
AbstractMailboxProcessor<FetchRequest> {
public void onNext(FetchResponse fetchResponse) {
AtomicBoolean mustRequestOne = new AtomicBoolean(true);
responder.respond(fetchResponse);
+ // Flush iff a literal was deferred, while the message content is
still live; keeps the backpressure
+ // check below on an up-to-date writability, without penalizing
literal-free responses.
+ responder.flushIfNeeded();
Runnable requestOne = () -> {
if (mustRequestOne.getAndSet(false)) {
LOGGER.info("Resuming IMAP FETCH for user {}",
imapSession.getUserName().asString());
diff --git
a/protocols/imap/src/test/java/org/apache/james/imap/encode/ImapResponseComposerImplTest.java
b/protocols/imap/src/test/java/org/apache/james/imap/encode/ImapResponseComposerImplTest.java
index 9be8d9aec6..977dde53bc 100644
---
a/protocols/imap/src/test/java/org/apache/james/imap/encode/ImapResponseComposerImplTest.java
+++
b/protocols/imap/src/test/java/org/apache/james/imap/encode/ImapResponseComposerImplTest.java
@@ -19,20 +19,42 @@
package org.apache.james.imap.encode;
+import static java.nio.charset.StandardCharsets.US_ASCII;
import static org.assertj.core.api.Assertions.assertThat;
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicInteger;
+
import org.apache.james.imap.encode.base.ByteImapResponseWriter;
import org.apache.james.imap.encode.base.ImapResponseComposerImpl;
+import org.apache.james.imap.message.BytesBackedLiteral;
+import org.apache.james.imap.message.Literal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class ImapResponseComposerImplTest {
+ private AtomicInteger byteWrites;
+ private AtomicInteger literalWrites;
private ByteImapResponseWriter writer;
private ImapResponseComposer composer;
@BeforeEach
void setUp() {
- writer = new ByteImapResponseWriter();
+ byteWrites = new AtomicInteger();
+ literalWrites = new AtomicInteger();
+ writer = new ByteImapResponseWriter() {
+ @Override
+ public void write(byte[] buffer) throws IOException {
+ byteWrites.incrementAndGet();
+ super.write(buffer);
+ }
+
+ @Override
+ public void write(Literal literal) throws IOException {
+ literalWrites.incrementAndGet();
+ super.write(literal);
+ }
+ };
composer = new ImapResponseComposerImpl(writer);
}
@@ -45,4 +67,80 @@ class ImapResponseComposerImplTest {
assertThat(writer.getString()).isEqualTo(" \"?\"\r\n");
}
+
+ @Test
+ void plainResponseShouldBeWrittenAsBytesWithoutLiteralWrite() throws
Exception {
+ composer.untaggedResponse("OK completed");
+ composer.flush();
+
+ assertThat(writer.getString()).isEqualTo("* OK completed\r\n");
+ assertThat(byteWrites).hasValue(1);
+ assertThat(literalWrites).hasValue(0);
+ }
+
+ @Test
+ void fetchBodyResponseShouldBeEmittedAsASingleSequencedLiteral() throws
Exception {
+ composer.untagged().message("1").message("FETCH");
+ composer.openParen();
+ composer.message("BODY[]");
+ composer.literal(BytesBackedLiteral.of("Body of
Initial".getBytes(US_ASCII)));
+ composer.closeParen();
+ composer.end();
+ composer.flush();
+
+ assertThat(writer.getString()).isEqualTo("* 1 FETCH (BODY[]
{15}\r\nBody of Initial)\r\n");
+ // Whole response (prefix + literal + trailing ")" + CRLF) leaves
through a single writer call, no
+ // separate byte[] write that would split the literal from the ")".
+ assertThat(literalWrites).hasValue(1);
+ assertThat(byteWrites).hasValue(0);
+ }
+
+ @Test
+ void multipleLiteralsShouldBeEmittedAsASingleSequencedLiteral() throws
Exception {
+ composer.openParen();
+ composer.message("BODY[HEADER]");
+ composer.literal(BytesBackedLiteral.of("H".getBytes(US_ASCII)));
+ composer.message("BODY[TEXT]");
+ composer.literal(BytesBackedLiteral.of("T".getBytes(US_ASCII)));
+ composer.closeParen();
+ composer.end();
+ composer.flush();
+
+ assertThat(writer.getString()).isEqualTo(" (BODY[HEADER] {1}\r\nH
BODY[TEXT] {1}\r\nT)\r\n");
+ assertThat(literalWrites).hasValue(1);
+ assertThat(byteWrites).hasValue(0);
+ }
+
+ @Test
+ void flushIfNeededShouldFlushWhenTheResponseContainsALiteral() throws
Exception {
+ composer.message("BODY[]");
+ composer.literal(BytesBackedLiteral.of("body".getBytes(US_ASCII)));
+ composer.closeParen();
+ composer.end();
+ composer.flushIfNeeded();
+
+ assertThat(writer.getString()).isEqualTo(" BODY[] {4}\r\nbody)\r\n");
+ assertThat(literalWrites).hasValue(1);
+ }
+
+ @Test
+ void flushIfNeededShouldNotFlushALiteralFreeResponse() throws Exception {
+ composer.untaggedResponse("OK");
+ composer.flushIfNeeded();
+
+ assertThat(byteWrites).hasValue(0);
+ assertThat(literalWrites).hasValue(0);
+ }
+
+ @Test
+ void emptyLiteralShouldNotForceASequencedLiteral() throws Exception {
+ composer.message("BODY[]");
+ composer.literal(BytesBackedLiteral.of(new byte[0]));
+ composer.end();
+ composer.flush();
+
+ assertThat(writer.getString()).isEqualTo(" BODY[] {0}\r\n\r\n");
+ assertThat(byteWrites).hasValue(1);
+ assertThat(literalWrites).hasValue(0);
+ }
}
diff --git
a/protocols/imap/src/test/java/org/apache/james/imap/message/SequencedLiteralTest.java
b/protocols/imap/src/test/java/org/apache/james/imap/message/SequencedLiteralTest.java
new file mode 100644
index 0000000000..509bea47b2
--- /dev/null
+++
b/protocols/imap/src/test/java/org/apache/james/imap/message/SequencedLiteralTest.java
@@ -0,0 +1,90 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one *
+ * or more contributor license agreements. See the NOTICE file *
+ * distributed with this work for additional information *
+ * regarding copyright ownership. The ASF licenses this file *
+ * to you under the Apache License, Version 2.0 (the *
+ * "License"); you may not use this file except in compliance *
+ * with the License. You may obtain a copy of the License at *
+ * *
+ * http://www.apache.org/licenses/LICENSE-2.0 *
+ * *
+ * Unless required by applicable law or agreed to in writing, *
+ * software distributed under the License is distributed on an *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
+ * KIND, either express or implied. See the License for the *
+ * specific language governing permissions and limitations *
+ * under the License. *
+ ****************************************************************/
+
+package org.apache.james.imap.message;
+
+import static java.nio.charset.StandardCharsets.US_ASCII;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.util.List;
+import java.util.Optional;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.Test;
+
+class SequencedLiteralTest {
+ private static Literal streamedLiteral(byte[] content) {
+ return new Literal() {
+ @Override
+ public long size() {
+ return content.length;
+ }
+
+ @Override
+ public InputStream getInputStream() {
+ return new ByteArrayInputStream(content);
+ }
+ };
+ }
+
+ @Test
+ void sizeShouldBeTheSumOfTheParts() throws Exception {
+ SequencedLiteral literal = new SequencedLiteral(List.of(
+ BytesBackedLiteral.of("ab".getBytes(US_ASCII)),
+ BytesBackedLiteral.of("cde".getBytes(US_ASCII))));
+
+ assertThat(literal.size()).isEqualTo(5);
+ }
+
+ @Test
+ void getInputStreamShouldConcatenateTheParts() throws Exception {
+ SequencedLiteral literal = new SequencedLiteral(List.of(
+ BytesBackedLiteral.of(" (BODY[] {15}\r\n".getBytes(US_ASCII)),
+ BytesBackedLiteral.of("Body of Initial".getBytes(US_ASCII)),
+ BytesBackedLiteral.of(")\r\n".getBytes(US_ASCII))));
+
+ assertThat(new String(IOUtils.toByteArray(literal.getInputStream()),
US_ASCII))
+ .isEqualTo(" (BODY[] {15}\r\nBody of Initial)\r\n");
+ }
+
+ @Test
+ void asBytesSequenceShouldAggregateChunksInOrderWhenAllPartsAreInMemory()
throws Exception {
+ SequencedLiteral literal = new SequencedLiteral(List.of(
+ BytesBackedLiteral.of("ab".getBytes(US_ASCII)),
+ BytesBackedLiteral.of("cde".getBytes(US_ASCII))));
+
+ Optional<byte[][]> chunks = literal.asBytesSequence();
+
+ assertThat(chunks).isPresent();
+ assertThat(chunks.get().length).isEqualTo(2);
+ assertThat(new String(chunks.get()[0], US_ASCII)).isEqualTo("ab");
+ assertThat(new String(chunks.get()[1], US_ASCII)).isEqualTo("cde");
+ }
+
+ @Test
+ void asBytesSequenceShouldBeEmptyWhenAPartIsNotInMemory() {
+ SequencedLiteral literal = new SequencedLiteral(List.of(
+ BytesBackedLiteral.of("ab".getBytes(US_ASCII)),
+ streamedLiteral("cde".getBytes(US_ASCII))));
+
+ assertThat(literal.asBytesSequence()).isEmpty();
+ }
+}
diff --git
a/protocols/imap/src/test/java/org/apache/james/imap/processor/fetch/FetchResponseLiteralSizeTest.java
b/protocols/imap/src/test/java/org/apache/james/imap/processor/fetch/FetchResponseLiteralSizeTest.java
new file mode 100644
index 0000000000..3fbdd78f33
--- /dev/null
+++
b/protocols/imap/src/test/java/org/apache/james/imap/processor/fetch/FetchResponseLiteralSizeTest.java
@@ -0,0 +1,125 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one *
+ * or more contributor license agreements. See the NOTICE file *
+ * distributed with this work for additional information *
+ * regarding copyright ownership. The ASF licenses this file *
+ * to you under the Apache License, Version 2.0 (the *
+ * "License"); you may not use this file except in compliance *
+ * with the License. You may obtain a copy of the License at *
+ * *
+ * http://www.apache.org/licenses/LICENSE-2.0 *
+ * *
+ * Unless required by applicable law or agreed to in writing, *
+ * software distributed under the License is distributed on an *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
+ * KIND, either express or implied. See the License for the *
+ * specific language governing permissions and limitations *
+ * under the License. *
+ ****************************************************************/
+
+package org.apache.james.imap.processor.fetch;
+
+import static java.nio.charset.StandardCharsets.US_ASCII;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.james.imap.encode.FetchResponseEncoder;
+import org.apache.james.imap.encode.ImapResponseComposer;
+import org.apache.james.imap.encode.base.ByteImapResponseWriter;
+import org.apache.james.imap.encode.base.ImapResponseComposerImpl;
+import org.apache.james.imap.message.response.FetchResponse;
+import org.apache.james.imap.message.response.FetchResponse.BodyElement;
+import org.apache.james.mailbox.MessageSequenceNumber;
+import org.apache.james.mailbox.model.ByteContent;
+import org.apache.james.mailbox.model.Header;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Byte-exact regression guard: the {@code {N}} literal advertised for every
{@code BODY[...]} section MUST equal
+ * the exact number of octets streamed between {@code {N}\r\n} and the closing
{@code )}. An undercount here
+ * mis-frames the response for a byte-exact client (the leftover octets
surface as a spurious tagged line).
+ *
+ * <p>Unlike the MPT {@code .test} scripts (which are line-oriented and never
frame the literal by its declared
+ * size), this test parses the raw wire bytes and asserts declared-vs-streamed
equality.
+ */
+class FetchResponseLiteralSizeTest {
+ private static final MessageSequenceNumber MSN =
MessageSequenceNumber.of(1);
+ // Matches the FIRST literal marker "{N}\r\n" in the response.
+ private static final Pattern LITERAL = Pattern.compile("\\{(\\d+)}\r\n");
+
+ private byte[] encode(BodyElement element) throws Exception {
+ ByteImapResponseWriter writer = new ByteImapResponseWriter();
+ ImapResponseComposer composer = new ImapResponseComposerImpl(writer);
+ FetchResponse response = new FetchResponse(MSN, null, null, null,
null, null, null,
+ null, null, null, List.of(element), null, null);
+ new FetchResponseEncoder(false).encode(response, composer);
+ composer.flush();
+ return writer.getBytes();
+ }
+
+ /**
+ * Parses "... {N}\r\n<content>)\r\n" and returns [declaredN,
measuredContentLength].
+ * The response always ends with the section-closing ")" then CRLF, so the
streamed content is everything
+ * between the "{N}\r\n" marker and the trailing ")\r\n".
+ */
+ private long[] declaredVsStreamed(byte[] response) {
+ String asString = new String(response, US_ASCII);
+ Matcher matcher = LITERAL.matcher(asString);
+ assertThat(matcher.find()).as("response must contain a literal marker:
%s", asString).isTrue();
+ long declared = Long.parseLong(matcher.group(1));
+ int contentStart = matcher.end();
+ // Response tail is ")\r\n" (3 octets): ")" closing the FETCH paren,
then CRLF.
+ int contentEnd = response.length - 3;
+ assertThat(new String(response, contentEnd, 3,
US_ASCII)).isEqualTo(")\r\n");
+ return new long[] {declared, contentEnd - contentStart};
+ }
+
+ private void assertLiteralIsExact(BodyElement element) throws Exception {
+ long[] declaredVsStreamed = declaredVsStreamed(encode(element));
+ assertThat(declaredVsStreamed[0])
+ .as("declared {%d} must equal streamed %d octets",
declaredVsStreamed[0], declaredVsStreamed[1])
+ .isEqualTo(declaredVsStreamed[1]);
+ }
+
+ @Test
+ void headerFieldsMessageIdShouldAdvertiseExactLiteralSize() throws
Exception {
+ // The exact shape reported by the failing client: BODY[HEADER.FIELDS
(MESSAGE-ID)] with a single header.
+ // Streamed content is "Message-ID: <initial@source>\r\n" (30) +
section-terminating "\r\n" (2) = 32 octets.
+ HeaderBodyElement element = new HeaderBodyElement("BODY[HEADER.FIELDS
(MESSAGE-ID)]",
+ List.of(new Header("Message-ID", "<initial@source>")));
+
+ long[] declaredVsStreamed = declaredVsStreamed(encode(element));
+
+ assertThat(declaredVsStreamed[0]).isEqualTo(32);
+ assertThat(declaredVsStreamed[1]).isEqualTo(32);
+ }
+
+ @Test
+ void headerFieldsWithSeveralHeadersShouldAdvertiseExactLiteralSize()
throws Exception {
+ assertLiteralIsExact(new HeaderBodyElement("BODY[HEADER.FIELDS (FROM
TO SUBJECT)]",
+ List.of(
+ new Header("From", "Timothy Tayler <[email protected]>"),
+ new Header("To", "Samual Smith <[email protected]>"),
+ new Header("Subject", "A Simple Email"))));
+ }
+
+ @Test
+ void fullBodyShouldAdvertiseExactLiteralSize() throws Exception {
+ byte[] message = ("Message-ID: <initial@source>\r\n"
+ + "Subject: A Simple Email\r\n"
+ + "\r\n"
+ + "This is a very simple email.\r\n").getBytes(US_ASCII);
+ assertLiteralIsExact(new ContentBodyElement("BODY[]", new
ByteContent(message)));
+ }
+
+ @Test
+ void headerBlockShouldAdvertiseExactLiteralSize() throws Exception {
+ byte[] headerBlock = ("Message-ID: <initial@source>\r\n"
+ + "Subject: A Simple Email\r\n"
+ + "\r\n").getBytes(US_ASCII);
+ assertLiteralIsExact(new ContentBodyElement("BODY[HEADER]", new
ByteContent(headerBlock)));
+ }
+}
diff --git
a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ChannelImapResponseWriter.java
b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ChannelImapResponseWriter.java
index 3ab11de936..362c4ee729 100644
---
a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ChannelImapResponseWriter.java
+++
b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ChannelImapResponseWriter.java
@@ -25,10 +25,12 @@ import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.channels.FileChannel;
+import java.util.Optional;
import org.apache.james.imap.api.process.ImapSession;
import org.apache.james.imap.encode.ImapResponseWriter;
import org.apache.james.imap.message.Literal;
+import org.apache.james.imap.message.SequencedLiteral;
import org.apache.james.util.MDCStructuredLogger;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -97,25 +99,38 @@ public class ChannelImapResponseWriter implements
ImapResponseWriter {
public void write(Literal literal) throws IOException {
flushCallback.run();
if (channel.isActive()) {
- if (literal.asBytesSequence().isPresent()) {
-
channel.writeAndFlush(Unpooled.wrappedBuffer(literal.asBytesSequence().get()));
- return;
- }
- InputStream in = literal.getInputStream();
- if (in instanceof FileInputStream) {
- FileChannel fc = ((FileInputStream) in).getChannel();
- // Zero-copy is only possible if no SSL/TLS and no COMPRESS
is in place
- //
- // See JAMES-1305 and JAMES-1306
- ChannelPipeline cp = channel.pipeline();
- if (zeroCopy && cp.get(SslHandler.class) == null &&
cp.get(ZlibEncoder.class) == null) {
- channel.writeAndFlush(new DefaultFileRegion(fc,
fc.position(), literal.size()));
- } else {
- channel.writeAndFlush(new ChunkedNioFile(fc, 8192));
+ // A SequencedLiteral's parts are written in one pass and flushed
once, each keeping its best representation.
+ if (literal instanceof SequencedLiteral sequencedLiteral) {
+ for (Literal part : sequencedLiteral.parts()) {
+ writePart(part);
}
} else {
- channel.writeAndFlush(new ChunkedStream(in));
+ writePart(literal);
+ }
+ channel.flush();
+ }
+ }
+
+ private void writePart(Literal literal) throws IOException {
+ Optional<byte[][]> bytesSequence = literal.asBytesSequence();
+ if (bytesSequence.isPresent()) {
+ channel.write(Unpooled.wrappedBuffer(bytesSequence.get()));
+ return;
+ }
+ InputStream in = literal.getInputStream();
+ if (in instanceof FileInputStream) {
+ FileChannel fc = ((FileInputStream) in).getChannel();
+ // Zero-copy is only possible if no SSL/TLS and no COMPRESS is in
place
+ //
+ // See JAMES-1305 and JAMES-1306
+ ChannelPipeline cp = channel.pipeline();
+ if (zeroCopy && cp.get(SslHandler.class) == null &&
cp.get(ZlibEncoder.class) == null) {
+ channel.write(new DefaultFileRegion(fc, fc.position(),
literal.size()));
+ } else {
+ channel.write(new ChunkedNioFile(fc, 8192));
}
+ } else {
+ channel.write(new ChunkedStream(in));
}
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]