All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.google.sitebricks.mail.NettyImapClient Maven / Gradle / Ivy

The newest version!
package com.google.sitebricks.mail;

import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import com.google.sitebricks.mail.imap.*;
import com.google.sitebricks.mail.oauth.OAuthConfig;
import com.google.sitebricks.mail.oauth.OAuth2Config;
import com.google.sitebricks.mail.oauth.Protocol;
import com.google.sitebricks.mail.oauth.XoauthSasl;
import com.google.sitebricks.mail.oauth.Xoauth2Sasl;
import org.jboss.netty.bootstrap.ClientBootstrap;
import org.jboss.netty.channel.Channel;
import org.jboss.netty.channel.ChannelFuture;
import org.jboss.netty.channel.ChannelFutureListener;
import org.jboss.netty.channel.socket.nio.NioClientSocketChannelFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.InetSocketAddress;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

/**
 * @author [email protected] (Dhanji R. Prasanna)
 */
public class NettyImapClient implements MailClient, Idler {
  private static final Logger log = LoggerFactory.getLogger(NettyImapClient.class);
  private static final SimpleDateFormat SINCE_FORMAT = new SimpleDateFormat("dd-MMM-yyyy", Locale.ENGLISH);

  // For debugging, use with caution!
  private static final Map logAllMessagesForUsers = new ConcurrentHashMap();

  private final ExecutorService workerPool;
  private final ExecutorService bossPool;

  private final MailClientConfig config;

  // Connection variables.
  private volatile ClientBootstrap bootstrap;
  private volatile MailClientHandler mailClientHandler;

  // State variables:
  private final AtomicLong sequence = new AtomicLong();
  private volatile Channel channel;
  private volatile Folder currentFolder = null;
  private volatile DisconnectListener disconnectListener;

  public NettyImapClient(MailClientConfig config,
                         ExecutorService bossPool,
                         ExecutorService workerPool) {
    this.workerPool = workerPool;
    this.bossPool = bossPool;
    this.config = config;
  }

  static {
    System.setProperty("mail.mime.decodetext.strict", "false");
  }

  // For debugging, use with caution!
  public static void addUserForVerboseOutput(String username, boolean toStdOut) {
    logAllMessagesForUsers.put(username, toStdOut);
  }

  public void enableSendLogging(boolean enable) {
    log.info("Logging of sent IMAP commands for user {} = {}", config.getUsername(), enable);
    if (enable)
      logAllMessagesForUsers.put(config.getUsername(), false);
    else
      logAllMessagesForUsers.remove(config.getUsername());
  }

  public boolean isConnected() {
    return channel != null
        && channel.isConnected()
        && channel.isOpen()
        && mailClientHandler.isLoggedIn();
  }

  private void reset() {
    Preconditions.checkState(!isConnected(),
        "Cannot reset while mail client is still connected (call disconnect() first).");

    // Just to be on the safe side.
    if (mailClientHandler != null) {
      mailClientHandler.halt();
      mailClientHandler.disconnected();
    }

    this.mailClientHandler = new MailClientHandler(this, config);
    MailClientPipelineFactory pipelineFactory =
        new MailClientPipelineFactory(mailClientHandler, config);

    this.bootstrap = new ClientBootstrap(new NioClientSocketChannelFactory(bossPool, workerPool));
    this.bootstrap.setPipelineFactory(pipelineFactory);

    // Reset state (helps if this is a reconnect).
    this.currentFolder = null;
    this.sequence.set(0L);
    mailClientHandler.idleRequested.set(false);
  }

  @Override
  public boolean connect() {
    return connect(null);
  }

  /**
   * Connects to the IMAP server logs in with the given credentials.
   */
  @Override
  public synchronized boolean connect(final DisconnectListener listener) {
    reset();

    ChannelFuture future = bootstrap.connect(new InetSocketAddress(config.getHost(),
        config.getPort()));

    Channel channel = future.awaitUninterruptibly().getChannel();
    if (!future.isSuccess()) {
      throw new RuntimeException("Could not connect channel", future.getCause());
    }

    this.channel = channel;
    this.disconnectListener = listener;
    if (null != listener) {
      // https://issues.jboss.org/browse/NETTY-47?page=com.atlassian.jirafisheyeplugin%3Afisheye-issuepanel#issue-tabs
      channel.getCloseFuture().addListener(new ChannelFutureListener() {
        @Override
        public void operationComplete(ChannelFuture future) throws Exception {
          mailClientHandler.idleAcknowledged.set(false);
          mailClientHandler.disconnected();
          listener.disconnected();
        }
      });
    }
    return login();
  }

  private boolean login() {
    try {
      channel.write(". CAPABILITY\r\n");
      if (config.getPassword() != null)
        channel.write(". login " + config.getUsername() + " " + config.getPassword() + "\r\n");
      else if (config.getOAuthConfig() != null) {
        // Use xoauth authentication.
        OAuthConfig oauth = config.getOAuthConfig();

        //noinspection ConstantConditions
        String oauthString = new XoauthSasl(config.getUsername(),
            oauth.clientId,
            oauth.clientSecret)

            .build(Protocol.IMAP, oauth.accessToken, oauth.tokenSecret);

        channel.write(". AUTHENTICATE XOAUTH " + oauthString + "\r\n");

      }
      else if (config.getOAuth2Config() != null) {
        // Use xoauth2 authentication.
        OAuth2Config oauth2 = config.getOAuth2Config();

        //noinspection ConstantConditions
        String oauth2String = Xoauth2Sasl.build(config.getUsername(), oauth2.accessToken);

        channel.write(". AUTHENTICATE XOAUTH2 " + oauth2String + "\r\n");

      }
      else
        Preconditions.checkArgument(false, "Must specify a valid oauth/oauth2 config if not using password auth");
      return mailClientHandler.awaitLogin();
    } catch (Exception e) {
      // Capture the wire trace and log it for some extra context here.
      StringBuilder trace = new StringBuilder();
      for (String line : mailClientHandler.getWireTrace()) {
        trace.append(line).append("\n");
      }

      log.warn("Could not oauth or login for {}. Partial trace follows:\n" +
          "----begin wiretrace----\n{}\n----end wiretrace----",
          new Object[]{config.getUsername(), trace.toString(), e});
    }
    return false;
  }

  @Override
  public WireError lastError() {
    return mailClientHandler.lastError();
  }

  @Override
  public List getWireTrace() {
    return mailClientHandler.getWireTrace();
  }

  @Override
  public List getCommandTrace() {
    return mailClientHandler.getCommandTrace();
  }

  @Override
  public void disconnectAsync() {
    workerPool.submit(new Runnable() {
      @Override
      public void run() {
        disconnect();
      }
    });
  }

  /**
   * Logs out of the current IMAP session and releases all resources, including
   * executor services.
   */
  @Override
  public synchronized void disconnect() {
    try {
      // If there is an error with the handler, dont bother logging out.
      if (!mailClientHandler.isHalted()) {
        if (mailClientHandler.idleRequested.get()) {
          log.warn("Disconnect called while IDLE, leaving idle and logging out.");
          done();
        }

        // Log out of the IMAP Server.
        channel.write(". logout\n");
      }

      currentFolder = null;
    } catch (Exception e) {
      // swallow any exceptions.
    } finally {
      // Shut down all channels and exit (leave threadpools as is--for reconnects).
      // The Netty channel close listener will fire a disconnect event to our client,
      // automatically. See connect() for details.
      try {
        channel.close().awaitUninterruptibly(config.getTimeout(), TimeUnit.MILLISECONDS);
      } catch (Exception e) {
        // swallow any exceptions.
      } finally {
        mailClientHandler.idleAcknowledged.set(false);
        mailClientHandler.disconnected();
        if (disconnectListener != null)
          disconnectListener.disconnected();
      }
    }
  }

   ChannelFuture send(Command command, String args, SettableFuture valueFuture) {
    Long seq = sequence.incrementAndGet();

    String commandString = seq + " " + command.toString()
        + (null == args ? "" : " " + args)
        + "\r\n";

    // Log the command but clip the \r\n
    log.debug("Sending {} to server...", commandString.substring(0, commandString.length() - 2));
    Boolean toStdOut = logAllMessagesForUsers.get(config.getUsername());
    if (toStdOut != null) {
      if (toStdOut)
        System.out.println("IMAPsnd[" + config.getUsername() + "]: " + commandString.substring(0, commandString.length() - 2));
      else
        log.info("IMAPsnd[{}]: {}", config.getUsername(), commandString.substring(0, commandString.length() - 2));
    }

    // Enqueue command.
    mailClientHandler.enqueue(new CommandCompletion(command, seq, valueFuture, commandString));


    return channel.write(commandString);
  }

  @Override
  public List capabilities() {
    return mailClientHandler.getCapabilities();
  }

  @Override
  // @Stateless
  public ListenableFuture> listFolders() {
    Preconditions.checkState(mailClientHandler.isLoggedIn(), "Can't execute command because client is not logged in");
    Preconditions.checkState(!mailClientHandler.idleRequested.get(),
        "Can't execute command while idling (are you watching a folder?)");

    SettableFuture> valueFuture = SettableFuture.create();

    send(Command.LIST_FOLDERS, "\"\" \"*\"", valueFuture);

    return valueFuture;
  }

  @Override
  // @Stateless
  public ListenableFuture statusOf(String folder) {
    Preconditions.checkState(mailClientHandler.isLoggedIn(), "Can't execute command because client is not logged in");
    SettableFuture valueFuture = SettableFuture.create();

    String args = '"' + folder + "\" (UIDNEXT RECENT MESSAGES UNSEEN)";
    send(Command.FOLDER_STATUS, args, valueFuture);

    return valueFuture;
  }

  @Override
  public ListenableFuture open(String folder) {
    return open(folder, false);
  }

  @Override
  // @Stateless
  public ListenableFuture open(String folder, boolean readWrite) {
    Preconditions.checkState(mailClientHandler.isLoggedIn(), "Can't execute command because client is not logged in");
    Preconditions.checkState(!mailClientHandler.idleRequested.get(),
        "Can't execute command while idling (are you watching a folder?)");

    final SettableFuture valueFuture = SettableFuture.create();
    final SettableFuture externalFuture = SettableFuture.create();

    valueFuture.addListener(new Runnable() {
      @Override
      public void run() {
        try {
          // We do this to enforce a happens-before ordering between the time a folder is
          // saved to currentFolder and a listener registered by the user may fire in a parallel
          // executor service.
          currentFolder = valueFuture.get();
          externalFuture.set(currentFolder);
        } catch (InterruptedException e) {
          log.error("Interrupted while attempting to open a folder", e);
        } catch (ExecutionException e) {
          log.error("Execution exception while attempting to open a folder", e);
          externalFuture.setException(e);
        }
      }
    }, workerPool);

    String args = '"' + folder + "\"";
    send(readWrite ? Command.FOLDER_OPEN : Command.FOLDER_EXAMINE, args, valueFuture);

    return externalFuture;
  }

  @Override
  public ListenableFuture> list(Folder folder, int start, int end) {
    Preconditions.checkState(mailClientHandler.isLoggedIn(), "Can't execute command because client is not logged in");
    Preconditions.checkState(!mailClientHandler.idleRequested.get(),
        "Can't execute command while idling (are you watching a folder?)");

    checkCurrentFolder(folder);
    checkRange(start, end);
    Preconditions.checkArgument(start > 0, "Start must be greater than zero (IMAP uses 1-based " +
        "indexing)");
    SettableFuture> valueFuture = SettableFuture.create();

    // -ve end range means get everything (*).
    String extensions = config.useGmailExtensions() ? " X-GM-MSGID X-GM-THRID X-GM-LABELS" : "";
    String args = start + ":" + toUpperBound(end) + " (RFC822.SIZE INTERNALDATE FLAGS ENVELOPE UID"
        + extensions + ")";
    send(Command.FETCH_HEADERS, args, valueFuture);

    return valueFuture;
  }

  @Override
  public ListenableFuture> listUidThin(Folder folder, int start, int end) {
    return listUidThin(folder, ImmutableList.of(new Sequence(start, end)));
  }

  @Override
  public ListenableFuture> listUidThin(Folder folder, List sequences) {
    Preconditions.checkState(mailClientHandler.isLoggedIn(), "Can't execute command because client is not logged in");
    Preconditions.checkState(!mailClientHandler.idleRequested.get(),
            "Can't execute command while idling (are you watching a folder?)");

    checkCurrentFolder(folder);
    SettableFuture> valueFuture = SettableFuture.create();

    // -ve end range means get everything (*).
    String extensions = config.useGmailExtensions() ? " X-GM-MSGID X-GM-THRID X-GM-LABELS UID" : "";
    StringBuilder argsBuilder = new StringBuilder();

    // Emit ranges.
    for (int i = 0, sequencesSize = sequences.size(); i < sequencesSize; i++) {
        Sequence seq = sequences.get(i);
        argsBuilder.append(toUpperBound(seq.start));
        if (seq.end != 0)
            argsBuilder.append(':').append(toUpperBound(seq.end));
        if (i < sequencesSize - 1)
            argsBuilder.append(',');
    }
    argsBuilder.append(" (FLAGS" + extensions + ")");
    send(Command.FETCH_THIN_HEADERS_UID, argsBuilder.toString(), valueFuture);

    return valueFuture;
  }

  @Override
  public ListenableFuture> searchUid(Folder folder, String query, Date since) {
    Preconditions.checkState(mailClientHandler.isLoggedIn(), "Can't execute command because client is not logged in");
    Preconditions.checkState(!mailClientHandler.idleRequested.get(),
            "Can't execute command while idling (are you watching a folder?)");

    checkCurrentFolder(folder);
    SettableFuture> valueFuture = SettableFuture.create();

    StringBuilder argsBuilder = new StringBuilder();

    if (config.useGmailExtensions()) {
      argsBuilder.append("X-GM-RAW \"").append(query).append('"');
    } else
      argsBuilder.append(query);

    if (since != null)
      argsBuilder.append(" since ").append(SINCE_FORMAT.format(since));

    send(Command.SEARCH_RAW_UID, argsBuilder.toString(), valueFuture);

    return valueFuture;
  }

  @Override
  public ListenableFuture> exists(Folder folder, Collection uids) {
    Preconditions.checkState(mailClientHandler.isLoggedIn(),
        "Can't execute command because client is not logged in");
    Preconditions.checkState(!mailClientHandler.idleRequested.get(),
            "Can't execute command while idling (are you watching a folder?)");

    checkCurrentFolder(folder);
    SettableFuture> valueFuture = SettableFuture.create();

    StringBuilder argsBuilder = new StringBuilder("uid ");

    Iterator iterator = uids.iterator();
    for (int i = 0, uidsSize = uids.size(); i < uidsSize; i++) {
      argsBuilder.append(iterator.next());

      if (i < uidsSize - 1)
        argsBuilder.append(",");
    }

    send(Command.SEARCH_UID_ONLY, argsBuilder.toString(), valueFuture);

    return valueFuture;
  }

  @Override
  public void expunge() {
    send(Command.EXPUNGE, "", SettableFuture.create());
  }

  private static void checkRange(int start, int end) {
    Preconditions.checkArgument(start <= end || end == -1, "Start must be <= end");
  }

  private static String toUpperBound(int end) {
    return (end > 0)
        ? Integer.toString(end)
        : "*";
  }

  @Override
  public ListenableFuture> addFlags(Folder folder, int imapUid, Set flags) {
    return addOrRemoveFlags(folder, imapUid, flags, true);
  }

  @Override
  public ListenableFuture> removeFlags(Folder folder, int imapUid, Set flags) {
    return addOrRemoveFlags(folder, imapUid, flags, false);
  }

  @Override
  public ListenableFuture> addOrRemoveFlags(Folder folder, int imapUid, Set flags,
                                                      boolean add) {
    Preconditions.checkState(mailClientHandler.isLoggedIn(),
        "Can't execute command because client is not logged in");
    Preconditions.checkState(!mailClientHandler.idleRequested.get(),
        "Can't execute command while idling (are you watching a folder?)");
    checkCurrentFolder(folder);
    SettableFuture> valueFuture = SettableFuture.create();
    String args = imapUid + " " + (add ? "+" : "-") + Flag.toImap(flags);
    send(Command.STORE_FLAGS, args, valueFuture);
    return valueFuture;
  }

  @Override
  public ListenableFuture copy(Folder folder, int imapUid, String toFolder) {
    Preconditions.checkState(mailClientHandler.isLoggedIn(),
        "Can't execute command because client is not logged in");
    Preconditions.checkState(!mailClientHandler.idleRequested.get(),
        "Can't execute command while idling (are you watching a folder?)");
    checkCurrentFolder(folder);
    SettableFuture valueFuture = SettableFuture.create();
    String args = imapUid + " " + toFolder;
    send(Command.COPY, args, valueFuture);
    return valueFuture;
  }

  @Override
  public ListenableFuture> addOrRemoveGmailLabels(Folder folder, int imapUid,
                                                              Set labels, boolean add) {
    Preconditions.checkState(mailClientHandler.isLoggedIn(),
        "Can't execute command because client is not logged in");
    Preconditions.checkState(!mailClientHandler.idleRequested.get(),
        "Can't execute command while idling (are you watching a folder?)");
    checkCurrentFolder(folder);
    SettableFuture> valueFuture = SettableFuture.create();
    StringBuilder args = new StringBuilder();
    args.append(imapUid);
    args.append(add ? " +X-GM-LABELS (" : " -X-GM-LABELS (");
    Iterator it = labels.iterator();
    while (it.hasNext()) {
      args.append(it.next());
      if (it.hasNext())
        args.append(" ");
      else
        args.append(")");
    }
    send(Command.STORE_LABELS, args.toString(), valueFuture);
    return valueFuture;
  }

  @Override
  public ListenableFuture> setGmailLabels(Folder folder, int imapUid,
                                                      Set labels) {
    Preconditions.checkState(mailClientHandler.isLoggedIn(),
        "Can't execute command because client is not logged in");
    Preconditions.checkState(!mailClientHandler.idleRequested.get(),
        "Can't execute command while idling (are you watching a folder?)");
    checkCurrentFolder(folder);
    SettableFuture> valueFuture = SettableFuture.create();
    StringBuilder args = new StringBuilder();
    args.append(imapUid);
    args.append(" X-GM-LABELS (");
    Iterator it = labels.iterator();
    while (it.hasNext()) {
      args.append(it.next());
      if (it.hasNext())
        args.append(" ");
      else
        args.append(")");
    }
    send(Command.STORE_LABELS, args.toString(), valueFuture);
    return valueFuture;
  }

  @Override
  public ListenableFuture> fetch(Folder folder, int start, int end) {
    Preconditions.checkState(mailClientHandler.isLoggedIn(),
        "Can't execute command because client is not logged in");
    Preconditions.checkState(!mailClientHandler.idleRequested.get(),
        "Can't execute command while idling (are you watching a folder?)");

    checkCurrentFolder(folder);
    checkRange(start, end);
    Preconditions.checkArgument(start > 0, "Start must be greater than zero (IMAP uses 1-based " +
        "indexing)");
    SettableFuture> valueFuture = SettableFuture.create();

    String args = start + ":" + toUpperBound(end) + " (uid body[])";
    send(Command.FETCH_BODY, args, valueFuture);

    return valueFuture;
  }

  @Override
  public ListenableFuture fetchUid(Folder folder, int uid) {
    Preconditions.checkState(mailClientHandler.isLoggedIn(), "Can't execute command because client is not logged in");
    Preconditions.checkState(!mailClientHandler.idleRequested.get(),
        "Can't execute command while idling (are you watching a folder?)");

    checkCurrentFolder(folder);
    Preconditions.checkArgument(uid > 0, "UID must be greater than zero");
    SettableFuture valueFuture = SettableFuture.create();

    String args = uid + " (uid body[])";
    send(Command.FETCH_BODY_UID, args, valueFuture);

    return valueFuture;
  }

  @Override
  public synchronized void watch(Folder folder, FolderObserver observer) {
    Preconditions.checkState(mailClientHandler.isLoggedIn(), "Can't execute command because client is not logged in");
    checkCurrentFolder(folder);
    Preconditions.checkState(mailClientHandler.idleRequested.compareAndSet(false, true), "Already idling...");

    // This MUST happen in the following order, otherwise send() may trigger a new mail event
    // before we've registered the folder observer.
    mailClientHandler.observe(observer);
    channel.write(sequence.incrementAndGet() + " idle\r\n");
  }

  @Override
  public synchronized boolean unwatch() {
    if (!mailClientHandler.idleRequested.get())
      return false;

    done();
    return true;
  }

  @Override
  public boolean isIdling() {
    return mailClientHandler.idleAcknowledged.get();
  }

  @Override
  public synchronized void updateOAuthAccessToken(String accessToken, String tokenSecret) {
    config.getOAuthConfig().accessToken = accessToken;
    config.getOAuthConfig().tokenSecret = tokenSecret;
  }

  @Override
  public synchronized void updateOAuth2AccessToken(String accessToken) {
    config.getOAuthConfig().accessToken = accessToken;
  }

  public synchronized void done() {
    log.trace("Dropping out of IDLE...");
    channel.write("done\r\n");
  }

  @Override
  public void idleStart() {
    disconnectListener.idled();
  }

  @Override
  public void idleEnd() {
    disconnectListener.unidled();
  }

  private void checkCurrentFolder(Folder folder) {
    Preconditions.checkState(folder.equals(currentFolder), "You must have opened folder %s" +
        " before attempting to read from it (%s is currently open).", folder.getName(),
        (currentFolder == null ? "No folder" : currentFolder.getName()));
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy