This is an automated email from the ASF dual-hosted git repository.

rcordier 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 aee6550277 JAMES-3995 Optimize Email/get
aee6550277 is described below

commit aee6550277db4e31099867e1d29c7598f9a4dad9
Author: Benoit TELLIER <btell...@linagora.com>
AuthorDate: Thu Feb 22 22:47:26 2024 +0100

    JAMES-3995 Optimize Email/get
---
 .../org/apache/james/jmap/api/model/Preview.java   |   2 +
 .../AvoidBinaryBodyBufferingBodyFactory.java       | 366 +++++++++++++++++++++
 .../apache/james/jmap/mime4j/FakeBinaryBody.java   |  64 ++++
 .../org/apache/james/jmap/mime4j/SizeUtils.java    | 152 +++++++++
 .../ComputeMessageFastViewProjectionListener.java  |   4 +-
 .../src/main/resources/eml/nested2.eml             |  77 +++++
 .../rfc8621/contract/EmailGetMethodContract.scala  |  63 +++-
 .../contract/EmailParseMethodContract.scala        |   2 +-
 .../scala/org/apache/james/jmap/mail/Email.scala   |   6 +-
 .../org/apache/james/jmap/mail/EmailBodyPart.scala |  17 +-
 10 files changed, 734 insertions(+), 19 deletions(-)

diff --git 
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/model/Preview.java
 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/model/Preview.java
index 5474b6211c..2ef3ecc876 100644
--- 
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/model/Preview.java
+++ 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/model/Preview.java
@@ -29,6 +29,7 @@ import java.util.Objects;
 import javax.inject.Inject;
 
 import org.apache.commons.lang3.StringUtils;
+import org.apache.james.jmap.mime4j.AvoidBinaryBodyBufferingBodyFactory;
 import org.apache.james.mailbox.exception.MailboxException;
 import org.apache.james.mailbox.model.MessageResult;
 import org.apache.james.mime4j.dom.Message;
@@ -81,6 +82,7 @@ public class Preview {
         private Message parse(InputStream inputStream) throws IOException {
             DefaultMessageBuilder defaultMessageBuilder = new 
DefaultMessageBuilder();
             defaultMessageBuilder.setMimeEntityConfig(MimeConfig.PERMISSIVE);
+            defaultMessageBuilder.setBodyFactory(new 
AvoidBinaryBodyBufferingBodyFactory());
             return defaultMessageBuilder.parseMessage(inputStream);
         }
     }
diff --git 
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/mime4j/AvoidBinaryBodyBufferingBodyFactory.java
 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/mime4j/AvoidBinaryBodyBufferingBodyFactory.java
new file mode 100644
index 0000000000..42abd42f2d
--- /dev/null
+++ 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/mime4j/AvoidBinaryBodyBufferingBodyFactory.java
@@ -0,0 +1,366 @@
+/****************************************************************
+ * 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.jmap.mime4j;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.Reader;
+import java.io.StringReader;
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.Charset;
+import java.nio.charset.IllegalCharsetNameException;
+import java.nio.charset.UnsupportedCharsetException;
+
+import org.apache.james.mime4j.Charsets;
+import org.apache.james.mime4j.dom.BinaryBody;
+import org.apache.james.mime4j.dom.SingleBody;
+import org.apache.james.mime4j.dom.TextBody;
+import org.apache.james.mime4j.io.InputStreams;
+import org.apache.james.mime4j.message.BasicBodyFactory;
+import org.apache.james.mime4j.message.BodyFactory;
+import org.apache.james.mime4j.util.ByteArrayOutputStreamRecycler;
+import org.apache.james.mime4j.util.ContentUtil;
+
+import com.google.common.io.CountingOutputStream;
+
+/**
+ * Factory for creating message bodies.
+ */
+public class AvoidBinaryBodyBufferingBodyFactory implements BodyFactory {
+
+    public static final BasicBodyFactory INSTANCE = new BasicBodyFactory();
+
+    private final Charset defaultCharset;
+
+    public AvoidBinaryBodyBufferingBodyFactory() {
+        this(true);
+    }
+
+    public AvoidBinaryBodyBufferingBodyFactory(final Charset defaultCharset) {
+        this.defaultCharset = defaultCharset;
+    }
+
+    public AvoidBinaryBodyBufferingBodyFactory(final boolean lenient) {
+        this(lenient ? Charset.defaultCharset() : null);
+    }
+
+    /**
+     * @return the defaultCharset
+     */
+    public Charset getDefaultCharset() {
+        return defaultCharset;
+    }
+
+    /**
+     * <p>
+     * Select the Charset for the given <code>mimeCharset</code> string.
+     * </p>
+     * <p>
+     * If you need support for non standard or invalid 
<code>mimeCharset</code> specifications you might want to
+     * create your own derived {@link BodyFactory} extending {@link 
BasicBodyFactory} and overriding this method as
+     * suggested by <a 
href="https://issues.apache.org/jira/browse/MIME4J-218";>MIME4J-218</a>
+     * </p>
+     * <p>
+     * The default behavior is lenient, invalid <code>mimeCharset</code> 
specifications will return the
+     * <code>defaultCharset</code>.
+     * </p>
+     *
+     * @param mimeCharset - the string specification for a Charset e.g. "UTF-8"
+     * @throws UnsupportedEncodingException if the mimeCharset is invalid
+     */
+    protected Charset resolveCharset(final String mimeCharset) throws 
UnsupportedEncodingException {
+        if (mimeCharset != null) {
+            try {
+                return Charset.forName(mimeCharset);
+            } catch (UnsupportedCharsetException ex) {
+                if (defaultCharset == null) {
+                    throw new UnsupportedEncodingException(mimeCharset);
+                }
+            } catch (IllegalCharsetNameException ex) {
+                if (defaultCharset == null) {
+                    throw new UnsupportedEncodingException(mimeCharset);
+                }
+            }
+        }
+        return defaultCharset;
+    }
+
+    public TextBody textBody(final String text, final String mimeCharset) 
throws UnsupportedEncodingException {
+        if (text == null) {
+            throw new IllegalArgumentException("Text may not be null");
+        }
+        return new StringBody1(text, resolveCharset(mimeCharset));
+    }
+
+    public TextBody textBody(final byte[] content, final Charset charset) {
+        if (content == null) {
+            throw new IllegalArgumentException("Content may not be null");
+        }
+        return new StringBody2(content, charset);
+    }
+
+    public TextBody textBody(final InputStream content, final String 
mimeCharset) throws IOException {
+        if (content == null) {
+            throw new IllegalArgumentException("Input stream may not be null");
+        }
+        return new StringBody3(ContentUtil.bufferEfficient(content), 
resolveCharset(mimeCharset));
+    }
+
+    public TextBody textBody(final String text, final Charset charset) {
+        if (text == null) {
+            throw new IllegalArgumentException("Text may not be null");
+        }
+        return new StringBody1(text, charset);
+    }
+
+    public TextBody textBody(final String text) {
+        return textBody(text, Charsets.DEFAULT_CHARSET);
+    }
+
+    public BinaryBody binaryBody(final String content, final Charset charset) {
+        if (content == null) {
+            throw new IllegalArgumentException("Content may not be null");
+        }
+        return new BinaryBody2(content, charset);
+    }
+
+    public BinaryBody binaryBody(final InputStream is) throws IOException {
+        CountingOutputStream out = new 
CountingOutputStream(OutputStream.nullOutputStream());
+        is.transferTo(out);
+        return new FakeBinaryBody(out.getCount());
+    }
+
+    public BinaryBody binaryBody(final byte[] buf) {
+        return new BinaryBody1(buf);
+    }
+
+    static class StringBody1 extends TextBody {
+
+        private final String content;
+        private final Charset charset;
+
+        StringBody1(final String content, final Charset charset) {
+            super();
+            this.content = content;
+            this.charset = charset;
+        }
+
+        @Override
+        public String getMimeCharset() {
+            return this.charset != null ? this.charset.name() : null;
+        }
+
+        @Override
+        public Charset getCharset() {
+            return charset;
+        }
+
+        @Override
+        public Reader getReader() throws IOException {
+            return new StringReader(this.content);
+        }
+
+        @Override
+        public InputStream getInputStream() throws IOException {
+            return InputStreams.create(this.content,
+                    this.charset != null ? this.charset : 
Charsets.DEFAULT_CHARSET);
+        }
+
+        @Override
+        public void dispose() {
+        }
+
+        @Override
+        public SingleBody copy() {
+            return new StringBody1(this.content, this.charset);
+        }
+
+    }
+
+    static class StringBody2 extends TextBody {
+
+        private final byte[] content;
+        private final Charset charset;
+
+        StringBody2(final byte[] content, final Charset charset) {
+            super();
+            this.content = content;
+            this.charset = charset;
+        }
+
+        @Override
+        public String getMimeCharset() {
+            return this.charset != null ? this.charset.name() : null;
+        }
+
+        @Override
+        public Charset getCharset() {
+            return charset;
+        }
+
+        @Override
+        public Reader getReader() throws IOException {
+            return new InputStreamReader(InputStreams.create(this.content), 
this.charset);
+        }
+
+        @Override
+        public InputStream getInputStream() throws IOException {
+            return InputStreams.create(this.content);
+        }
+
+        @Override
+        public void writeTo(OutputStream out) throws IOException {
+            out.write(content);
+        }
+
+        @Override
+        public long size() {
+            return content.length;
+        }
+
+        @Override
+        public void dispose() {
+        }
+
+        @Override
+        public SingleBody copy() {
+            return new StringBody2(this.content, this.charset);
+        }
+
+    }
+
+    static class StringBody3 extends TextBody {
+
+        private final ByteArrayOutputStreamRecycler.Wrapper content;
+        private final Charset charset;
+
+        StringBody3(final ByteArrayOutputStreamRecycler.Wrapper content, final 
Charset charset) {
+            super();
+            this.content = content;
+            this.charset = charset;
+        }
+
+        @Override
+        public String getMimeCharset() {
+            return this.charset != null ? this.charset.name() : null;
+        }
+
+        @Override
+        public Charset getCharset() {
+            return charset;
+        }
+
+        @Override
+        public Reader getReader() throws IOException {
+            return new 
InputStreamReader(this.content.getValue().toInputStream(), this.charset);
+        }
+
+        @Override
+        public InputStream getInputStream() throws IOException {
+            return this.content.getValue().toInputStream();
+        }
+
+        @Override
+        public long size() {
+            return content.getValue().size();
+        }
+
+        @Override
+        public void writeTo(OutputStream out) throws IOException {
+            content.getValue().writeTo(out);
+        }
+
+        @Override
+        public void dispose() {
+            this.content.release();
+        }
+
+        @Override
+        public SingleBody copy() {
+            return new StringBody3(this.content, this.charset);
+        }
+
+    }
+
+    static class BinaryBody2 extends BinaryBody {
+
+        private final String content;
+        private final Charset charset;
+
+        BinaryBody2(final String content, final Charset charset) {
+            super();
+            this.content = content;
+            this.charset = charset;
+        }
+
+        @Override
+        public InputStream getInputStream() throws IOException {
+            return InputStreams.create(this.content,
+                this.charset != null ? this.charset : 
Charsets.DEFAULT_CHARSET);
+        }
+
+        @Override
+        public void dispose() {
+        }
+
+        @Override
+        public SingleBody copy() {
+            return new BinaryBody2(this.content, this.charset);
+        }
+
+    }
+
+    static class BinaryBody1 extends BinaryBody {
+
+        private final byte[] content;
+
+        BinaryBody1(final byte[] content) {
+            super();
+            this.content = content;
+        }
+
+        @Override
+        public InputStream getInputStream() throws IOException {
+            return InputStreams.create(this.content);
+        }
+
+        @Override
+        public void writeTo(OutputStream out) throws IOException {
+            out.write(content);
+        }
+
+        @Override
+        public long size() {
+            return content.length;
+        }
+
+        @Override
+        public void dispose() {
+        }
+
+        @Override
+        public SingleBody copy() {
+            return new BinaryBody1(this.content);
+        }
+
+    }
+}
diff --git 
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/mime4j/FakeBinaryBody.java
 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/mime4j/FakeBinaryBody.java
new file mode 100644
index 0000000000..687567448e
--- /dev/null
+++ 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/mime4j/FakeBinaryBody.java
@@ -0,0 +1,64 @@
+/****************************************************************
+ * 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.jmap.mime4j;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import org.apache.commons.lang3.NotImplementedException;
+import org.apache.james.mime4j.dom.BinaryBody;
+import org.apache.james.mime4j.dom.SingleBody;
+
+public class FakeBinaryBody extends BinaryBody {
+    private final long size;
+
+    FakeBinaryBody(long size) {
+        this.size = size;
+    }
+
+    @Override
+    public InputStream getInputStream() throws IOException {
+        throw new NotImplementedException();
+    }
+
+    @Override
+    public void writeTo(OutputStream out) throws IOException {
+        throw new NotImplementedException();
+    }
+
+    @Override
+    public long size() {
+        return size;
+    }
+
+    public long getSize() {
+        return size;
+    }
+
+    @Override
+    public void dispose() {
+    }
+
+    @Override
+    public SingleBody copy() {
+        return new FakeBinaryBody(size);
+    }
+}
diff --git 
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/mime4j/SizeUtils.java
 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/mime4j/SizeUtils.java
new file mode 100644
index 0000000000..0a85a3357b
--- /dev/null
+++ 
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/mime4j/SizeUtils.java
@@ -0,0 +1,152 @@
+/****************************************************************
+ * 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.jmap.mime4j;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.apache.james.mime4j.dom.Body;
+import org.apache.james.mime4j.dom.Entity;
+import org.apache.james.mime4j.dom.Header;
+import org.apache.james.mime4j.dom.Message;
+import org.apache.james.mime4j.dom.Multipart;
+import org.apache.james.mime4j.dom.SingleBody;
+import org.apache.james.mime4j.dom.field.ContentTypeField;
+import org.apache.james.mime4j.dom.field.FieldName;
+import org.apache.james.mime4j.message.BodyPart;
+import org.apache.james.mime4j.message.DefaultMessageWriter;
+import org.apache.james.mime4j.message.MessageImpl;
+import org.apache.james.mime4j.message.MultipartImpl;
+import org.apache.james.mime4j.util.ByteSequence;
+import org.apache.james.mime4j.util.ContentUtil;
+
+import com.google.common.io.CountingOutputStream;
+
+public class SizeUtils {
+    public static long sizeOf(Entity entity) throws IOException {
+        if (entity instanceof BodyPart) {
+            BodyPart bodyPart = (BodyPart) entity;
+
+            return sizeOf(bodyPart.getBody());
+        }
+        if (entity instanceof MessageImpl) {
+            MessageImpl bodyPart = (MessageImpl) entity;
+
+            return sizeOf(bodyPart.getBody());
+        }
+        CountingOutputStream countingOutputStream = new 
CountingOutputStream(OutputStream.nullOutputStream());
+        DefaultMessageWriter defaultMessageWriter = new DefaultMessageWriter();
+        defaultMessageWriter.writeEntity(entity, countingOutputStream);
+        return countingOutputStream.getCount();
+    }
+
+    public static long sizeOf(Header header) throws IOException {
+        CountingOutputStream countingOutputStream = new 
CountingOutputStream(OutputStream.nullOutputStream());
+        DefaultMessageWriter defaultMessageWriter = new DefaultMessageWriter();
+        defaultMessageWriter.writeHeader(header, countingOutputStream);
+        return countingOutputStream.getCount();
+    }
+
+    public static long sizeOf(Body body) throws IOException {
+        if (body instanceof FakeBinaryBody) {
+            return ((FakeBinaryBody) body).getSize();
+        }
+        if (body instanceof SingleBody) {
+            return ((SingleBody) body).size();
+        }
+        if (body instanceof Multipart) {
+            return sizeOfMultipart((Multipart) body);
+        }
+        if (body instanceof Message) {
+            Message message = (Message) body;
+            return sizeOf(message.getHeader()) + sizeOf(message.getBody());
+        }
+        CountingOutputStream countingOutputStream = new 
CountingOutputStream(OutputStream.nullOutputStream());
+        DefaultMessageWriter defaultMessageWriter = new DefaultMessageWriter();
+        defaultMessageWriter.writeBody(body, countingOutputStream);
+        return countingOutputStream.getCount();
+    }
+
+    // Inspired from DefaultMessageWriter
+    public static long sizeOfMultipart(Multipart multipart)
+        throws IOException {
+        long result = 0;
+        ContentTypeField contentType = getContentType(multipart);
+
+        ByteSequence boundary = getBoundary(contentType);
+
+        ByteSequence preamble;
+        ByteSequence epilogue;
+        if (multipart instanceof MultipartImpl) {
+            preamble = ((MultipartImpl) multipart).getPreambleRaw();
+            epilogue = ((MultipartImpl) multipart).getEpilogueRaw();
+        } else {
+            preamble = multipart.getPreamble() != null ? 
ContentUtil.encode(multipart.getPreamble()) : null;
+            epilogue = multipart.getEpilogue() != null ? 
ContentUtil.encode(multipart.getEpilogue()) : null;
+        }
+        if (preamble != null) {
+            result += preamble.length() + 2;
+        }
+
+        for (Entity bodyPart : multipart.getBodyParts()) {
+            result += 2 + boundary.length() + 2; // -- boudary CRLF
+            result += sizeOf(bodyPart.getHeader());
+            result += sizeOf(bodyPart);
+            result += 2; // CRLF
+        }
+
+        result += 2 + boundary.length() + 2 + 2; // -- boudary -- CRLF
+        if (epilogue != null) {
+            result += epilogue.length();
+        }
+        return result;
+    }
+
+
+    // Taken from DefaultMessageWriter
+    private static ContentTypeField getContentType(Multipart multipart) {
+        Entity parent = multipart.getParent();
+        if (parent == null) {
+            throw new IllegalArgumentException("Missing parent entity in 
multipart");
+        }
+
+        Header header = parent.getHeader();
+        if (header == null) {
+            throw new IllegalArgumentException("Missing header in parent 
entity");
+        }
+
+        ContentTypeField contentType = (ContentTypeField) header
+            .getField(FieldName.CONTENT_TYPE_LOWERCASE);
+        if (contentType == null) {
+            throw new IllegalArgumentException("Content-Type field not 
specified");
+        }
+
+        return contentType;
+    }
+
+    // Taken from DefaultMessageWriter
+    private static ByteSequence getBoundary(ContentTypeField contentType) {
+        String boundary = contentType.getBoundary();
+        if (boundary == null) {
+            throw new IllegalArgumentException("Multipart boundary not 
specified. Mime-Type: " + contentType.getMimeType() + ", Raw: " + 
contentType.toString());
+        }
+        return ContentUtil.encode(boundary);
+    }
+}
diff --git 
a/server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/event/ComputeMessageFastViewProjectionListener.java
 
b/server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/event/ComputeMessageFastViewProjectionListener.java
index c75f47120b..3a0c7941df 100644
--- 
a/server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/event/ComputeMessageFastViewProjectionListener.java
+++ 
b/server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/event/ComputeMessageFastViewProjectionListener.java
@@ -48,6 +48,7 @@ import com.google.common.collect.ImmutableSet;
 
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
 
 public class ComputeMessageFastViewProjectionListener implements 
EventListener.ReactiveGroupEventListener {
     public static class ComputeMessageFastViewProjectionListenerGroup extends 
Group {
@@ -103,7 +104,8 @@ public class ComputeMessageFastViewProjectionListener 
implements EventListener.R
         return 
Flux.from(messageIdManager.getMessagesReactive(addedEvent.getMessageIds(), 
FetchGroup.FULL_CONTENT, session))
             .flatMap(Throwing.function(messageResult -> Mono.fromCallable(
                 () -> Pair.of(messageResult.getMessageId(),
-                    computeFastViewPrecomputedProperties(messageResult)))), 
DEFAULT_CONCURRENCY)
+                    computeFastViewPrecomputedProperties(messageResult)))
+                .subscribeOn(Schedulers.parallel())), DEFAULT_CONCURRENCY)
             .flatMap(message -> 
messageFastViewProjection.store(message.getKey(), message.getValue()), 
DEFAULT_CONCURRENCY)
             .then();
     }
diff --git 
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/resources/eml/nested2.eml
 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/resources/eml/nested2.eml
new file mode 100644
index 0000000000..7dc18bee6e
--- /dev/null
+++ 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/resources/eml/nested2.eml
@@ -0,0 +1,77 @@
+Content-Type: multipart/mixed; boundary="------------6a0Tq7Y8z9M0dIG10PnZ7l7E"
+Message-ID: <b3493b24-116a-4d6b-a61c-1a3b09e44...@linagora.com>
+Date: Wed, 21 Feb 2024 22:58:55 +0100
+MIME-Version: 1.0
+References: <mime4j.8f3.69abfdd624c15c44.18dcdad3...@linagora.com>
+Subject: Fwd: Will be nested
+To: btell...@linagora.com
+From: "btell...@linagora.com" <btell...@linagora.com>
+In-Reply-To: <mime4j.8f3.69abfdd624c15c44.18dcdad3...@linagora.com>
+X-Forwarded-Message-Id: <mime4j.8f3.69abfdd624c15c44.18dcdad3...@linagora.com>
+
+This is a multi-part message in MIME format.
+--------------6a0Tq7Y8z9M0dIG10PnZ7l7E
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: 7bit
+
+
+--------------6a0Tq7Y8z9M0dIG10PnZ7l7E
+Content-Type: message/rfc822; name="Will be nested.eml"
+Content-Disposition: attachment; filename="Will be nested.eml"
+Content-Transfer-Encoding: 7bit
+
+MIME-Version: 1.0
+Subject: Will be nested
+From: Benoit TELLIER <bx...@linagora.com>
+To: =?ISO-8859-1?Q?Beno=EEt_TELLIER?= <bx...@linagora.com>
+Reply-To: btell...@linagora.com
+Date: Wed, 21 Feb 2024 21:58:17 +0000
+Message-ID: <mime4j.8f3.69abfdd624c15c44.18dcdad3...@linagora.com>
+Content-Type: multipart/mixed;
+ boundary="-=Part.8f5.4ee8b8343a133e4f.18dcdad38b0.f1b9fcef27eb8231=-"
+
+---=Part.8f5.4ee8b8343a133e4f.18dcdad38b0.f1b9fcef27eb8231=-
+Content-Type: multipart/alternative;
+ boundary="-=Part.8f4.9ab3439f1c0c1361.18dcdad38b0.b6d054e4dd1744c7=-"
+
+---=Part.8f4.9ab3439f1c0c1361.18dcdad38b0.b6d054e4dd1744c7=-
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: quoted-printable
+
+Nested body
+
+--=C2=A0
+
+Best regards,
+
+Benoit TELLIER
+
+---=Part.8f4.9ab3439f1c0c1361.18dcdad38b0.b6d054e4dd1744c7=-
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: quoted-printable
+
+<div>Nested body<br><br></div><div class=3D"tmail-signature" style=3D"displ=
+ay: block;"><span class=3D"tmail_signature_prefix">--&nbsp;</span><br><br>B=
+est regards,<br><br><b>Benoit TELLIER</b><br><br>General manager of <a href=
+=3D"https://linagora=2Evn";>Linagora VIETNAM</a>=2E<br>Product owner for <a =
+href=3D"https://github=2Ecom/linagora/tmail-flutter";>Team-Mail</a> product=
+=2E<br>Chairman of the <a href=3D"https://james=2Eapache=2Eorg/";>Apache Jam=
+es project</a>=2E<br><br>Mail: <a href=3D"mailto:btellier@linagora=2Ecom";>b=
+tellier@linagora=2Ecom</a><br>Tel: (0033) 6 77 26 04 58 (WhatsApp, Signal)<=
+br><br><br></div>
+---=Part.8f4.9ab3439f1c0c1361.18dcdad38b0.b6d054e4dd1744c7=---
+
+---=Part.8f5.4ee8b8343a133e4f.18dcdad38b0.f1b9fcef27eb8231=-
+Content-Type: application/octet-stream;
+ name="=?US-ASCII?Q?signal-desktop-keyring.gpg?="
+Content-Disposition: attachment
+Content-Transfer-Encoding: base64
+
+mQINBFjlSicBEACgho//0EzxuvuCn01LwFqGAgwPKcSSl4L+AWws5/YbsZZvmTBkggIiVOCIMh+d
+3cmGu5W3ydaeUbWbFGNsxO44EB5YBZcuLa5EzRKbNPVaOXKXmhp+w0mEbkoKbF+3mz3lifwBnzcB
+pukyJDgcJSq8cXfq5JsDPR1KAL6ph/kwKeiDNg+8oFgqfboukK56yPTYc9iM8hkTFdx9L6JCJaZG
+XME/ru6EZofUFxeVdev5+9ztYJBBZCGMug5Xp3Gxh/9JUWi6F1+9qAyzN+O606NOXLwcmq5KZL0g
+
+---=Part.8f5.4ee8b8343a133e4f.18dcdad38b0.f1b9fcef27eb8231=---
+
+--------------6a0Tq7Y8z9M0dIG10PnZ7l7E--
diff --git 
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailGetMethodContract.scala
 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailGetMethodContract.scala
index 1a90ecf267..aa1c7f45e9 100644
--- 
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailGetMethodContract.scala
+++ 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailGetMethodContract.scala
@@ -3254,6 +3254,67 @@ trait EmailGetMethodContract {
          |}""".stripMargin)
   }
 
+  @Test
+  def shouldSupportAttachedMessageWithComplexMultipart(server: 
GuiceJamesServer): Unit = {
+    val path = MailboxPath.inbox(BOB)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(BOB.asString, path, AppendCommand.from(
+          ClassLoaderUtils.getSystemResourceAsSharedStream("eml/nested2.eml")))
+      .getMessageId
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [[
+         |    "Email/get",
+         |    {
+         |      "accountId": 
"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6",
+         |      "ids": ["${messageId.serialize}"],
+         |      "properties":["bodyStructure"],
+         |      "bodyProperties":["partId", "blobId", "subParts","size", 
"type"]
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .whenIgnoringPaths("methodResponses[0][1].state")
+      .inPath("methodResponses[0][1].list[0].bodyStructure")
+      .isEqualTo(
+      s"""{
+         |  "subParts": [
+         |      {
+         |        "size": 0,
+         |        "partId": "2",
+         |        "blobId": "${messageId.serialize()}_2",
+         |        "type": "text/plain"
+         |      },
+         |      {
+         |        "size": 2093,
+         |        "partId": "3",
+         |        "blobId": "${messageId.serialize()}_3",
+         |        "type": "message/rfc822"
+         |      }
+         |    ],
+         |    "size": 2513,
+         |    "partId": "1",
+         |    "type": "multipart/mixed"
+         |  }""".stripMargin)
+  }
+
   @Test
   def mailboxIdsPropertiesShouldBeReturned(server: GuiceJamesServer): Unit = {
     val path = MailboxPath.inbox(BOB)
@@ -3866,7 +3927,7 @@ trait EmailGetMethodContract {
          |                                    "value": "$contentType"
          |                                }
          |                            ],
-         |                            "size": 2287,
+         |                            "size": 1880,
          |                            "type": "multipart/mixed",
          |                            "charset": "us-ascii",
          |                            "subParts": [
diff --git 
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailParseMethodContract.scala
 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailParseMethodContract.scala
index be5fa494aa..59d934eb92 100644
--- 
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailParseMethodContract.scala
+++ 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailParseMethodContract.scala
@@ -327,7 +327,7 @@ trait EmailParseMethodContract {
            |                            }
            |                        ],
            |                        "subject": "test subject",
-           |                        "size": 807,
+           |                        "size": 797,
            |                        "blobId": "${messageId.serialize()}_3",
            |                        "preview": "test body",
            |                        "messageId": [
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Email.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Email.scala
index 334f3bb82a..82c51a7633 100644
--- 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Email.scala
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/Email.scala
@@ -44,7 +44,7 @@ import 
org.apache.james.jmap.mail.EmailHeaderName.{ADDRESSES_NAMES, DATE, MESSAG
 import 
org.apache.james.jmap.mail.FastViewWithAttachmentsMetadataReadLevel.supportedByFastViewWithAttachments
 import org.apache.james.jmap.mail.KeywordsFactory.LENIENT_KEYWORDS_FACTORY
 import org.apache.james.jmap.method.ZoneIdProvider
-import org.apache.james.jmap.mime4j.JamesBodyDescriptorBuilder
+import org.apache.james.jmap.mime4j.{JamesBodyDescriptorBuilder, 
AvoidBinaryBodyBufferingBodyFactory}
 import org.apache.james.mailbox.model.FetchGroup.{FULL_CONTENT, HEADERS, 
HEADERS_WITH_ATTACHMENTS_METADATA, MINIMAL}
 import org.apache.james.mailbox.model.{FetchGroup, MailboxId, MessageId, 
MessageResult, ThreadId => JavaThreadId}
 import org.apache.james.mailbox.{MailboxSession, MessageIdManager}
@@ -52,7 +52,7 @@ import org.apache.james.mime4j.codec.DecodeMonitor
 import org.apache.james.mime4j.dom.field.{AddressListField, DateTimeField, 
MailboxField, MailboxListField}
 import org.apache.james.mime4j.dom.{Header, Message}
 import org.apache.james.mime4j.field.{AddressListFieldLenientImpl, 
LenientFieldParser}
-import org.apache.james.mime4j.message.{BasicBodyFactory, 
DefaultMessageBuilder}
+import org.apache.james.mime4j.message.DefaultMessageBuilder
 import org.apache.james.mime4j.stream.{Field, MimeConfig, RawFieldParser}
 import org.apache.james.mime4j.util.MimeUtil
 import org.apache.james.util.AuditTrail
@@ -138,7 +138,7 @@ object Email {
     defaultMessageBuilder.setMimeEntityConfig(MimeConfig.PERMISSIVE)
     defaultMessageBuilder.setDecodeMonitor(DecodeMonitor.SILENT)
     defaultMessageBuilder.setBodyDescriptorBuilder(new 
JamesBodyDescriptorBuilder(null, LenientFieldParser.getParser, 
DecodeMonitor.SILENT))
-    defaultMessageBuilder.setBodyFactory(new BasicBodyFactory(defaultCharset))
+    defaultMessageBuilder.setBodyFactory(new 
AvoidBinaryBodyBufferingBodyFactory(defaultCharset))
     val resultMessage = Try(defaultMessageBuilder.parseMessage(inputStream))
     resultMessage.fold(e => {
       Try(inputStream.close())
diff --git 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailBodyPart.scala
 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailBodyPart.scala
index cda256d791..28ecc58c62 100644
--- 
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailBodyPart.scala
+++ 
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/mail/EmailBodyPart.scala
@@ -23,7 +23,6 @@ import java.io.OutputStream
 import java.time.ZoneId
 
 import cats.implicits._
-import com.google.common.io.CountingOutputStream
 import eu.timepit.refined.api.Refined
 import eu.timepit.refined.auto._
 import eu.timepit.refined.numeric.NonNegative
@@ -34,14 +33,14 @@ import org.apache.james.jmap.api.model.Size.Size
 import org.apache.james.jmap.core.Properties
 import org.apache.james.jmap.mail.EmailBodyPart.{FILENAME_PREFIX, MDN_TYPE, 
MULTIPART_ALTERNATIVE, TEXT_HTML, TEXT_PLAIN, of}
 import org.apache.james.jmap.mail.PartId.PartIdValue
-import org.apache.james.jmap.mime4j.JamesBodyDescriptorBuilder
+import org.apache.james.jmap.mime4j.{ JamesBodyDescriptorBuilder, SizeUtils}
 import org.apache.james.mailbox.model.{Cid, MessageAttachmentMetadata, 
MessageResult}
 import org.apache.james.mime4j.Charsets.ISO_8859_1
 import org.apache.james.mime4j.codec.{DecodeMonitor, DecoderUtil}
 import org.apache.james.mime4j.dom.field.{ContentDispositionField, 
ContentLanguageField, ContentTypeField, FieldName}
-import org.apache.james.mime4j.dom.{Entity, Message, Multipart, SingleBody, 
TextBody => Mime4JTextBody}
+import org.apache.james.mime4j.dom.{Entity, Message, Multipart, TextBody => 
Mime4JTextBody}
 import org.apache.james.mime4j.field.LenientFieldParser
-import org.apache.james.mime4j.message.{BasicBodyFactory, 
DefaultMessageBuilder, DefaultMessageWriter}
+import org.apache.james.mime4j.message.{BasicBodyFactory, 
DefaultMessageBuilder}
 import org.apache.james.mime4j.stream.{Field, MimeConfig, RawField}
 import org.apache.james.util.html.HtmlTextExtractor
 
@@ -181,15 +180,7 @@ object EmailBodyPart {
     .headOption
     .map(_.getBody)
 
-  private def size(entity: Entity): Try[Size] =
-    entity.getBody match {
-      case body: SingleBody => refineSize(body.size())
-      case body =>
-        val countingOutputStream: CountingOutputStream = new 
CountingOutputStream(OutputStream.nullOutputStream())
-        val writer = new DefaultMessageWriter
-        writer.writeBody(body, countingOutputStream)
-        refineSize(countingOutputStream.getCount)
-    }
+  private def size(entity: Entity): Try[Size] = 
refineSize(SizeUtils.sizeOf(entity))
 
   private def refineSize(l: Long): Try[Size] = refineV[NonNegative](l) match {
     case scala.Right(size) => Success(size)


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscr...@james.apache.org
For additional commands, e-mail: notifications-h...@james.apache.org


Reply via email to