/**
* Yobi, Project Hosting SW
*
* Copyright 2014 NAVER Corp.
* http://yobi.io
*
* @Author Yi EungJun
*
* Licensed 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 mailbox;
import com.sun.mail.imap.IMAPMessage;
import com.googlecode.htmlcompressor.compressor.HtmlCompressor;
import mailbox.exceptions.IssueNotFound;
import mailbox.exceptions.MailHandlerException;
import mailbox.exceptions.PermissionDenied;
import mailbox.exceptions.PostingNotFound;
import models.*;
import models.enumeration.ResourceType;
import models.resource.Resource;
import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import play.Logger;
import play.api.i18n.Lang;
import play.db.ebean.Transactional;
import play.i18n.Messages;
import utils.AccessControl;
import utils.MimeType;
import javax.annotation.Nonnull;
import javax.mail.Address;
import javax.mail.BodyPart;
import javax.mail.MessagingException;
import javax.mail.Part;
import javax.mail.internet.*;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
/**
* A set of methods to create a resource from an incoming email.
*/
public class CreationViaEmail {
/**
* Create a comment from the given email.
*
* @param message
* @param target
* @throws MessagingException
* @throws MailHandlerException
* @throws IOException
* @throws NoSuchAlgorithmException
*/
@Transactional
public static Comment saveComment(IMAPMessage message, Resource target)
throws MessagingException, MailHandlerException, IOException, NoSuchAlgorithmException {
User author = IMAPMessageUtil.extractSender(message);
if (!AccessControl.isProjectResourceCreatable(
author, target.getProject(), target.getType())) {
throw new PermissionDenied(cannotCreateMessage(author,
target.getProject(), target.getType()));
}
Content parsedMessage = extractContent(message);
Comment comment = makeNewComment(target, author, parsedMessage.body);
comment.save();
Map<String, Attachment> relatedAttachments = saveAttachments(
parsedMessage.attachments,
comment.asResource());
if (new ContentType(parsedMessage.type).match(MimeType.HTML)) {
comment.contents = postprocessForHTML(comment.contents, relatedAttachments);
comment.update();
}
new OriginalEmail(message.getMessageID(), comment.asResource()).save();
// Add the event
addEvent(NotificationEvent.forNewComment(comment, author),
message.getAllRecipients(), author);
return comment;
}
/**
* Does postprocessing for HTML document.
*
* 1. Replaces cid with attachments.
* 2. Removes newlines between HTML tags which will make the result rendered
* by markdown ugly.
*
* @param contents
* @param relatedAttachments
* @return
*/
private static String postprocessForHTML(
String contents, Map<String, Attachment> relatedAttachments) {
return new HtmlCompressor().compress(
replaceCidWithAttachments(contents, relatedAttachments));
}
private static Comment makeNewComment(Resource target, User sender, String body) throws IssueNotFound, PostingNotFound {
Comment comment;
Long id = Long.valueOf(target.getId());
switch(target.getType()) {
case ISSUE_POST:
Issue issue = Issue.finder.byId(id);
if (issue == null) {
throw new IssueNotFound(id);
}
comment = new IssueComment(issue, sender, body);
break;
case BOARD_POST:
Posting posting = Posting.finder.byId(id);
if (posting == null) {
throw new PostingNotFound(id);
}
comment = new PostingComment(posting, sender, body);
break;
default:
throw new IllegalArgumentException("Unsupported resource type: " + target.getType());
}
return comment;
}
/**
* Create an issue from the given email.
*
* @param message
* @param project
* @throws MessagingException
* @throws PermissionDenied
* @throws IOException
* @throws NoSuchAlgorithmException
*/
static Issue saveIssue(IMAPMessage message,
Project project)
throws MessagingException, PermissionDenied, IOException, NoSuchAlgorithmException {
User sender = IMAPMessageUtil.extractSender(message);
if (!AccessControl.isProjectResourceCreatable(
sender, project, ResourceType.ISSUE_POST)) {
throw new PermissionDenied(cannotCreateMessage(sender, project,
ResourceType.ISSUE_POST));
}
Content parsedMessage = extractContent(message);
String messageId = message.getMessageID();
Address[] recipients = message.getAllRecipients();
String subject = message.getSubject();
return saveIssue(subject, project, sender, parsedMessage, messageId, recipients);
}
@Transactional
public static Issue saveIssue(String subject,
Project project,
User sender,
Content parsedMessage,
String messageId, Address[] recipients)
throws MessagingException, IOException, NoSuchAlgorithmException {
Issue issue = new Issue(project, sender, subject, parsedMessage.body);
issue.save();
NotificationEvent event = NotificationEvent.forNewIssue(issue, sender);
Map<String, Attachment> relatedAttachments = saveAttachments(
parsedMessage.attachments,
issue.asResource());
if (new ContentType(parsedMessage.type).match(MimeType.HTML)) {
issue.body = postprocessForHTML(issue.body, relatedAttachments);
issue.update();
}
new OriginalEmail(messageId, issue.asResource()).save();
// Add the event
addEvent(event, recipients, sender);
return issue;
}
/**
* Create a review comment from the given email.
*
* @param message
* @param target
* @throws IOException
* @throws MessagingException
* @throws PermissionDenied
* @throws NoSuchAlgorithmException
*/
static void saveReviewComment(IMAPMessage message,
Resource target)
throws IOException, MessagingException, PermissionDenied, NoSuchAlgorithmException {
User sender = IMAPMessageUtil.extractSender(message);
if (!AccessControl.isProjectResourceCreatable(
sender, target.getProject(), ResourceType.REVIEW_COMMENT)) {
throw new PermissionDenied(cannotCreateMessage(sender,
target.getProject(), target.getType()));
}
Content content = extractContent(message);
String messageID = message.getMessageID();
Address[] allRecipients = message.getAllRecipients();
saveReviewComment(target, sender, content, messageID, allRecipients);
}
@Transactional
protected static ReviewComment saveReviewComment(Resource target,
User sender,
Content content,
String messageID,
Address[] allRecipients)
throws MessagingException, IOException, NoSuchAlgorithmException {
ReviewComment comment;
CommentThread thread = CommentThread.find.byId(Long.valueOf(target.getId()));
if (thread == null) {
throw new IllegalArgumentException();
}
comment = new ReviewComment();
comment.setContents(content.body);
comment.author = new UserIdent(sender);
comment.thread = thread;
comment.save();
Map<String, Attachment> relatedAttachments = saveAttachments(
content.attachments,
comment.asResource());
if (new ContentType(content.type).match(MimeType.HTML)) {
// replace cid with attachments
comment.setContents(replaceCidWithAttachments(
comment.getContents(), relatedAttachments));
comment.update();
}
new OriginalEmail(messageID, comment.asResource()).save();
// Add the event
if (thread.isOnPullRequest()) {
addEvent(NotificationEvent.forNewComment(sender, thread.pullRequest, comment),
allRecipients, sender);
} else {
try {
String commitId;
if (thread instanceof CodeCommentThread) {
commitId = ((CodeCommentThread)thread).commitId;
} else if (thread instanceof NonRangedCodeCommentThread) {
commitId = ((NonRangedCodeCommentThread)thread).commitId;
} else {
throw new IllegalArgumentException();
}
addEvent(NotificationEvent.forNewCommitComment(target.getProject(), comment,
commitId, sender), allRecipients, sender);
} catch (Exception e) {
Logger.warn("Failed to send a notification", e);
}
}
return comment;
}
// You don't need to instantiate this class because this class is just
// a set of static methods.
private CreationViaEmail() { }
@Nonnull
private static Content extractContent(MimePart part) throws IOException, MessagingException {
return processPart(part, null);
}
@Nonnull
private static Content processPart(MimePart part,
MimePart parent) throws MessagingException,
IOException {
if (part == null) {
return new Content();
}
if (part.getFileName() != null) {
// Assume that a part which has a filename is an attachment.
return new Content(part);
}
if (part.isMimeType("text/*")) {
return getContent(part);
} else if (part.isMimeType("multipart/*")) {
if (part.isMimeType(MimeType.MULTIPART_RELATED)) {
return getContentWithAttachments(part);
} else if (part.isMimeType(MimeType.MULTIPART_ALTERNATIVE)) {
return getContentOfBestPart(part, parent);
} else {
return getJoinedContent(part);
}
}
return new Content();
}
private static Content getJoinedContent(MimePart part) throws IOException, MessagingException {
Content result = new Content();
MimeMultipart mp = (MimeMultipart) part.getContent();
for(int i = 0; i < mp.getCount(); i++) {
MimeBodyPart p = (MimeBodyPart) mp.getBodyPart(i);
result.merge(processPart(p, part));
}
return result;
}
private static Content getContent(MimePart part) throws IOException, MessagingException {
Content result = new Content();
result.body = (String) part.getContent();
result.type = part.getContentType();
return result;
}
private static Content getContentOfBestPart(MimePart part, MimePart parent) throws IOException, MessagingException {
MimeBodyPart best = null;
MimeMultipart mp = (MimeMultipart) part.getContent();
for(int i = 0; i < mp.getCount(); i++) {
// Prefer HTML if the parent is a multipart/related part which may contain
// inline images, because text/plain cannot embed the images.
boolean isHtmlPreferred =
parent != null && parent.isMimeType(MimeType.MULTIPART_RELATED);
best = better((MimeBodyPart) mp.getBodyPart(i), best, isHtmlPreferred);
}
return processPart(best, part);
}
private static Content getContentWithAttachments(MimePart part) throws MessagingException, IOException {
Content result = new Content();
String rootId = new ContentType(part.getContentType())
.getParameter("start");
MimeMultipart mp = (MimeMultipart) part.getContent();
for(int i = 0; i < mp.getCount(); i++) {
MimePart p = (MimePart) mp.getBodyPart(i);
if (isRootPart(p, i, rootId)) {
result = result.merge(processPart(p, part));
} else {
result.attachments.add(p);
}
}
return result;
}
/**
* Returns true if the given part is root part.
*
* The given part is root part, if the part is the first one and the given
* root id is not defined or the content id of the part equals to the given
* root id.
*
* @param part
* @param nthPart
* @param rootId
* @return
* @throws MessagingException
*/
private static boolean isRootPart(MimePart part, int nthPart, String rootId) throws MessagingException {
return (rootId == null && nthPart == 0) || StringUtils.equals(part.getContentID(), rootId);
}
private static int getPoint(BodyPart p, String[] preferences) throws MessagingException {
if (p == null) {
return 0;
}
for (int i = 0; i < preferences.length; i++) {
if (p.isMimeType(preferences[i])) {
return preferences.length + 1 - i;
}
}
return 1;
}
/**
* multipart/related > text/plain > the others
*
* @param p
* @param best
* @param isHtmlPreferred
* @return
* @throws javax.mail.MessagingException
*/
private static MimeBodyPart better(MimeBodyPart p, MimeBodyPart best, boolean isHtmlPreferred) throws
MessagingException {
String[] preferences;
if (isHtmlPreferred) {
preferences = new String[]{MimeType.MULTIPART_RELATED, MimeType.HTML, MimeType.PLAIN_TEXT};
} else {
preferences = new String[]{MimeType.MULTIPART_RELATED, MimeType.PLAIN_TEXT, MimeType.HTML};
}
return getPoint(p, preferences) > getPoint(best, preferences) ? p : best;
}
private static String cannotCreateMessage(User user, Project project,
ResourceType resourceType) {
Lang lang = Lang.apply(user.getPreferredLanguage());
String resourceTypeName = resourceType.getName(lang);
return Messages.get(lang, "viaEmail.error.cannotCreate", user, resourceTypeName, project);
}
private static void addEvent(NotificationEvent event, Address[] recipients,
User sender) {
HashSet<User> emailUsers = new HashSet<>();
emailUsers.add(sender);
for (Address addr : recipients) {
emailUsers.add(
User.findByEmail(((InternetAddress) addr).getAddress()));
}
event.receivers.removeAll(emailUsers);
NotificationEvent.add(event);
}
private static String replaceCidWithAttachments(String html,
Map<String, Attachment> attachments) {
Document doc = Jsoup.parse(html);
String[] attrNames = {"src", "href"};
for (String attrName : attrNames) {
Elements tags = doc.select("*[" + attrName + "]");
for (Element tag : tags) {
String uriString = tag.attr(attrName).trim();
if (!uriString.toLowerCase().startsWith("cid:")) {
continue;
}
String cid = uriString.substring("cid:".length());
if (!attachments.containsKey(cid)) {
continue;
}
Long id = attachments.get(cid).id;
tag.attr(attrName,
controllers.routes.AttachmentApp.getFile(id).url());
}
}
Elements bodies = doc.getElementsByTag("body");
if (bodies.size() > 0) {
return bodies.get(0).html();
} else {
return doc.html();
}
}
private static Attachment saveAttachment(Part partToAttach, Resource container)
throws MessagingException, IOException, NoSuchAlgorithmException {
Attachment attach = new Attachment();
String fileName = MimeUtility.decodeText(partToAttach.getFileName());
attach.store(partToAttach.getInputStream(), fileName, container);
if (!attach.mimeType.equalsIgnoreCase(partToAttach.getContentType())) {
Logger.info("The email says the content type is '" + partToAttach
.getContentType() + "' but Yobi determines it is '" +
attach.mimeType + "'");
}
return attach;
}
private static Map<String, Attachment> saveAttachments(
Collection<MimePart> partsToAttach, Resource container)
throws MessagingException, IOException, NoSuchAlgorithmException {
Map<String, Attachment> result = new HashMap<>();
for (MimePart partToAttach : partsToAttach) {
Attachment attachment = saveAttachment(partToAttach, container);
if(partToAttach.getContentID() != null) {
String cid = partToAttach.getContentID().trim();
cid = cid.replace("<", "");
cid = cid.replace(">", "");
result.put(cid, attachment);
}
}
return result;
}
}