Yi EungJun 2014-10-28
Introduce Mailbox Service
Mailbox Service is a service which fetches emails from the IMAP server
configured by imap.* and posts the emails.

If the email address to yobi is "yobi@mail.com":

* If a user want to posts an issue in project "my/proj", just send an
  email to "yobi+my/project@mail.com". The email will be posted as an issue.
* Users can post a comment by replying to notification emails. It works
  only if the notification email can accept replies; The footer of the
  notification email describes the ability.
* If a user discuss using email with many people and you want that Yobi
  records the discussion as an issue and comments in project "my/proj",
  just add an address "yobi+my/proj@mail.com" in CC list. Note: But Yobi
  will records only the emails from Yobi users.
* If a user don't know how to send emails he or she can send an email to
  "yobi+help@mail.com". Then yobi replies with help messages.

Mailbox Service fetches emails in two ways: polling and listening.

* Listening - Yobi listens updates from the IMAP server and
  process new emails immediately. Listening works only if the IMAP
  server supports IDLE command.
* Polling - Yobi fetches new emails on the interval configured by
  `application.mailbox.polling.interval`.

Yobi tries listening first and only if it fails tries polling.

If you want to know how to add the configuration for the IMAP server,
see the Mailbox Service section in conf/application.conf.default file.
@8be433932cb0f9ac18262fe4988f566f6cd09d89
app/Global.java
--- app/Global.java
+++ app/Global.java
@@ -18,22 +18,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import java.io.IOException;
-import java.lang.reflect.Method;
-import java.math.BigInteger;
-import java.net.InetAddress;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.security.SecureRandom;
-import java.util.Date;
 
+import mailbox.MailboxService;
 import com.avaje.ebean.Ebean;
 import controllers.SvnApp;
-import models.*;
-
 import controllers.UserApp;
 import controllers.routes;
+import models.*;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.http.impl.cookie.DateUtils;
 import play.Application;
@@ -41,7 +32,8 @@
 import play.Play;
 import play.api.mvc.Handler;
 import play.data.Form;
-import play.mvc.*;
+import play.mvc.Action;
+import play.mvc.Http;
 import play.mvc.Http.RequestHeader;
 import play.mvc.Result;
 import play.libs.F.Promise;
@@ -62,8 +54,6 @@
 import java.security.SecureRandom;
 import java.util.Date;
 
-import views.html.welcome.secret;
-import views.html.welcome.restart;
 import static play.data.Form.form;
 import static play.mvc.Results.badRequest;
 
@@ -74,6 +64,8 @@
 
     private boolean isSecretInvalid = false;
     private boolean isRestartRequired = false;
+
+    private MailboxService mailboxService = new MailboxService();
 
     @Override
     public void onStart(Application app) {
@@ -88,6 +80,7 @@
         Attachment.onStart();
         YobiUpdate.onStart();
         AccessControl.onStart();
+        mailboxService.start();
     }
 
     private boolean equalsDefaultSecret() {
@@ -221,6 +214,7 @@
     }
 
     public void onStop(Application app) {
+        mailboxService.stop();
     }
 
     @Override
 
app/mailbox/Content.java (added)
+++ app/mailbox/Content.java
@@ -0,0 +1,29 @@
+package mailbox;
+
+import org.apache.commons.lang3.StringUtils;
+
+import javax.mail.internet.MimePart;
+import java.util.ArrayList;
+import java.util.List;
+
+public class Content {
+    public String body = "";
+    public final List<MimePart> attachments = new ArrayList<>();
+    public String type;
+
+    public Content() { }
+
+    public Content(MimePart attachment) {
+        this.attachments.add(attachment);
+    }
+
+    public Content merge(Content that) {
+        body += that.body;
+        if (StringUtils.isEmpty(type)) {
+            type = that.type;
+        }
+        attachments.addAll(that.attachments);
+
+        return this;
+    }
+}
 
app/mailbox/CreationViaEmail.java (added)
+++ app/mailbox/CreationViaEmail.java
@@ -0,0 +1,500 @@
+/**
+ * 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 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)) {
+            // replace cid with attachments
+            comment.contents = replaceCidWithAttachments(
+                    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;
+    }
+
+    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)) {
+            // replace cid with attachments
+            issue.body = replaceCidWithAttachments(
+                    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;
+    }
+
+}
 
app/mailbox/EmailAddressWithDetail.java (added)
+++ app/mailbox/EmailAddressWithDetail.java
@@ -0,0 +1,104 @@
+/**
+ * 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 org.apache.commons.lang3.StringUtils;
+import play.Configuration;
+
+import javax.annotation.Nonnull;
+
+/**
+ * An email address whose detail part is parsed.
+ */
+public class EmailAddressWithDetail {
+    @Nonnull
+    private final String user;
+
+    @Nonnull
+    private String detail;
+
+    @Nonnull
+    private final String domain;
+
+    public EmailAddressWithDetail(@Nonnull String address) {
+        int plus = address.indexOf('+');
+        int at = address.indexOf('@');
+
+        if (plus < 0) {
+            user = address.substring(0, at);
+            detail = "";
+        } else {
+            user = address.substring(0, plus);
+            detail = address.substring(plus + 1, at);
+        }
+
+        domain = address.substring(at + 1);
+    }
+
+    /**
+     * @return  whether this email address is to Yobi
+     */
+    public boolean isToYobi() {
+        Configuration imapConfig = Configuration.root().getConfig("imap");
+        String yobiAddr = imapConfig.getString("address", imapConfig.getString("user"));
+        return this.equalsExceptDetails(new EmailAddressWithDetail(yobiAddr));
+    }
+
+    @Nonnull
+    public String getUser() {
+        return user;
+    }
+
+    @Nonnull
+    public String getDetail() {
+        return detail;
+    }
+
+    @Nonnull
+    public String getDomain() {
+        return domain;
+    }
+
+    /**
+     * Set the detail part.
+     *
+     * @param detail
+     */
+    public void setDetail(@Nonnull String detail) {
+        if (detail == null) {
+            throw new IllegalArgumentException("detail must not be null");
+        }
+        this.detail = detail;
+    }
+
+    /**
+     * @param that
+     * @return  whether the given address has the same detail with this
+     */
+    public boolean equalsExceptDetails(@Nonnull EmailAddressWithDetail that) {
+        return this.getUser().equals(that.getUser())
+            && this.getDomain().equals(that.getDomain());
+    }
+
+    public String toString() {
+        return StringUtils.join(new String[]{user, detail}, "+") + "@" + domain;
+    }
+}
 
app/mailbox/EmailHandler.java (added)
+++ app/mailbox/EmailHandler.java
@@ -0,0 +1,489 @@
+/**
+ * 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.*;
+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 play.Logger;
+import play.api.i18n.Lang;
+import play.i18n.Messages;
+import utils.AccessControl;
+import utils.Config;
+
+import javax.annotation.Nonnull;
+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;
+
+        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());
+
+            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 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;
+    }
+
+    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;
+                }
+            }
+        }
+
+        return threads;
+    }
+
+    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;
+    }
+}
 
app/mailbox/IMAPMessageUtil.java (added)
+++ app/mailbox/IMAPMessageUtil.java
@@ -0,0 +1,54 @@
+/**
+ * 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 models.*;
+import org.apache.commons.lang.ArrayUtils;
+
+import javax.mail.*;
+import javax.mail.internet.*;
+
+public class IMAPMessageUtil {
+    public static User extractSender(Message msg) throws MessagingException {
+        for (Address addr : msg.getFrom()) {
+            User user = User.findByEmail(((InternetAddress)addr).getAddress());
+            if (!user.equals(User.anonymous)) {
+                return user;
+            }
+        }
+
+        return User.anonymous;
+    }
+
+    public static String asString(IMAPMessage msg) throws MessagingException {
+        return String.format("{Subject: %s, From: %s, To: %s}",
+                msg.getSubject(),
+                ArrayUtils.toString(msg.getFrom()),
+                ArrayUtils.toString(msg.getAllRecipients()));
+    }
+
+    public static String getIdLeftFromMessageId(String messageId) {
+        int start = messageId.indexOf('<');
+        int at = messageId.indexOf('@');
+        return messageId.substring(start + 1, at).trim().replaceFirst("^/", "");
+    }
+}
 
app/mailbox/MailboxService.java (added)
+++ app/mailbox/MailboxService.java
@@ -0,0 +1,351 @@
+/**
+ * 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 akka.actor.Cancellable;
+import com.sun.mail.imap.IMAPFolder;
+import com.sun.mail.imap.IMAPMessage;
+import com.sun.mail.imap.IMAPStore;
+import models.Property;
+import models.User;
+import play.Configuration;
+import play.Logger;
+import play.libs.Akka;
+import scala.concurrent.duration.Duration;
+import utils.Diagnostic;
+import utils.SimpleDiagnostic;
+
+import javax.annotation.Nonnull;
+import javax.annotation.concurrent.NotThreadSafe;
+import javax.mail.Folder;
+import javax.mail.FolderClosedException;
+import javax.mail.MessagingException;
+import javax.mail.Session;
+import javax.mail.event.MessageCountEvent;
+import javax.mail.event.MessageCountListener;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+import static javax.mail.Session.getDefaultInstance;
+
+/**
+ * MailboxService opens a mailbox and process emails if necessary.
+ *
+ * MailboxService connects an IMAP server and opens the mailbox from the
+ * server. Every configuration to be needed to do that is defined by imap.* in
+ * conf/application.conf.
+ *
+ * Then MailboxService fetches and processes emails in the mailbox as follows:
+ *
+ * 1. Only emails whose recipients contain the address of Yobi defined by
+ *    imap.address configuration are accepted.
+ * 2. Emails must have one or more recipients which are Yobi projects; If not
+ *    Yobi replies with an error.
+ * 3. Emails which reference or reply to a resource assumed to comment the
+ *    resource; otherwise assumed to post an issue in the projects.
+ * 4. Yobi does the assumed job only if the sender has proper permission to do
+ *    that; else Yobi replies with a permission denied error.
+ *
+ * Note: It is possible to create multiple resources if the recipients contain
+ * multiple projects.
+ */
+@NotThreadSafe
+public class MailboxService {
+    private IMAPStore store;
+    private static IMAPFolder folder;
+    private Thread idleThread;
+    private Cancellable pollingSchedule;
+    private boolean isStopping = false;
+
+    /**
+     * Among the given {@code keys}, returns the keys which don't have a matched
+     * value in the given {@code config}.
+     *
+     * @param   config
+     * @param   keys
+     * @return  the keys which don't have a matched value
+     */
+    private static Set<String> getNotConfiguredKeys(Configuration config, String... keys) {
+        Set<String> notConfigured = new HashSet<>();
+        Map<String, Object> configMap = config.asMap();
+        for (String key : keys) {
+            if (!configMap.containsKey(key)) {
+                notConfigured.add(key);
+            }
+        }
+        return notConfigured;
+    }
+
+    /**
+     * Connect to the IMAP server configured by the given {@code imapConfig}.
+     *
+     * @param imapConfig
+     * @return the store to be connected
+     * @throws MessagingException
+     */
+    private IMAPStore connect(Configuration imapConfig) throws MessagingException {
+        Set<String> notConfiguredKeys =
+                getNotConfiguredKeys(imapConfig, "host", "user", "password");
+
+        if (!notConfiguredKeys.isEmpty()) {
+            throw new IllegalStateException(
+                    "Cannot connect to the IMAP server because these are" +
+                            " not configured: " + notConfiguredKeys);
+        }
+
+        Properties props = new Properties();
+        String s = imapConfig.getBoolean("ssl", false) ? "s" : "";
+        props.setProperty("mail.store.protocol", "imap" + s);
+
+        Session session = getDefaultInstance(props, null);
+        store = (IMAPStore) session.getStore();
+        store.connect(imapConfig.getString("host"),
+                imapConfig.getString("user"),
+                imapConfig.getString("password"));
+        return store;
+
+    }
+
+    /**
+     * Stop MailboxService.
+     */
+    public void stop() {
+        isStopping = true;
+        try {
+            folder.close(true);
+            store.close();
+            if (pollingSchedule != null && !pollingSchedule.isCancelled()) {
+                pollingSchedule.cancel();
+            }
+        } catch (MessagingException e) {
+            play.Logger.error("Error occurred while stop the email receiver", e);
+        }
+    }
+
+    /**
+     * Start Mailbox Service.
+     */
+    public void start() {
+        final Configuration imapConfig = Configuration.root().getConfig("imap");
+
+        if (imapConfig == null) {
+            play.Logger.info("Mailbox Service doesn't start because IMAP server is not configured.");
+            return;
+        }
+
+        List<User> users = User.find.where()
+                .ilike("email", imapConfig.getString("user") + "+%").findList();
+
+        if (users.size() == 1) {
+            Logger.warn("There is a user whose email is danger: " + users);
+        }
+        if (users.size() > 1) {
+            Logger.warn("There are some users whose email is danger: " + users);
+        }
+
+        try {
+            store = connect(imapConfig);
+            folder = (IMAPFolder) store.getFolder(
+                    imapConfig.getString("folder", "inbox"));
+            folder.open(Folder.READ_ONLY);
+        } catch (Exception e) {
+            play.Logger.error("Failed to open IMAP folder", e);
+            return;
+        }
+
+        try {
+            EmailHandler.handleNewMessages(folder);
+        } catch (MessagingException e) {
+            play.Logger.error("Failed to handle new messages");
+        }
+
+        try {
+            startEmailListener();
+        } catch (Exception e) {
+            startEmailPolling();
+        }
+
+        Diagnostic.register(new SimpleDiagnostic() {
+            @Override
+            public String checkOne() {
+                if (idleThread == null) {
+                    return "The Email Receiver is not initialized";
+                } else if (!idleThread.isAlive()) {
+                    return "The Email Receiver is not running";
+                } else {
+                    return null;
+                }
+            }
+        });
+    }
+
+    /**
+     * Reopen the IMAP folder which is used by MailboxService.
+     *
+     * @return  the open IMAP folder
+     * @throws  MessagingException
+     */
+    private IMAPFolder reopenFolder() throws MessagingException {
+        final Configuration imapConfig = Configuration.root().getConfig("imap");
+
+        if (store == null || !store.isConnected()) {
+            store = connect(imapConfig);
+        }
+
+        IMAPFolder folder = (IMAPFolder) store.getFolder(
+                imapConfig.getString("folder", "inbox"));
+        folder.open(Folder.READ_ONLY);
+
+        return folder;
+    }
+
+    /**
+     * Start the polling of emails.
+     *
+     * The polling is fetching new emails from the IMAP folder and processing
+     * them.
+     *
+     * This polling is a fallback of
+     * {@link #startEmailListener()} if the IMAP server does
+     * not support IDLE command.
+     */
+    private void startEmailPolling() {
+        Runnable polling = new Runnable() {
+            @Override
+            public void run() {
+                try {
+                    if (folder == null || !folder.isOpen()) {
+                        folder = reopenFolder();
+                    }
+
+                    EmailHandler.handleNewMessages(folder);
+                } catch (MessagingException e) {
+                    play.Logger.error("Failed to poll emails", e);
+                    return;
+                }
+
+                try {
+                    folder.close(true);
+                } catch (MessagingException e) {
+                    play.Logger.error("Failed to close the IMAP folder", e);
+                }
+            }
+        };
+
+        pollingSchedule = Akka.system().scheduler().schedule(
+                Duration.create(0, TimeUnit.MINUTES),
+                Duration.create(
+                        Configuration.root().getMilliseconds("application.mailbox.polling.interval", 5 * 60 * 1000L),
+                        TimeUnit.MILLISECONDS),
+                polling,
+                Akka.system().dispatcher()
+        );
+    }
+
+    /**
+     * Start the email listener by using IDLE command.
+     *
+     * The listener will fetch new emails from the IMAP folder and process them.
+     *
+     * @throws MessagingException
+     * @throws UnsupportedOperationException
+     */
+    private void startEmailListener()
+            throws MessagingException, UnsupportedOperationException {
+        if (!((IMAPStore)folder.getStore()).hasCapability("IDLE")) {
+            throw new UnsupportedOperationException(
+                    "The imap server does not support IDLE command");
+        }
+
+        MessageCountListener messageCountListener = new MessageCountListener() {
+            @Override
+            public void messagesAdded(@Nonnull MessageCountEvent e) {
+                try {
+                    EmailHandler.handleMessages(folder, e.getMessages());
+                } catch (Exception e1) {
+                    play.Logger.error("Unexpected error occurs while handling messages", e1);
+                }
+            }
+
+            @Override
+            public void messagesRemoved(MessageCountEvent e) {
+
+            }
+        };
+
+        // Add the handler for messages to be added in the future.
+        folder.addMessageCountListener(messageCountListener);
+
+        idleThread = new Thread() {
+            @Override
+            public void run() {
+                Logger.info("Start the Email Receiving Thread");
+                while (true) {
+                    if (isStopping) break;
+                    try {
+                        // Notify the message count listener if the value of EXISTS response is
+                        // larger than realTotal.
+                        folder.idle();
+                    } catch (FolderClosedException e) {
+                        if (isStopping) break;
+                        // reconnect
+                        Logger.info("Reopen the imap folder");
+                        try {
+                            folder = reopenFolder();
+                        } catch (MessagingException e1) {
+                            Logger.warn("Failed to reopen the imap folder; " +
+                                    "abort", e1);
+                            break;
+                        }
+                    } catch (Exception e) {
+                        Logger.warn("Failed to run IDLE command; abort", e);
+                        break;
+                    }
+                }
+                Logger.info("Stop the Email Receiving Thread");
+            }
+        };
+        idleThread.start();
+    }
+
+    /**
+     * Update the lastSeenUID.
+     *
+     * lastSeenUID MUST be updated when a new email is processed so that
+     * MailboxService fetches new emails correctly.
+     *
+     * @param msg
+     * @throws MessagingException
+     */
+    synchronized static void updateLastSeenUID(IMAPMessage msg) throws MessagingException {
+        long uid = folder.getUID(msg);
+
+        // Do not update lastSeenUID if it is larger than the current uid.
+        try {
+            long lastSeenUID = Property.getLong(Property.Name.MAILBOX_LAST_SEEN_UID);
+            if (uid <= lastSeenUID) {
+                return;
+            }
+        } catch (Exception ignored) { }
+
+        Property.set(Property.Name.MAILBOX_LAST_SEEN_UID, uid);
+    }
+}
 
app/mailbox/exceptions/IllegalDetailException.java (added)
+++ app/mailbox/exceptions/IllegalDetailException.java
@@ -0,0 +1,25 @@
+/**
+ * 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.exceptions;
+
+public class IllegalDetailException extends Exception {
+    private static final long serialVersionUID = -1678153413236121508L;
+}
 
app/mailbox/exceptions/IssueNotFound.java (added)
+++ app/mailbox/exceptions/IssueNotFound.java
@@ -0,0 +1,29 @@
+/**
+ * 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.exceptions;
+
+public class IssueNotFound extends MailHandlerException {
+    private static final long serialVersionUID = -6222671675057709905L;
+
+    public IssueNotFound(Long number) {
+        super(String.format("Issue %d not found", number));
+    }
+}
 
app/mailbox/exceptions/MailHandlerException.java (added)
+++ app/mailbox/exceptions/MailHandlerException.java
@@ -0,0 +1,29 @@
+/**
+ * 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.exceptions;
+
+public class MailHandlerException extends Exception{
+    private static final long serialVersionUID = 3925450020478998663L;
+
+    MailHandlerException(String s) {
+        super(s);
+    }
+}
 
app/mailbox/exceptions/PermissionDenied.java (added)
+++ app/mailbox/exceptions/PermissionDenied.java
@@ -0,0 +1,29 @@
+/**
+ * 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.exceptions;
+
+public class PermissionDenied extends MailHandlerException {
+    private static final long serialVersionUID = 1408386395224769560L;
+
+    public PermissionDenied(String s) {
+        super(s);
+    }
+}
 
app/mailbox/exceptions/PostingNotFound.java (added)
+++ app/mailbox/exceptions/PostingNotFound.java
@@ -0,0 +1,29 @@
+/**
+ * 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.exceptions;
+
+public class PostingNotFound extends MailHandlerException {
+    private static final long serialVersionUID = 6604209946955366044L;
+
+    public PostingNotFound(Long number) {
+        super(String.format("Posting %d not found", number));
+    }
+}
app/models/AbstractPosting.java
--- app/models/AbstractPosting.java
+++ app/models/AbstractPosting.java
@@ -85,6 +85,14 @@
         this.updatedDate = JodaDateUtil.now();
     }
 
+    public AbstractPosting(Project project, User author, String title, String body) {
+        this();
+        setAuthor(author);
+        this.project = project;
+        this.title = title;
+        this.body = body;
+    }
+
     /**
      * @see models.Issue#increaseNumber()
      * @see models.Posting#increaseNumber()
app/models/Comment.java
--- app/models/Comment.java
+++ app/models/Comment.java
@@ -58,6 +58,12 @@
         createdDate = new Date();
     }
 
+    public Comment(User author, String contents) {
+        this();
+        setAuthor(author);
+        this.contents = contents;
+    }
+
     public Duration ago() {
         return JodaDateUtil.ago(this.createdDate);
     }
app/models/CommentThread.java
--- app/models/CommentThread.java
+++ app/models/CommentThread.java
@@ -20,15 +20,15 @@
  */
 package models;
 
-import models.support.ReviewSearchCondition;
-import play.data.format.Formats;
-import play.data.validation.Constraints;
 import models.enumeration.ResourceType;
 import models.resource.Resource;
 import models.resource.ResourceConvertible;
+import models.support.ReviewSearchCondition;
+import play.data.format.Formats;
+import play.data.validation.Constraints;
 import play.db.ebean.Model;
+
 import javax.persistence.*;
-import java.lang.reflect.ParameterizedType;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
app/models/CommitComment.java
--- app/models/CommitComment.java
+++ app/models/CommitComment.java
@@ -23,6 +23,7 @@
 import models.enumeration.ResourceType;
 import models.resource.Resource;
 import org.apache.commons.lang3.StringUtils;
+import playRepository.Commit;
 
 import javax.persistence.Entity;
 import javax.persistence.Transient;
@@ -66,6 +67,11 @@
                 return authorId;
             }
 
+            @Override
+            public Resource getContainer() {
+                return Commit.getAsResource(project, commitId);
+            }
+
             public void delete() {
                 CommitComment.this.delete();
             }
app/models/Issue.java
--- app/models/Issue.java
+++ app/models/Issue.java
@@ -35,19 +35,18 @@
 import models.resource.Resource;
 import models.support.SearchCondition;
 import org.apache.commons.lang3.time.DateUtils;
+import play.data.Form;
 import play.data.format.Formats;
 import play.i18n.Messages;
 import utils.JodaDateUtil;
 
 import javax.persistence.*;
-
-import play.data.Form;
-
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.lang.Boolean;
 import java.text.SimpleDateFormat;
 import java.util.*;
+import java.util.regex.Pattern;
 import java.util.regex.Pattern;
 
 @Entity
@@ -106,6 +105,15 @@
     @Formula(select = "case when due_date is null then cast('9999-12-31 23:59:59' as timestamp) else due_date end")
     public Date dueDateAsc;
 
+    public Issue(Project project, User author, String title, String body) {
+        super(project, author, title, body);
+        this.state = State.OPEN;
+    }
+
+    public Issue() {
+        super();
+    }
+
     /**
      * @see models.AbstractPosting#computeNumOfComments()
      */
app/models/IssueComment.java
--- app/models/IssueComment.java
+++ app/models/IssueComment.java
@@ -42,6 +42,11 @@
     )
     public List<User> voters;
 
+    public IssueComment(Issue issue, User author, String contents) {
+        super(author, contents);
+        this.issue = issue;
+    }
+
     /**
      * @see Comment#getParent()
      */
app/models/NotificationEvent.java
--- app/models/NotificationEvent.java
+++ app/models/NotificationEvent.java
@@ -42,6 +42,7 @@
 import playRepository.*;
 import scala.concurrent.duration.Duration;
 import utils.AccessControl;
+import utils.Config;
 import utils.EventConstants;
 import utils.RouteUtil;
 
@@ -480,6 +481,10 @@
      */
     public static void afterNewComment(User sender, PullRequest pullRequest,
                                        ReviewComment newComment, String urlToView) {
+        NotificationEvent.add(forNewComment(sender, pullRequest, newComment));
+    }
+
+    public static NotificationEvent forNewComment(User sender, PullRequest pullRequest, ReviewComment newComment) {
         NotificationEvent notiEvent = createFrom(sender, newComment);
         notiEvent.title = formatReplyTitle(pullRequest);
         Set<User> receivers = getMentionedUsers(newComment.getContents());
@@ -489,8 +494,7 @@
         notiEvent.eventType = NEW_REVIEW_COMMENT;
         notiEvent.oldValue = null;
         notiEvent.newValue = newComment.getContents();
-
-        NotificationEvent.add(notiEvent);
+        return notiEvent;
     }
 
     public static NotificationEvent afterNewPullRequest(PullRequest pullRequest) {
@@ -502,21 +506,24 @@
     }
 
     public static void afterNewComment(Comment comment) {
+        NotificationEvent.add(forNewComment(comment, UserApp.currentUser()));
+    }
+
+    public static NotificationEvent forNewComment(Comment comment, User author) {
         AbstractPosting post = comment.getParent();
 
-        NotificationEvent notiEvent = createFromCurrentUser(comment);
+        NotificationEvent notiEvent = createFrom(author, comment);
         notiEvent.title = formatReplyTitle(post);
         Set<User> receivers = getReceivers(post);
         receivers.addAll(getMentionedUsers(comment.contents));
-        receivers.remove(UserApp.currentUser());
+        receivers.remove(author);
         notiEvent.receivers = receivers;
         notiEvent.eventType = NEW_COMMENT;
         notiEvent.oldValue = null;
         notiEvent.newValue = comment.contents;
         notiEvent.resourceType = comment.asResource().getType();
         notiEvent.resourceId = comment.asResource().getId();
-
-        NotificationEvent.add(notiEvent);
+        return notiEvent;
     }
 
     public static void afterNewCommentWithState(Comment comment, State state) {
@@ -585,8 +592,6 @@
         return notiEvent;
     }
 
-
-
     public static NotificationEvent afterAssigneeChanged(User oldAssignee, Issue issue) {
         NotificationEvent notiEvent = createFromCurrentUser(issue);
 
@@ -611,14 +616,17 @@
     }
 
     public static void afterNewIssue(Issue issue) {
-        NotificationEvent notiEvent = createFromCurrentUser(issue);
+        NotificationEvent.add(forNewIssue(issue, UserApp.currentUser()));
+    }
+
+    public static NotificationEvent forNewIssue(Issue issue, User author) {
+        NotificationEvent notiEvent = createFrom(author, issue);
         notiEvent.title = formatNewTitle(issue);
         notiEvent.receivers = getReceivers(issue);
         notiEvent.eventType = NEW_ISSUE;
         notiEvent.oldValue = null;
         notiEvent.newValue = issue.body;
-
-        NotificationEvent.add(notiEvent);
+        return notiEvent;
     }
 
     public static NotificationEvent afterIssueBodyChanged(String oldBody, Issue issue) {
@@ -635,39 +643,55 @@
     }
 
     public static void afterNewPost(Posting post) {
-        NotificationEvent notiEvent = createFromCurrentUser(post);
+        NotificationEvent.add(forNewPosting(post, UserApp.currentUser()));
+    }
+
+    public static NotificationEvent forNewPosting(Posting post, User author) {
+        NotificationEvent notiEvent = createFrom(author, post);
         notiEvent.title = formatNewTitle(post);
         notiEvent.receivers = getReceivers(post);
         notiEvent.eventType = NEW_POSTING;
         notiEvent.oldValue = null;
         notiEvent.newValue = post.body;
-
-        NotificationEvent.add(notiEvent);
+        return notiEvent;
     }
 
     public static void afterNewCommitComment(Project project, ReviewComment comment,
                                              String commitId) throws
             IOException, SVNException, ServletException {
+        NotificationEvent.add(
+                forNewCommitComment(project, comment, commitId, UserApp.currentUser()));
+    }
+
+    public static NotificationEvent forNewCommitComment(
+            Project project, ReviewComment comment, String commitId, User author)
+            throws IOException, SVNException, ServletException {
         Commit commit = RepositoryService.getRepository(project).getCommit(commitId);
         Set<User> watchers = commit.getWatchers(project);
         watchers.addAll(getMentionedUsers(comment.getContents()));
-        watchers.remove(UserApp.currentUser());
+        watchers.remove(author);
 
-        NotificationEvent notiEvent = createFromCurrentUser(comment);
+        NotificationEvent notiEvent = createFrom(author, comment);
         notiEvent.title = formatReplyTitle(project, commit);
         notiEvent.receivers = watchers;
         notiEvent.eventType = NEW_REVIEW_COMMENT;
         notiEvent.oldValue = null;
         notiEvent.newValue = comment.getContents();
-
-        NotificationEvent.add(notiEvent);
+        return notiEvent;
     }
 
-    public static void afterNewSVNCommitComment(Project project, CommitComment codeComment) throws IOException, SVNException, ServletException {
+    public static void afterNewSVNCommitComment(Project project, CommitComment codeComment)
+            throws IOException, SVNException, ServletException {
+        NotificationEvent.add(forNewSVNCommitComment(project, codeComment, UserApp.currentUser()));
+    }
+
+    private static NotificationEvent forNewSVNCommitComment(
+            Project project, CommitComment codeComment, User author)
+            throws IOException, SVNException, ServletException {
         Commit commit = RepositoryService.getRepository(project).getCommit(codeComment.commitId);
         Set<User> watchers = commit.getWatchers(project);
         watchers.addAll(getMentionedUsers(codeComment.contents));
-        watchers.remove(UserApp.currentUser());
+        watchers.remove(author);
 
         NotificationEvent notiEvent = createFromCurrentUser(codeComment);
         notiEvent.title = formatReplyTitle(project, commit);
@@ -675,8 +699,7 @@
         notiEvent.eventType = NEW_COMMENT;
         notiEvent.oldValue = null;
         notiEvent.newValue = codeComment.contents;
-
-        NotificationEvent.add(notiEvent);
+        return notiEvent;
     }
 
     public static void afterMemberRequest(Project project, User user, RequestState state) {
@@ -827,7 +850,7 @@
     private static Set<User> getReceivers(AbstractPosting abstractPosting) {
         Set<User> receivers = abstractPosting.getWatchers();
         receivers.addAll(getMentionedUsers(abstractPosting.body));
-        receivers.remove(UserApp.currentUser());
+        receivers.remove(abstractPosting.author);
         return receivers;
     }
 
@@ -1047,4 +1070,5 @@
 
         return find.setRawSql(RawSqlBuilder.parse(sql).create()).findList().size();
     }
+
 }
app/models/NotificationMail.java
--- app/models/NotificationMail.java
+++ app/models/NotificationMail.java
@@ -20,6 +20,7 @@
  */
 package models;
 
+import mailbox.EmailAddressWithDetail;
 import info.schleichardt.play2.mailplugin.Mailer;
 import models.enumeration.ResourceType;
 import models.enumeration.UserState;
@@ -40,13 +41,13 @@
 import scala.concurrent.duration.Duration;
 import utils.Config;
 import utils.Markdown;
-import utils.RouteUtil;
 import utils.Url;
 
 import javax.mail.MessagingException;
 import javax.mail.Session;
 import javax.mail.internet.InternetAddress;
 import javax.mail.internet.MimeMessage;
+import javax.annotation.Nullable;
 import javax.persistence.Entity;
 import javax.persistence.Id;
 import javax.persistence.OneToOne;
@@ -246,6 +247,13 @@
                 email.setFrom(Config.getEmailFromSmtp(), event.getSender().name);
                 email.addTo(Config.getEmailFromSmtp(), utils.Config.getSiteName());
 
+                String replyTo = getReplyTo(event.getResource());
+                boolean acceptsReply = false;
+                if (replyTo != null) {
+                    email.addReplyTo(replyTo);
+                    acceptsReply = true;
+                }
+
                 for (User receiver : usersByLang.get(langCode)) {
                     if (hideAddress) {
                         email.addBcc(receiver.email, receiver.name);
@@ -258,21 +266,21 @@
                     continue;
                 }
 
+                // FIXME: gmail은 From과 To에 같은 주소가 있으면 reply-to를 무시한다.
+
                 Lang lang = Lang.apply(langCode);
 
                 String message = event.getMessage(lang);
                 String urlToView = event.getUrlToView();
-                String reference = Url.removeFragment(event.getUrlToView());
 
                 email.setSubject(event.title);
-
                 Resource resource = event.getResource();
                 if (resource.getType() == ResourceType.ISSUE_COMMENT) {
                     IssueComment issueComment = IssueComment.find.byId(Long.valueOf(resource.getId()));
                     resource = issueComment.issue.asResource();
                 }
-                email.setHtmlMsg(getHtmlMessage(lang, message, urlToView, resource));
-                email.setTextMsg(getPlainMessage(lang, message, Url.create(urlToView)));
+                email.setHtmlMsg(getHtmlMessage(lang, message, urlToView, resource, acceptsReply));
+                email.setTextMsg(getPlainMessage(lang, message, Url.create(urlToView), acceptsReply));
                 email.setCharset("utf-8");
                 email.addReferences();
                 email.setSentDate(event.created);
@@ -283,7 +291,7 @@
                 recipients.addAll(email.getCcAddresses());
                 recipients.addAll(email.getBccAddresses());
                 String logEntry = String.format("\"%s\" %s", escapedTitle, recipients);
-                play.Logger.of("mail").info(logEntry);
+                play.Logger.of("mail.out").info(logEntry);
             } catch (Exception e) {
                 Logger.warn("Failed to send a notification: "
                         + email + "\n" + ExceptionUtils.getStackTrace(e));
@@ -291,8 +299,44 @@
         }
     }
 
-    private static String getHtmlMessage(Lang lang, String message, String urlToView, Resource resource) {
-        String content = getRenderedHTMLWithTemplate(lang, Markdown.render(message), urlToView, resource);
+    @Nullable
+    public static String getReplyTo(Resource resource) {
+        if (resource == null) {
+            return null;
+        }
+
+        String detail = null;
+
+        switch(resource.getType()) {
+            case ISSUE_COMMENT:
+            case NONISSUE_COMMENT:
+            case COMMIT_COMMENT:
+            case REVIEW_COMMENT:
+                detail = resource.getContainer().getDetail();
+                break;
+            case ISSUE_POST:
+            case BOARD_POST:
+            case COMMIT:
+                detail = resource.getDetail();
+                break;
+            default:
+                break;
+        }
+
+        if (detail != null) {
+            EmailAddressWithDetail addr =
+                    new EmailAddressWithDetail(Config.getEmailFromImap());
+            addr.setDetail(detail);
+            return addr.toString();
+        } else {
+            return null;
+        }
+    }
+
+    private static String getHtmlMessage(Lang lang, String message, String urlToView,
+                                         Resource resource, boolean acceptsReply) {
+        String content = views.html.common.notificationMail.render(
+                lang, Markdown.render(message), urlToView, resource, acceptsReply).toString();
 
         Document doc = Jsoup.parse(content);
 
@@ -309,10 +353,6 @@
         String html = doc.html();
 
         return html;
-    }
-
-    private static String getRenderedHTMLWithTemplate(Lang lang, String message, String urlToView, Resource resource){
-        return views.html.common.notificationMail.render(lang, message, urlToView, resource).toString();
     }
 
     /**
@@ -364,12 +404,14 @@
         }
     }
 
-    private static String getPlainMessage(Lang lang, String message, String urlToView) {
+    private static String getPlainMessage(Lang lang, String message, String urlToView, boolean acceptsReply) {
         String msg = message;
         String url = urlToView;
+        String messageKey = acceptsReply ?
+                "notification.replyOrLinkToView" : "notification.linkToView";
 
         if (url != null) {
-            msg += String.format("\n\n--\n" + Messages.get(lang, "notification.linkToView", url));
+            msg += String.format("\n\n--\n" + Messages.get(lang, messageKey, url));
         }
 
         return msg;
 
app/models/OriginalEmail.java (added)
+++ app/models/OriginalEmail.java
@@ -0,0 +1,68 @@
+/**
+ * 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 models;
+
+import models.enumeration.ResourceType;
+import models.resource.Resource;
+import play.data.validation.Constraints;
+import play.db.ebean.Model;
+
+import javax.persistence.*;
+
+@Entity
+@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"resource_type", "resource_id"}))
+public class OriginalEmail extends Model {
+    public static final Finder<Long, OriginalEmail> finder = new Finder<>(Long.class,
+            OriginalEmail.class);
+
+    private static final long serialVersionUID = 9079975193167733297L;
+
+    @Id
+    public Long id;
+
+    @Constraints.Required
+    @Column(unique=true)
+    public String messageId;
+
+    @Constraints.Required
+    @Enumerated(EnumType.STRING)
+    public ResourceType resourceType;
+
+    @Constraints.Required
+    public String resourceId;
+
+    public static OriginalEmail findBy(Resource resource) {
+        return finder.where()
+                .eq("resourceType", resource.getType())
+                .eq("resourceId", resource.getId())
+                .findUnique();
+    }
+
+    public static boolean exists(Resource resource) {
+        return findBy(resource) != null;
+    }
+
+    public OriginalEmail(String messageId, Resource resource) {
+        this.messageId = messageId;
+        this.resourceType = resource.getType();
+        this.resourceId = resource.getId();
+    }
+}
app/models/Posting.java
--- app/models/Posting.java
+++ app/models/Posting.java
@@ -4,13 +4,13 @@
 
 package models;
 
-import javax.persistence.*;
-
 import models.enumeration.ResourceType;
 import models.resource.Resource;
 import utils.JodaDateUtil;
 
-import java.util.*;
+import javax.persistence.*;
+import java.util.Collections;
+import java.util.List;
 
 import static com.avaje.ebean.Expr.eq;
 
@@ -26,6 +26,10 @@
 
     @OneToMany(cascade = CascadeType.ALL)
     public List<PostingComment> comments;
+
+    public Posting(Project project, User author, String title, String body) {
+        super(project, author, title, body);
+    }
 
     /**
      * @see models.Project#increaseLastPostingNumber()
@@ -113,5 +117,4 @@
                 .add(eq("readme", true))
                 .findUnique();
     }
-
 }
app/models/PostingComment.java
--- app/models/PostingComment.java
+++ app/models/PostingComment.java
@@ -33,6 +33,11 @@
     @ManyToOne
     public Posting posting;
 
+    public PostingComment(Posting posting, User author, String contents) {
+        super(author, contents);
+        this.posting = posting;
+    }
+
     /**
      * @see Comment#getParent()
      */
@@ -65,6 +70,11 @@
             public Long getAuthorId() {
                 return authorId;
             }
+
+            @Override
+            public Resource getContainer() {
+                return posting.asResource();
+            }
         };
     }
 }
app/models/Property.java
--- app/models/Property.java
+++ app/models/Property.java
@@ -81,6 +81,10 @@
     }
 
     public static enum Name {
+        // the uid of the most recent email Mailbox has received
+        MAILBOX_LAST_SEEN_UID,
+        // the uidvalidity of the imap folder Mailbox has used most recently
+        MAILBOX_LAST_UID_VALIDITY
         // Add property you need here.
     }
 
app/models/ReviewComment.java
--- app/models/ReviewComment.java
+++ app/models/ReviewComment.java
@@ -20,17 +20,16 @@
  */
 package models;
 
-import java.util.Date;
-import java.util.List;
-
-import javax.persistence.*;
-
 import models.enumeration.ResourceType;
 import models.resource.Resource;
 import models.resource.ResourceConvertible;
 import play.data.validation.Constraints;
 import play.db.ebean.Model;
 
+import javax.persistence.*;
+import java.util.Date;
+import java.util.List;
+
 /**
  * @author Keesun Baik
  */
app/models/enumeration/ResourceType.java
--- app/models/enumeration/ResourceType.java
+++ app/models/enumeration/ResourceType.java
@@ -20,6 +20,9 @@
  */
 package models.enumeration;
 
+import play.api.i18n.Lang;
+import play.i18n.Messages;
+
 public enum ResourceType {
     ISSUE_POST("issue_post"),
     ISSUE_ASSIGNEE("issue_assignee"),
@@ -73,4 +76,23 @@
         }
         throw new IllegalArgumentException("No matching resource type found for [" + value + "]");
     }
+
+    public String asPathSegment() {
+        switch(this) {
+            case ISSUE_POST:
+                return "issue";
+            case BOARD_POST:
+                return "post";
+            case COMMENT_THREAD:
+                return "review";
+            case COMMIT:
+                return "commit";
+            default:
+                return this.resource;
+        }
+    }
+
+    public String getName(Lang lang) {
+        return Messages.get(lang, "resource." + resource);
+    }
 }
app/models/resource/Resource.java
--- app/models/resource/Resource.java
+++ app/models/resource/Resource.java
@@ -174,6 +174,43 @@
     public boolean isAuthoredBy(User user) { return getAuthorId() != null && getAuthorId().equals(user.id); }
     public void delete() { throw new UnsupportedOperationException(); }
 
+    public String getDetail() {
+        Project project = getProject();
+        String type = getType().asPathSegment();
+
+        if (project != null && type != null) {
+            return project + "/" + type;
+        } else {
+            return null;
+        }
+    }
+
+    @Override
+    public boolean equals(Object object) {
+        if (this == object) return true;
+        if (object == null || getClass() != object.getClass()) return false;
+
+        Resource that = (Resource) object;
+
+        if (!getId().equals(that.getId())) return false;
+        if (!getType().equals(that.getType())) return false;
+
+        return true;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = 0;
+        result = 31 * result + (getId() != null ? getId().hashCode() : 0);
+        result = 31 * result + (getType() != null ? getType().hashCode() : 0);
+        return result;
+    }
+
+    @Override
+    public String toString() {
+        return getType().resource() + "/" + getId();
+    }
+
     /**
      * @see {@link actions.IsAllowedAction}
      */
@@ -200,6 +237,34 @@
         }
     }
 
+    /**
+     * Finds a resource by the given resource path.
+     *
+     * The format of resource path is as follows:
+     *
+     *     resource-type "/" resource-id
+     *
+     * @param path
+     * @return
+     */
+    public static Resource findByPath(String path) {
+        String[] segments = path.split("/");
+
+        if (segments.length < 2) {
+            return null;
+        }
+
+        ResourceType resourceType = null;
+
+        try {
+            resourceType = ResourceType.getValue(segments[0]);
+        } catch (IllegalArgumentException e) {
+            return null;
+        }
+
+        return Resource.get(resourceType, segments[1]);
+    }
+
     public String getMessageId() {
         return String.format("<%s@%s>",
                 this, Config.getHostname());
app/playRepository/Commit.java
--- app/playRepository/Commit.java
+++ app/playRepository/Commit.java
@@ -84,6 +84,10 @@
         return Project.find.byId(Long.valueOf(pair[0]));
     }
 
+    public static Resource getAsResource(Project project, String commitId) {
+        return getAsResource(project.id + ":" + commitId);
+    }
+
     public static Resource getAsResource(final String resourceId) {
         return new Resource() {
 
app/utils/Config.java
--- app/utils/Config.java
+++ app/utils/Config.java
@@ -196,18 +196,7 @@
     }
 
     public static String getEmailFromSmtp() {
-        Configuration config = Configuration.root();
-        String user = config.getString("smtp.user");
-
-        if (user == null) {
-            return null;