MailProcessor.java

/*
 * Copyright (C) 2000 - 2024 Silverpeas
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * As a special exception to the terms and conditions of version 3.0 of
 * the GPL, you may redistribute this Program in connection with Free/Libre
 * Open Source Software ("FLOSS") applications as described in Silverpeas's
 * FLOSS exception. You should have received a copy of the text describing
 * the FLOSS exception, and it is also available here:
 * "https://www.silverpeas.org/legal/floss_exception.html"
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 */
package org.silverpeas.components.mailinglist.service.job;

import org.silverpeas.components.mailinglist.service.event.MessageEvent;
import org.silverpeas.components.mailinglist.service.event.MessageListener;
import org.silverpeas.components.mailinglist.service.model.beans.Attachment;
import org.silverpeas.components.mailinglist.service.model.beans.Message;
import org.silverpeas.components.mailinglist.service.util.HtmlCleaner;
import org.silverpeas.core.annotation.Service;
import org.silverpeas.core.util.MimeTypes;
import org.silverpeas.core.util.file.FileRepositoryManager;
import org.silverpeas.core.util.logging.SilverLogger;

import javax.inject.Inject;
import javax.mail.MessagingException;
import javax.mail.Multipart;
import javax.mail.Part;
import javax.mail.internet.ContentType;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import javax.mail.internet.ParseException;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.io.StringReader;
import java.nio.file.Files;

import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;

@Service
public class MailProcessor {

  public static final int SUMMARY_SIZE = 200;
  public static final String MAIL_HEADER_IN_REPLY_TO = "In-Reply-To";
  public static final String MAIL_HEADER_REFERENCES = "References";

  private SilverLogger logger = SilverLogger.getLogger(this);

  @Inject
  private HtmlCleaner cleaner;

  public void setCleaner(HtmlCleaner cleaner) {
    this.cleaner = cleaner;
    this.cleaner.setSummarySize(SUMMARY_SIZE);
  }

  /**
   * Processes a part for a multi-part email.
   * @param part the part to be processed.
   * @param message the message corresponding to the email.
   * @throws MessagingException
   * @throws IOException
   */
  public void processMailPart(Part part, Message message) throws MessagingException, IOException {
    if (!isTextPart(part)) {
      Object content = part.getContent();
      if (content instanceof Multipart) {
        processMultipart((Multipart) content, message);
      } else {
        String fileName = getFileName(part);
        if (fileName != null) {
          Attachment attachment = new Attachment();
          attachment.setSize(part.getSize());
          attachment.setFileName(fileName);
          attachment.setContentType(extractContentType(part.getContentType()));
          String attachmentPath = saveAttachment(part, message.getComponentId(), message.
              getMessageId());
          attachment.setPath(attachmentPath);
          message.getAttachments().add(attachment);
        }
      }
    } else {
      processBody((String) part.getContent(), extractContentType(part.getContentType()), message);
    }
  }

  /**
   * Processes the body (text) part of an email.
   * @param content the text content of the email.
   * @param contentType the content type for this text.
   * @param message the message corresponding to this part
   * @throws IOException
   * @throws MessagingException
   */
  public void processBody(String content, String contentType, Message message)
      throws IOException, MessagingException {
    if (message.getContentType() != null &&
        message.getContentType().contains(MimeTypes.HTML_MIME_TYPE)) {
      // this is the text-part of an HTMLmultipart message
      return;
    }
    message.setContentType(contentType);
    if (contentType == null) {
      message.setContentType(MimeTypes.PLAIN_TEXT_MIME_TYPE);
    }
    if (message.getContentType().contains(MimeTypes.PLAIN_TEXT_MIME_TYPE)) {
      message.setBody(content);
      if (message.getBody().length() > SUMMARY_SIZE) {
        message.setSummary(message.getBody().substring(0, SUMMARY_SIZE));
      } else {
        message.setSummary(message.getBody());
      }
    } else if (message.getContentType().contains(MimeTypes.HTML_MIME_TYPE)) {
      message.setBody(content);
      Reader reader = null;
      try {
        reader = new StringReader(content);
        cleaner.parse(reader);
        message.setSummary(cleaner.getSummary());
      } finally {
        if (reader != null) {
          reader.close();
        }
      }
    } else { // Managing as text/plain
      message.setContentType(MimeTypes.PLAIN_TEXT_MIME_TYPE);
      message.setBody(content);
      if (message.getBody().length() > SUMMARY_SIZE) {
        message.setSummary(message.getBody().substring(0, SUMMARY_SIZE));
      } else {
        message.setSummary(message.getBody());
      }
    }
  }

  /**
   * Replaces special chars.
   * @param toParse the String whose chars are to be replaced.
   * @return the String without its special chars. Empty String if toParse is null.
   */
  public String replaceSpecialChars(String toParse) {
    if (toParse == null) {
      return "";
    }
    String newLogicalName = toParse.replace(' ', '_');
    newLogicalName = newLogicalName.replace('\'', '_');
    newLogicalName = newLogicalName.replace('-', '_');
    newLogicalName = newLogicalName.replace('#', '_');
    newLogicalName = newLogicalName.replace('%', '_');
    newLogicalName = newLogicalName.replace('>', '_');
    newLogicalName = newLogicalName.replace('<', '_');
    newLogicalName = newLogicalName.replace('\\', '_');
    newLogicalName = newLogicalName.replace('/', '_');
    newLogicalName = newLogicalName.replace('?', '_');
    newLogicalName = newLogicalName.replace(':', '_');
    newLogicalName = newLogicalName.replace('|', '_');
    newLogicalName = newLogicalName.replace('"', '_');
    return newLogicalName;
  }

  /**
   * Saves an attachment as a file, and stores the path in the message.
   * @param part the part corresponding to the attachment.
   * @param componentId the id of the mailing list component.
   * @param messageId the id of the message (email id).
   * @return the absolute path to the file.
   * @throws IOException
   * @throws MessagingException
   */
  public String saveAttachment(Part part, String componentId, String messageId)
      throws IOException, MessagingException {
    File parentDir = new File(
        FileRepositoryManager.getAbsolutePath(componentId) + replaceSpecialChars(messageId));
    if (!parentDir.exists()) {
      parentDir.mkdirs();
    }
    File targetFile = new File(parentDir, getFileName(part));
    try(InputStream partIn = part.getInputStream()) {
      Files.copy(partIn, targetFile.toPath(), REPLACE_EXISTING);
    }
    return targetFile.getAbsolutePath();
  }

  /**
   * Process an email, extracting attachments and constructing a Message.
   * @param mail the email to be processed.
   * @param mailingList the mailing list it is going to be affected to.
   * @param event the event which will be send at the end of all processing.
   * @throws MessagingException
   * @throws IOException
   */
  public void prepareMessage(MimeMessage mail, MessageListener mailingList, MessageEvent event)
      throws MessagingException, IOException {
    String sender = ((InternetAddress[]) mail.getFrom())[0].getAddress();
    if (!mailingList.checkSender(sender)) {
      return;
    }
    Message message = new Message();
    message.setComponentId(mailingList.getComponentId());
    message.setSender(sender);
    message.setSentDate(mail.getSentDate());
    message.setMessageId(mail.getMessageID());
    String[] referenceId = mail.getHeader(MAIL_HEADER_IN_REPLY_TO);
    if (referenceId == null || referenceId.length == 0) {
      referenceId = mail.getHeader(MAIL_HEADER_REFERENCES);
    }
    if (referenceId == null || referenceId.length == 0) {
      message.setReferenceId(null);
    } else {
      message.setReferenceId(referenceId[0]);
    }
    message.setTitle(mail.getSubject());
    Object content = mail.getContent();
    if (content instanceof Multipart) {
      processMultipart((Multipart) content, message);
    } else if (content instanceof String) {
      processBody((String) content, mail.getContentType(), message);
    }
    event.addMessage(message);
  }

  protected static String extractContentType(String contentType) {
    try {
      ContentType type = new ContentType(contentType);
      return type.getBaseType();
    } catch (ParseException e) {
      SilverLogger.getLogger(MailProcessor.class).error(e);
    }
    return contentType;
  }

  protected String getFileName(Part part) throws MessagingException {
    String fileName = part.getFileName();
    if (fileName == null) {
      try {
        ContentType type = new ContentType(part.getContentType());
        fileName = type.getParameter("name");
      } catch (ParseException e) {
        logger.error(e.getMessage(), e);
      }
    }
    return fileName;
  }

  /**
   * Analyze the part to check if it is an attachment, a base64 encoded file or some text.
   * @param part the part to be analyzed.
   * @return true if it is some text - false otherwise.
   * @throws MessagingException
   */
  protected boolean isTextPart(Part part) throws MessagingException {
    String disposition = part.getDisposition();
    if (!Part.ATTACHMENT.equals(disposition) && !Part.INLINE.equals(disposition)) {
      try {
        ContentType type = new ContentType(part.getContentType());
        return "text".equalsIgnoreCase(type.getPrimaryType());
      } catch (ParseException e) {
        logger.error(e.getMessage(), e);
      }
    } else if (Part.INLINE.equals(disposition)) {
      try {
        ContentType type = new ContentType(part.getContentType());
        return "text".equalsIgnoreCase(type.getPrimaryType()) && getFileName(part) == null;
      } catch (ParseException e) {
        logger.error(e.getMessage(), e);
      }
    }
    return false;
  }

  public void processMultipart(Multipart multipart, Message message)
      throws MessagingException, IOException {
    int partsNumber = multipart.getCount();
    for (int i = 0; i < partsNumber; i++) {
      Part part = multipart.getBodyPart(i);
      processMailPart(part, message);
    }
  }
}