blob: c7acdf36e5abfd55d1f13bbe025563faa54e3bab [file] [log] [blame]
/****************************************************************
* 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.draft.methods;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.TimeZone;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import jakarta.inject.Inject;
import org.apache.james.jmap.draft.model.CreationMessage;
import org.apache.james.jmap.draft.model.CreationMessage.DraftEmailer;
import org.apache.james.jmap.model.Attachment;
import org.apache.james.jmap.model.message.view.MessageViewFactory;
import org.apache.james.mime4j.codec.DecodeMonitor;
import org.apache.james.mime4j.codec.EncoderUtil;
import org.apache.james.mime4j.codec.EncoderUtil.Usage;
import org.apache.james.mime4j.dom.FieldParser;
import org.apache.james.mime4j.dom.Message;
import org.apache.james.mime4j.dom.Multipart;
import org.apache.james.mime4j.dom.TextBody;
import org.apache.james.mime4j.dom.address.Mailbox;
import org.apache.james.mime4j.dom.field.ContentDispositionField;
import org.apache.james.mime4j.dom.field.ContentTypeField;
import org.apache.james.mime4j.dom.field.FieldName;
import org.apache.james.mime4j.dom.field.UnstructuredField;
import org.apache.james.mime4j.field.ContentIdFieldImpl;
import org.apache.james.mime4j.field.Fields;
import org.apache.james.mime4j.field.LenientFieldParser;
import org.apache.james.mime4j.field.UnstructuredFieldImpl;
import org.apache.james.mime4j.message.BasicBodyFactory;
import org.apache.james.mime4j.message.BodyPart;
import org.apache.james.mime4j.message.BodyPartBuilder;
import org.apache.james.mime4j.message.DefaultMessageWriter;
import org.apache.james.mime4j.message.MultipartBuilder;
import org.apache.james.mime4j.stream.NameValuePair;
import org.apache.james.mime4j.stream.RawField;
import org.apache.james.mime4j.util.MimeUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.io.ByteStreams;
import com.google.common.net.MediaType;
public class MIMEMessageConverter {
private static final Logger LOGGER = LoggerFactory.getLogger(MIMEMessageConverter.class);
private static final String PLAIN_TEXT_MEDIA_TYPE = MediaType.PLAIN_TEXT_UTF_8.withoutParameters().toString();
private static final String HTML_MEDIA_TYPE = MediaType.HTML_UTF_8.withoutParameters().toString();
private static final NameValuePair UTF_8_CHARSET = new NameValuePair("charset", StandardCharsets.UTF_8.name());
private static final String ALTERNATIVE_SUB_TYPE = "alternative";
private static final String MIXED_SUB_TYPE = "mixed";
private static final String RELATED_SUB_TYPE = "related";
private static final String QUOTED_PRINTABLE = "quoted-printable";
private static final String BASE64 = "base64";
private static final String IN_REPLY_TO_HEADER = "In-Reply-To";
private static final List<String> COMPUTED_HEADERS = ImmutableList.of(
FieldName.FROM,
FieldName.SENDER,
FieldName.REPLY_TO,
FieldName.TO,
FieldName.CC,
FieldName.BCC,
FieldName.SUBJECT,
FieldName.MESSAGE_ID,
FieldName.DATE,
FieldName.CONTENT_TYPE,
FieldName.MIME_VERSION,
FieldName.CONTENT_TRANSFER_ENCODING);
private static final List<String> LOWERCASED_COMPUTED_HEADERS = COMPUTED_HEADERS.stream()
.map(s -> s.toLowerCase(Locale.ENGLISH))
.collect(ImmutableList.toImmutableList());
private static final LenientFieldParser FIELD_PARSER = new LenientFieldParser();
private final BasicBodyFactory bodyFactory;
@Inject
public MIMEMessageConverter() {
this.bodyFactory = new BasicBodyFactory();
}
public byte[] convert(ValueWithId.CreationMessageEntry creationMessageEntry, ImmutableList<Attachment.WithBlob> messageAttachments) {
Message message = convertToMime(creationMessageEntry, messageAttachments);
byte[] result = asBytes(message);
message.dispose();
return result;
}
public byte[] asBytes(Message message) {
try {
return DefaultMessageWriter.asBytes(message);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@VisibleForTesting Message convertToMime(ValueWithId.CreationMessageEntry creationMessageEntry, ImmutableList<Attachment.WithBlob> messageAttachments) {
if (creationMessageEntry == null || creationMessageEntry.getValue() == null) {
throw new IllegalArgumentException("creationMessageEntry is either null or has null message");
}
Message.Builder messageBuilder = Message.Builder.of()
.use(FIELD_PARSER);
if (isMultipart(creationMessageEntry.getValue(), messageAttachments)) {
messageBuilder.setBody(createMultipart(creationMessageEntry.getValue(), messageAttachments));
} else {
messageBuilder.setBody(createTextBody(creationMessageEntry.getValue()))
.setContentTransferEncoding(QUOTED_PRINTABLE);
}
buildMimeHeaders(messageBuilder, creationMessageEntry.getValue(), messageAttachments);
return messageBuilder.build();
}
private void buildMimeHeaders(Message.Builder messageBuilder, CreationMessage newMessage, ImmutableList<Attachment.WithBlob> messageAttachments) {
Optional<Mailbox> fromAddress = newMessage.getFrom().filter(DraftEmailer::hasValidEmail).map(this::convertEmailToMimeHeader);
fromAddress.ifPresent(messageBuilder::setFrom);
fromAddress.ifPresent(messageBuilder::setSender);
messageBuilder.setReplyTo(newMessage.getReplyTo().stream()
.map(this::convertEmailToMimeHeader)
.collect(Collectors.toList()));
messageBuilder.setTo(newMessage.getTo().stream()
.filter(DraftEmailer::hasValidEmail)
.map(this::convertEmailToMimeHeader)
.collect(Collectors.toList()));
messageBuilder.setCc(newMessage.getCc().stream()
.filter(DraftEmailer::hasValidEmail)
.map(this::convertEmailToMimeHeader)
.collect(Collectors.toList()));
messageBuilder.setBcc(newMessage.getBcc().stream()
.filter(DraftEmailer::hasValidEmail)
.map(this::convertEmailToMimeHeader)
.collect(Collectors.toList()));
messageBuilder.setSubject(newMessage.getSubject());
messageBuilder.setMessageId(generateUniqueMessageId(fromAddress));
// note that date conversion probably lose milliseconds!
messageBuilder.setDate(Date.from(newMessage.getDate().toInstant()), TimeZone.getTimeZone(newMessage.getDate().getZone()));
newMessage.getInReplyToMessageId()
.ifPresent(id -> addHeader(messageBuilder, IN_REPLY_TO_HEADER, id));
if (!isMultipart(newMessage, messageAttachments)) {
newMessage.getHtmlBody().ifPresent(x -> messageBuilder.setContentType(HTML_MEDIA_TYPE, UTF_8_CHARSET));
}
newMessage.getHeaders().entrySet().stream()
.filter(header -> ! header.getKey().trim().isEmpty())
.filter(header -> ! LOWERCASED_COMPUTED_HEADERS.contains(header.getKey().toLowerCase(Locale.ENGLISH)))
.forEach(header -> addMultivaluedHeader(messageBuilder, header.getKey(), header.getValue()));
}
private String generateUniqueMessageId(Optional<Mailbox> fromAddress) {
String noDomain = null;
return MimeUtil.createUniqueMessageId(fromAddress
.map(Mailbox::getDomain)
.orElse(noDomain));
}
private void addMultivaluedHeader(Message.Builder messageBuilder, String fieldName, String multipleValues) {
Splitter.on(MessageViewFactory.JMAP_MULTIVALUED_FIELD_DELIMITER).split(multipleValues)
.forEach(value -> addHeader(messageBuilder, fieldName, value));
}
private void addHeader(Message.Builder messageBuilder, String fieldName, String value) {
FieldParser<UnstructuredField> parser = UnstructuredFieldImpl.PARSER;
RawField rawField = new RawField(fieldName, value);
messageBuilder.addField(parser.parse(rawField, DecodeMonitor.SILENT));
}
private boolean isMultipart(CreationMessage newMessage, ImmutableList<Attachment.WithBlob> messageAttachments) {
return (newMessage.getTextBody().isPresent() && newMessage.getHtmlBody().isPresent())
|| hasAttachment(messageAttachments);
}
private boolean hasAttachment(ImmutableList<Attachment.WithBlob> messageAttachments) {
return !messageAttachments.isEmpty();
}
private TextBody createTextBody(CreationMessage newMessage) {
String body = newMessage.getHtmlBody()
.orElse(newMessage.getTextBody()
.orElse(""));
return bodyFactory.textBody(body, StandardCharsets.UTF_8);
}
private Multipart createMultipart(CreationMessage newMessage, ImmutableList<Attachment.WithBlob> messageAttachments) {
try {
if (hasAttachment(messageAttachments)) {
return createMultipartWithAttachments(newMessage, messageAttachments);
} else {
return createMultipartAlternativeBody(newMessage);
}
} catch (IOException e) {
LOGGER.error("Error while creating textBody \n{}\n or htmlBody \n{}", newMessage.getTextBody().get(), newMessage.getHtmlBody().get(), e);
throw new RuntimeException(e);
}
}
private Multipart createMultipartWithAttachments(CreationMessage newMessage, ImmutableList<Attachment.WithBlob> messageAttachments) throws IOException {
MultipartBuilder mixedMultipartBuilder = MultipartBuilder.create(MIXED_SUB_TYPE);
List<Attachment.WithBlob> inlineAttachments = messageAttachments.stream()
.filter(attachment -> attachment.getAttachment().isIsInline())
.collect(ImmutableList.toImmutableList());
List<Attachment.WithBlob> besideAttachments = messageAttachments.stream()
.filter(Predicate.not(attachment -> attachment.getAttachment().isIsInline()))
.collect(ImmutableList.toImmutableList());
if (inlineAttachments.size() > 0) {
mixedMultipartBuilder.addBodyPart(relatedInnerMessage(newMessage, inlineAttachments));
} else {
addBody(newMessage, mixedMultipartBuilder);
}
addAttachments(besideAttachments, mixedMultipartBuilder);
return mixedMultipartBuilder.build();
}
private Message relatedInnerMessage(CreationMessage newMessage, List<Attachment.WithBlob> inlines) throws IOException {
MultipartBuilder relatedMultipart = MultipartBuilder.create(RELATED_SUB_TYPE);
addBody(newMessage, relatedMultipart);
return Message.Builder.of()
.setBody(addAttachments(inlines, relatedMultipart)
.build())
.build();
}
private MultipartBuilder addAttachments(List<Attachment.WithBlob> messageAttachments,
MultipartBuilder multipartBuilder) {
messageAttachments.forEach(addAttachment(multipartBuilder));
return multipartBuilder;
}
private void addBody(CreationMessage newMessage, MultipartBuilder builder) throws IOException {
if (newMessage.getHtmlBody().isPresent() && newMessage.getTextBody().isPresent()) {
Multipart body = createMultipartAlternativeBody(newMessage);
builder.addBodyPart(BodyPartBuilder.create().setBody(body));
} else {
addText(builder, newMessage.getTextBody());
addHtml(builder, newMessage.getHtmlBody());
}
}
private Multipart createMultipartAlternativeBody(CreationMessage newMessage) throws IOException {
MultipartBuilder bodyBuilder = MultipartBuilder.create(ALTERNATIVE_SUB_TYPE);
addText(bodyBuilder, newMessage.getTextBody());
addHtml(bodyBuilder, newMessage.getHtmlBody());
return bodyBuilder.build();
}
private void addText(MultipartBuilder builder, Optional<String> textBody) throws IOException {
if (textBody.isPresent()) {
builder.addBodyPart(BodyPartBuilder.create()
.use(bodyFactory)
.setBody(textBody.get(), StandardCharsets.UTF_8)
.setContentType(PLAIN_TEXT_MEDIA_TYPE, UTF_8_CHARSET)
.setContentTransferEncoding(QUOTED_PRINTABLE));
}
}
private void addHtml(MultipartBuilder builder, Optional<String> htmlBody) throws IOException {
if (htmlBody.isPresent()) {
builder.addBodyPart(BodyPartBuilder.create()
.use(bodyFactory)
.setBody(htmlBody.get(), StandardCharsets.UTF_8)
.setContentType(HTML_MEDIA_TYPE, UTF_8_CHARSET)
.setContentTransferEncoding(QUOTED_PRINTABLE));
}
}
private Consumer<Attachment.WithBlob> addAttachment(MultipartBuilder builder) {
return att -> {
try {
builder.addBodyPart(attachmentBodyPart(att));
} catch (IOException e) {
LOGGER.error("Error while creating attachment", e);
throw new RuntimeException(e);
}
};
}
private BodyPart attachmentBodyPart(Attachment.WithBlob att) throws IOException {
try (InputStream attachmentStream = att.getBlob().getStream()) {
BodyPartBuilder builder = BodyPartBuilder.create()
.use(bodyFactory)
.setBody(new BasicBodyFactory().binaryBody(ByteStreams.toByteArray(attachmentStream)))
.setField(contentTypeField(att.getAttachment()))
.setField(contentDispositionField(att.getAttachment().isIsInline()))
.setContentTransferEncoding(BASE64);
contentId(builder, att);
return builder.build();
}
}
private void contentId(BodyPartBuilder builder, Attachment.WithBlob att) {
if (att.getAttachment().getCid().isPresent()) {
builder.setField(ContentIdFieldImpl.PARSER.parse(new RawField("Content-ID", att.getAttachment().getCid().get()), DecodeMonitor.SILENT));
}
}
@VisibleForTesting
ContentTypeField contentTypeField(Attachment attachment) {
final ContentTypeField typeAsField = attachment.getType().asMime4J();
if (attachment.getName().isPresent()) {
return Fields.contentType(typeAsField.getMimeType(),
ImmutableMap.<String, String>builder()
.putAll(parametersWithoutName(typeAsField))
.put("name", encode(attachment.getName().get()))
.build());
}
return typeAsField;
}
private ImmutableMap<String, String> parametersWithoutName(ContentTypeField typeAsField) {
return typeAsField.getParameters()
.entrySet()
.stream()
.filter(entry -> !entry.getKey().equals("name"))
.collect(ImmutableMap.toImmutableMap(Map.Entry::getKey, Map.Entry::getValue));
}
private String encode(String name) {
return EncoderUtil.encodeEncodedWord(name, Usage.TEXT_TOKEN);
}
private ContentDispositionField contentDispositionField(boolean isInline) {
if (isInline) {
return Fields.contentDisposition("inline");
}
return Fields.contentDisposition("attachment");
}
private Mailbox convertEmailToMimeHeader(DraftEmailer address) {
if (!address.hasValidEmail()) {
throw new IllegalArgumentException("address");
}
CreationMessage.EmailUserAndDomain emailUserAndDomain = address.getEmailUserAndDomain();
return new Mailbox(address.getName().orElse(null), null, emailUserAndDomain.getUser().orElse(null), emailUserAndDomain.getDomain().orElse(null));
}
}