mailbox.EmailHandler.java Source code

Java tutorial

Introduction

Here is the source code for mailbox.EmailHandler.java

Source

/**
 * 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.IMAPFolder;
import com.sun.mail.imap.IMAPMessage;
import info.schleichardt.play2.mailplugin.Mailer;
import mailbox.exceptions.IllegalDetailException;
import mailbox.exceptions.MailHandlerException;
import models.OriginalEmail;
import models.Project;
import models.Property;
import models.User;
import models.enumeration.Operation;
import models.resource.Resource;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.commons.mail.HtmlEmail;
import org.joda.time.DateTime;
import play.Logger;
import play.api.i18n.Lang;
import play.i18n.Messages;
import utils.AccessControl;
import utils.Config;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.mail.Address;
import javax.mail.Message;
import javax.mail.MessagingException;
import javax.mail.internet.InternetAddress;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * A set of methods to process incoming emails.
 *
 * See {@link MailboxService} for more detailed rules to process the emails.
 */
class EmailHandler {
    /**
     * Fetches new emails from the given IMAP folder and process them.
     *
     * @param folder
     * @throws MessagingException
     */
    static void handleNewMessages(IMAPFolder folder) throws MessagingException {
        Long lastUIDValidity = Property.getLong(Property.Name.MAILBOX_LAST_UID_VALIDITY);
        Long lastSeenUID = Property.getLong(Property.Name.MAILBOX_LAST_SEEN_UID);

        long uidValidity = folder.getUIDValidity();

        // Get new messages and handle them
        if (lastUIDValidity != null && lastUIDValidity.equals(uidValidity) && lastSeenUID != null) {
            // Use the next uid instead of folder.LASTUID which possibly be
            // smaller than lastSeenUID + 1.
            handleMessages(folder, folder.getMessagesByUID(lastSeenUID + 1, folder.getUIDNext()));
        }

        Property.set(Property.Name.MAILBOX_LAST_UID_VALIDITY, uidValidity);
    }

    /**
     * Processes the given emails.
     *
     * @param folder
     * @param messages
     */
    static void handleMessages(IMAPFolder folder, Message[] messages) {
        handleMessages(folder, Arrays.asList(messages));
    }

    private EmailHandler() {
        // You don't need to instantiate this class because this class is just
        // a set of static methods.
    }

    private static List<String> parseMessageIds(String headerValue) {
        // in-reply-to     =   "In-Reply-To:" 1*msg-id CRLF
        // references      =   "References:" 1*msg-id CRLF
        // msg-id          =   [CFWS] "<" id-left "@" id-right ">" [CFWS]
        // CFWS            =   (1*([FWS] comment) [FWS]) / FWS
        // comment         =   "(" *([FWS] ccontent) [FWS] ")"
        // ccontent        =   ctext / quoted-pair / comment
        // FWS             =   ([*WSP CRLF] 1*WSP) /  obs-FWS
        // obs-FWS         =   1*WSP *(CRLF 1*WSP)
        // WSP             =  SP / HTAB
        // ctext           =   %d33-39 /          ; Printable US-ASCII
        //                     %d42-91 /          ;  characters not including
        //                     %d93-126 /         ;  "(", ")", or "\"
        //                     obs-ctext
        List<String> result = new ArrayList<>();
        String cfws = "[^<]*(?:\\([^\\(]*\\))?[^<]*";
        Pattern pattern = Pattern.compile(cfws + "(<[^>]*>)" + cfws);
        Matcher matcher = pattern.matcher(headerValue);
        while (matcher.find()) {
            result.add(matcher.group(1));
        }
        return result;
    }

    private static void handleMessages(final IMAPFolder folder, List<Message> messages) {
        // Sort messages by uid; If they are not sorted, it is possible to miss
        // a email as a followed example:
        //
        // 1. Yobi fetches two messages with uid of 1, 3 and 2.
        // 2. Yobi handles a message with uid of 1 and update lastseenuid to 1.
        // 3. Yobi handles a message with uid of 3 and update lastseenuid to 3.
        // 4. **Yobi Shutdown Abnormally**
        // 5. The system administrator restarts Yobi.
        // 6. Yobi fetches messages with uid larger than 3, the value of the
        //    lastseenuid; It means that **the message with uid of 2 will be
        //    never handled!**
        Collections.sort(messages, new Comparator<Message>() {
            @Override
            public int compare(Message m1, Message m2) {
                try {
                    return Long.compare(folder.getUID(m1), folder.getUID(m2));
                } catch (MessagingException e) {
                    play.Logger.warn("Failed to compare uids of " + m1 + " and " + m2
                            + " while sorting messages by the uid; " + "There is some remote chance of loss of "
                            + "mail requests.");
                    return 0;
                }
            }
        });

        for (Message msg : messages) {
            handleMessage((IMAPMessage) msg);
        }
    }

    private static void handleMessage(@Nonnull IMAPMessage msg) {
        Exception exception = null;
        long startTime = System.currentTimeMillis();
        User author;

        try {
            // Ignore auto-replied emails to avoid suffering from tons of
            // vacation messages. For more details about auto-replied emails,
            // see https://tools.ietf.org/html/rfc3834
            if (isAutoReplied(msg)) {
                return;
            }
        } catch (MessagingException e) {
            play.Logger.warn("Failed to determine whether the email is auto-replied or not: " + msg, e);
        }

        try {
            // Ignore the email if there is an email with the same id. It occurs
            // quite frequently because mail servers send an email twice if the
            // email has two addresses differ from each other: e.g.
            // yobi+my/proj@mail.com and yobi+your/proj@mail.com.
            OriginalEmail sameMessage = OriginalEmail.finder.where().eq("messageId", msg.getMessageID())
                    .findUnique();
            if (sameMessage != null) {
                // Warn if the older email was handled one hour or more ago. Because it is
                // quite long time so that possibly the ignored email is actually
                // new one which should be handled.
                if (sameMessage.getHandledDate().before(new DateTime().minusHours(1).toDate())) {
                    String warn = String.format(
                            "This email '%s' is ignored because an email with"
                                    + " the same id '%s' was already handled at '%s'",
                            msg, sameMessage.messageId, sameMessage.getHandledDate());
                    play.Logger.warn(warn);
                }
                return;
            }
        } catch (MessagingException e) {
            play.Logger.warn("Failed to determine whether the email is duplicated or not: " + msg, e);
        }

        InternetAddress[] senderAddresses;

        try {
            senderAddresses = (InternetAddress[]) msg.getFrom();
        } catch (Exception e) {
            play.Logger.error("Failed to get senders from an email", e);
            return;
        }

        if (senderAddresses == null || senderAddresses.length == 0) {
            play.Logger.warn("This email has no sender: " + msg);
            return;
        }

        for (InternetAddress senderAddress : senderAddresses) {
            List<String> errors = new ArrayList<>();

            author = User.findByEmail(senderAddress.getAddress());

            if (author.isAnonymous()) {
                continue;
            }

            try {
                createResources(msg, author, errors);
            } catch (MailHandlerException e) {
                exception = e;
                errors.add(e.getMessage());
            } catch (Exception e) {
                exception = e;
                String shortDescription;
                try {
                    shortDescription = IMAPMessageUtil.asString(msg);
                } catch (MessagingException e1) {
                    shortDescription = msg.toString();
                }
                play.Logger.warn("Failed to process an email: " + shortDescription, e);
                errors.add("Unexpected error occurs");
            }

            if (errors.size() > 0) {
                String username = senderAddress.getPersonal();
                String emailAddress = senderAddress.getAddress();
                String helpMessage = getHelpMessage(Lang.apply(author.getPreferredLanguage()), username, errors);
                reply(msg, username, emailAddress, helpMessage);
            }

            try {
                log(msg, startTime, exception);
            } catch (MessagingException e) {
                play.Logger.warn("Failed to log mail request", e);
            }

            try {
                MailboxService.updateLastSeenUID(msg);
            } catch (MessagingException e) {
                play.Logger.warn("Failed to update the lastSeenUID", e);
            }
        }
    }

    private static class MailHeader {
        private final IMAPMessage message;
        private final String name;

        public MailHeader(@Nonnull IMAPMessage message, @Nonnull String name) {
            this.message = message;
            this.name = name;
        }

        public boolean containsIgnoreCase(@Nonnull String expectedValue) throws MessagingException {
            String[] values = message.getHeader(name);

            if (values == null) {
                return false;
            }

            for (String value : values) {
                int semicolon = value.indexOf(';');
                if (semicolon >= 0) {
                    value = value.substring(0, semicolon);
                }
                if (value.trim().equalsIgnoreCase(expectedValue)) {
                    return true;
                }
            }

            return false;
        }
    }

    /**
     * @param   message
     * @return  true if the given message looks like auto-replied.
     * @throws  MessagingException
     */
    private static boolean isAutoReplied(IMAPMessage message) throws MessagingException {
        return new MailHeader(message, "Auto-Submitted").containsIgnoreCase("auto-replied")
                || new MailHeader(message, "X-Naver-Absent").containsIgnoreCase("yes");
    }

    private static void createResources(IMAPMessage msg, User sender, List<String> errors)
            throws MessagingException, IOException, MailHandlerException, NoSuchAlgorithmException {

        // Find all threads by message-ids in In-Reply-To and References headers in the email.
        Set<Resource> threads = getThreads(msg);

        // Note: It is possible that an email creates many resources.
        for (Project project : getProjects(msg, sender, errors)) {
            boolean hasCommented = false;

            // If there is a related thread, we assume the author wants to comment the thread.
            for (Resource thread : threads) {
                if (thread.getProject().id.equals(project.id)) {
                    switch (thread.getType()) {
                    case COMMENT_THREAD:
                        CreationViaEmail.saveReviewComment(msg, thread);
                        break;
                    case ISSUE_POST:
                    case BOARD_POST:
                        CreationViaEmail.saveComment(msg, thread);
                        break;
                    }
                    hasCommented = true;
                }
            }

            // If there is no related thread, we assume the author wants to create new issue.
            if (!hasCommented) {
                CreationViaEmail.saveIssue(msg, project);
            }
        }
    }

    private static Set<Project> getProjects(IMAPMessage msg, User sender, List<String> errors)
            throws MessagingException {
        Set<Project> projects = new HashSet<>();
        for (EmailAddressWithDetail address : getMailAddressesToYobi(msg.getAllRecipients())) {
            Project project;
            String detail = address.getDetail();

            if (StringUtils.isEmpty(detail)) {
                // TODO: Do we need send help message?
                continue;
            }

            Lang lang = Lang.apply(sender.getPreferredLanguage());

            if (StringUtils.equalsIgnoreCase(detail, "help")) {
                reply(msg, sender, getHelpMessage(lang, sender));
                continue;
            }

            try {
                project = getProjectFromDetail(detail);
            } catch (IllegalDetailException e) {
                errors.add(Messages.get(lang, "viaEmail.error.email", address.toString()));
                continue;
            }

            if (project == null || !AccessControl.isAllowed(sender, project.asResource(), Operation.READ)) {
                errors.add(Messages.get(lang, "viaEmail.error.forbidden.or.notfound", address.toString()));
                continue;
            }

            projects.add(project);
        }
        return projects;
    }

    /**
     * Returns the threads to which the given message is sent.
     *
     * The threads are determined by message-ids in In-Reply-To and References
     * headers and the detail part of the recipients' email addresses.
     *
     * @param msg
     * @return
     * @throws MessagingException
     */
    private static Set<Resource> getThreads(IMAPMessage msg) throws MessagingException {
        // Get message-ids from In-Reply-To and References headers.
        Set<String> messageIds = new HashSet<>();

        String inReplyTo = msg.getInReplyTo();
        if (inReplyTo != null) {
            messageIds.addAll(parseMessageIds(inReplyTo));
        }

        for (String references : ArrayUtils.nullToEmpty(msg.getHeader("References"))) {
            if (references != null) {
                messageIds.addAll(parseMessageIds(references));
            }
        }

        // Find threads by the message-id.
        Set<Resource> threads = new HashSet<>();

        for (String messageId : messageIds) {
            for (Resource resource : findResourcesByMessageId(messageId)) {
                switch (resource.getType()) {
                case COMMENT_THREAD:
                case ISSUE_POST:
                case BOARD_POST:
                    threads.add(resource);
                    break;
                case REVIEW_COMMENT:
                    threads.add(resource.getContainer());
                    break;
                default:
                    Logger.info("Cannot comment a resource of unknown type: " + resource);
                    break;
                }
            }
        }

        for (EmailAddressWithDetail address : getMailAddressesToYobi(msg.getAllRecipients())) {
            Resource thread = getResourceFromDetail(address.getDetail());
            if (thread != null) {
                threads.add(thread);
            }
        }

        return threads;
    }

    private static @Nullable Resource getResourceFromDetail(@Nullable String detail) {
        if (detail == null) {
            return null;
        }

        // detail = <owner>/<project>/<path>
        // path = <resource-type>/<resource-id>
        String[] segments = detail.split("/", 3);

        if (segments.length < 3) {
            return null;
        }

        return Resource.findByPath(segments[2]);
    }

    private static Project getProjectFromDetail(String detail) throws IllegalDetailException {
        String[] segments = detail.split("/");

        if (segments.length < 2) {
            throw new IllegalDetailException();
        }

        return Project.findByOwnerAndProjectName(segments[0], segments[1]);
    }

    private static String getHelpMessage(Lang lang, String username, List<String> errors) {
        String help = "";
        String paragraphSeparator = "\n\n";
        String sampleProject = "dlab/hive";
        EmailAddressWithDetail address = new EmailAddressWithDetail(Config.getEmailFromImap());
        address.setDetail("dlab/hive/issue");
        help += Messages.get(lang, "viaEmail.help.hello", username);
        if (errors != null && errors.size() > 0) {
            help += paragraphSeparator;
            String error;
            String messageKey;
            if (errors.size() > 1) {
                error = "\n* " + StringUtils.join(errors, "\n* ");
                messageKey = "viaEmail.help.errorMultiLine";
            } else {
                error = errors.get(0);
                messageKey = "viaEmail.help.errorSingleLine";
            }
            help += Messages.get(lang, messageKey, Config.getSiteName(), error);
        }
        help += paragraphSeparator;
        help += Messages.get(lang, "viaEmail.help.intro", Config.getSiteName());
        help += paragraphSeparator;
        help += Messages.get(lang, "viaEmail.help.description", sampleProject, address);
        help += paragraphSeparator;
        help += Messages.get(lang, "viaEmail.help.bye", Config.getSiteName());
        return help;
    }

    private static String getHelpMessage(Lang lang, User to) {
        return getHelpMessage(lang, to, null);
    }

    private static String getHelpMessage(Lang lang, User to, List<String> errors) {
        return getHelpMessage(lang, to.name, errors);
    }

    private static void reply(IMAPMessage origin, String username, String emailAddress, String msg) {
        final HtmlEmail email = new HtmlEmail();

        try {
            email.setFrom(Config.getEmailFromSmtp(), Config.getSiteName());
            email.addTo(emailAddress, username);
            String subject;
            if (!origin.getSubject().toLowerCase().startsWith("re:")) {
                subject = "Re: " + origin.getSubject();
            } else {
                subject = origin.getSubject();
            }
            email.setSubject(subject);
            email.setTextMsg(msg);
            email.setCharset("utf-8");
            email.setSentDate(new Date());
            email.addHeader("In-Reply-To", origin.getMessageID());
            email.addHeader("References", origin.getMessageID());
            Mailer.send(email);
            String escapedTitle = email.getSubject().replace("\"", "\\\"");
            String logEntry = String.format("\"%s\" %s", escapedTitle, email.getToAddresses());
            play.Logger.of("mail").info(logEntry);
        } catch (Exception e) {
            Logger.warn("Failed to send an email: " + email + "\n" + ExceptionUtils.getStackTrace(e));
        }
    }

    private static void reply(IMAPMessage origin, User to, String msg) {
        reply(origin, to.name, to.email, msg);
    }

    private static Set<EmailAddressWithDetail> getMailAddressesToYobi(Address[] addresses) {
        Set<EmailAddressWithDetail> addressesToYobi = new HashSet<>();

        if (addresses != null) {
            for (Address recipient : addresses) {
                EmailAddressWithDetail address = new EmailAddressWithDetail(
                        ((InternetAddress) recipient).getAddress());
                if (address.isToYobi()) {
                    addressesToYobi.add(address);
                }
            }
        }

        return addressesToYobi;
    }

    /**
     * Log a message for an E-mail request.
     *
     * @param message    An email
     * @param startTime  the time in milliseconds when the request is received
     */
    private static void log(@Nonnull IMAPMessage message, long startTime, Exception exception)
            throws MessagingException {

        String time = ((Long) startTime != null) ? ((System.currentTimeMillis() - startTime) + "ms") : "-";

        String entry = String.format("%s %s %s", IMAPMessageUtil.asString(message),
                exception == null ? "SUCCESS" : "FAILED", time);

        if (exception != null && !(exception instanceof MailHandlerException)) {
            Logger.of("mail.in").error(entry, exception);
        } else {
            Logger.of("mail.in").info(entry);
        }
    }

    /**
     * Finds resources by the given message id.
     *
     * If there are resources created via an email which matches the message
     * id, returns the resources, else finds and returns resources which matches
     * the resource path taken from the id-left part of the message id.
     *
     * The format of message-id is defined by RFC 5322 as follows:
     *
     *     msg-id  =   [CFWS] "<" id-left "@" id-right ">" [CFWS]
     *
     * @param  messageId
     * @return the set of resources
     */
    @Nonnull
    public static Set<Resource> findResourcesByMessageId(String messageId) {
        Set<Resource> resources = new HashSet<>();
        Set<OriginalEmail> originalEmails = OriginalEmail.finder.where().eq("messageId", messageId).findSet();

        if (originalEmails.size() > 0) {
            for (OriginalEmail originalEmail : originalEmails) {
                resources.add(Resource.get(originalEmail.resourceType, originalEmail.resourceId));
            }
            return resources;
        }

        try {
            String resourcePath = IMAPMessageUtil.getIdLeftFromMessageId(messageId);
            Resource resource = Resource.findByPath(resourcePath);
            if (resource != null) {
                resources.add(resource);
            }
        } catch (Exception e) {
            Logger.info("Error while finding a resource by message-id '" + messageId + "'", e);
        }

        return resources;
    }
}