/** * 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; private final static String IMAP_USE_KEY = "imap.use"; private final static boolean IMAP_USE_DEFAULT = true; private final static String IMAP_FOLDER_KEY = "imap.folder"; private final static String IMAP_FOLDER_DEFAULT = "inbox"; private final static String IMAP_HOST_KEY = "imap.host"; private final static String IMAP_PASSWORD_KEY = "imap.password"; private final static String IMAP_SSL_KEY = "imap.ssl"; private final static String IMAP_USER_KEY = "imap.user"; /** * Among the given {@code keys}, returns the keys that does not exist in * the given {@code config}. * * @param config * @param keys * @return the keys which don't have a matched value */ private static Set<String> getMissingKeys(Configuration config, String... keys) { Set<String> requiredKeys = new HashSet<>(Arrays.asList(keys)); requiredKeys.removeAll(config.keys()); return requiredKeys; } /** * Connects to the configured IMAP server. * * @return the store to be connected * @throws MessagingException */ private IMAPStore connect() throws MessagingException { final Configuration config = Configuration.root(); Set<String> missingKeys = getMissingKeys(config, IMAP_HOST_KEY, IMAP_USER_KEY, IMAP_PASSWORD_KEY); if (missingKeys.size() > 0) { throw new IllegalStateException( "Cannot connect to the IMAP server because these are" + " not configured: " + missingKeys); } Properties props = new Properties(); String s = config.getBoolean(IMAP_SSL_KEY, false) ? "s" : ""; props.setProperty("mail.store.protocol", "imap" + s); Session session = getDefaultInstance(props, null); store = (IMAPStore) session.getStore(); store.connect(config.getString(IMAP_HOST_KEY), config.getString(IMAP_USER_KEY), config.getString(IMAP_PASSWORD_KEY)); return store; } /** * Stop MailboxService. */ public void stop() { if (folder == null && store == null && pollingSchedule == null) { // We don't need to stop the Mailbox Service which didn't start. return; } 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() { if (Configuration.root().getString(IMAP_HOST_KEY) == null) { play.Logger.info("Mailbox Service doesn't start because IMAP server is not configured."); return; } Configuration config = Configuration.root(); if (!config.getBoolean(IMAP_USE_KEY, IMAP_USE_DEFAULT)) { return; } List<User> users = User.find.where() .ilike("email", config.getString(IMAP_USER_KEY) + "+%").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(); folder = (IMAPFolder) store.getFolder( config.getString(IMAP_FOLDER_KEY, IMAP_FOLDER_DEFAULT)); 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 { if (store == null || !store.isConnected()) { store = connect(); } IMAPFolder folder = (IMAPFolder) store.getFolder( Configuration.root().getString(IMAP_FOLDER_KEY, IMAP_FOLDER_DEFAULT)); 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); } }