PageRenderTime 128ms CodeModel.GetById 100ms app.highlight 23ms RepoModel.GetById 1ms app.codeStats 1ms

/sitebricks-mail/src/main/java/com/google/sitebricks/mail/MailClientHandler.java

http://github.com/dhanji/sitebricks
Java | 424 lines | 333 code | 63 blank | 28 comment | 43 complexity | a287ddcc2df15eaca842d4711ecd7250 MD5 | raw file
  1package com.google.sitebricks.mail;
  2
  3import com.google.common.annotations.VisibleForTesting;
  4import com.google.common.base.Preconditions;
  5import com.google.common.collect.ImmutableList;
  6import com.google.common.collect.Lists;
  7import com.google.sitebricks.util.BoundedDiscardingList;
  8import org.jboss.netty.channel.ChannelHandlerContext;
  9import org.jboss.netty.channel.ExceptionEvent;
 10import org.jboss.netty.channel.MessageEvent;
 11import org.jboss.netty.channel.SimpleChannelHandler;
 12import org.slf4j.Logger;
 13import org.slf4j.LoggerFactory;
 14
 15import java.util.*;
 16import java.util.concurrent.*;
 17import java.util.concurrent.atomic.AtomicBoolean;
 18import java.util.regex.Matcher;
 19import java.util.regex.Pattern;
 20
 21/**
 22 * A command/response handler for a single mail connection/user.
 23 *
 24 * @author dhanji@gmail.com (Dhanji R. Prasanna)
 25 */
 26class MailClientHandler extends SimpleChannelHandler {
 27  private static final Logger log = LoggerFactory.getLogger(MailClientHandler.class);
 28
 29  private static boolean ENABLE_WIRE_TRACE = true;
 30
 31  private static final Map<String, Boolean> logAllMessagesForUsers = new ConcurrentHashMap<String, Boolean>();
 32
 33  public static final String CAPABILITY_PREFIX = "* CAPABILITY";
 34  static final Pattern GMAIL_AUTH_SUCCESS_REGEX =
 35      Pattern.compile("[.] OK .*@.* \\(Success\\)", Pattern.CASE_INSENSITIVE);
 36  
 37  static final Pattern IMAP_AUTH_SUCCESS_REGEX =
 38	      Pattern.compile("[.] OK (.*)", Pattern.CASE_INSENSITIVE);
 39
 40  static final Pattern COMMAND_FAILED_REGEX =
 41      Pattern.compile("^[.] (NO|BAD) (.*)", Pattern.CASE_INSENSITIVE);
 42  static final Pattern MESSAGE_COULDNT_BE_FETCHED_REGEX =
 43      Pattern.compile("^\\d+ no some messages could not be fetched \\(failure\\)\\s*",
 44          Pattern.CASE_INSENSITIVE);
 45  static final Pattern SYSTEM_ERROR_REGEX = Pattern.compile("[*]\\s*bye\\s*system\\s*error\\s*",
 46      Pattern.CASE_INSENSITIVE);
 47
 48  static final Pattern IDLE_ENDED_REGEX = Pattern.compile(".* OK IDLE terminated \\(success\\)\\s*",
 49      Pattern.CASE_INSENSITIVE);
 50  static final Pattern IDLE_EXISTS_REGEX = Pattern.compile("\\* (\\d+) exists\\s*",
 51      Pattern.CASE_INSENSITIVE);
 52  static final Pattern IDLE_EXPUNGE_REGEX = Pattern.compile("\\* (\\d+) expunge\\s*",
 53      Pattern.CASE_INSENSITIVE);
 54
 55  private final Idler idler;
 56  private final MailClientConfig config;
 57
 58  private final CountDownLatch loginSuccess = new CountDownLatch(1);
 59  private volatile List<String> capabilities;
 60  private volatile FolderObserver observer;
 61  final AtomicBoolean idleRequested = new AtomicBoolean();
 62  final AtomicBoolean idleAcknowledged = new AtomicBoolean();
 63  private final Object idleMutex = new Object();
 64
 65  // Panic button.
 66  private volatile boolean halt = false;
 67
 68  private final LinkedBlockingDeque<Error> errorStack = new LinkedBlockingDeque<Error>();
 69  private final Queue<CommandCompletion> completions =
 70      new ConcurrentLinkedQueue<CommandCompletion>();
 71  private volatile PushedData pushedData;
 72
 73  private final BoundedDiscardingList<String> commandTrace = new BoundedDiscardingList<String>(10);
 74  private final BoundedDiscardingList<String> wireTrace = new BoundedDiscardingList<String>(25);
 75  private final InputBuffer inputBuffer = new InputBuffer();
 76
 77
 78  public MailClientHandler(Idler idler, MailClientConfig config) {
 79    this.idler = idler;
 80    this.config = config;
 81  }
 82
 83  // For debugging, use with caution!
 84  public static void addUserForVerboseLogging(String username, boolean toStdOut) {
 85    logAllMessagesForUsers.put(username, toStdOut);
 86  }
 87
 88  public void setReceiveLogging(boolean b) {
 89    log.info("setReceiveLogging[" + config.getUsername() + "] = " + b);
 90    if (b)
 91      logAllMessagesForUsers.put(config.getUsername(), false);
 92    else
 93      logAllMessagesForUsers.remove(config.getUsername());
 94  }
 95
 96  public Set<String> getLogAllMessagesFor() {
 97    return logAllMessagesForUsers.keySet();
 98  }
 99
100  public List<String> getCommandTrace() {
101    return commandTrace.list();
102  }
103
104  public List<String> getWireTrace() {
105    return wireTrace.list();
106  }
107
108  public boolean isLoggedIn() {
109    return loginSuccess.getCount() == 0;
110  }
111
112  private static class PushedData {
113    volatile boolean idleExitSent = false;
114    // guarded by idleMutex.
115  	final ArrayList<Integer> pushAdds = new ArrayList<Integer>();
116    // guarded by idleMutex.
117  	final ArrayList<Integer> pushRemoves = new ArrayList<Integer>();
118  }
119
120  // DO NOT synchronize!
121  public void enqueue(CommandCompletion completion) {
122    completions.add(completion);
123    commandTrace.add(new Date().toString() + " " + completion.toString());
124  }
125
126  @Override
127  public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception {
128    String message = e.getMessage().toString();
129    for (String input : inputBuffer.processMessage(message)) {
130      processMessage(input);
131    }
132  }
133
134  private void processMessage(String message) throws Exception {
135    Boolean toStdOut = logAllMessagesForUsers.get(config.getUsername());
136    if (toStdOut != null) {
137      if (toStdOut)
138        System.out.println("IMAPrcv[" + config.getUsername() + "]: " + message);
139      else
140        log.info("IMAPrcv[{}]: {}", config.getUsername(), message);
141    }
142
143    if (ENABLE_WIRE_TRACE) {
144      wireTrace.add(message);
145      log.trace(message);
146    }
147
148    if (SYSTEM_ERROR_REGEX.matcher(message).matches()
149        || ". NO [ALERT] Account exceeded command or bandwidth limits. (Failure)".equalsIgnoreCase(
150        message.trim())) {
151      log.warn("{} disconnected by IMAP Server due to system error: {}", config.getUsername(),
152          message);
153      disconnectAbnormally(message);
154      return;
155    }
156
157    try {
158      if (halt) {
159        log.error("Mail client for {} is halted but continues to receive messages, ignoring!",
160            config.getUsername());
161        return;
162      }
163      
164      if (loginSuccess.getCount() > 0) {
165        if (message.startsWith(CAPABILITY_PREFIX)) {
166          this.capabilities = Arrays.asList( message.substring(CAPABILITY_PREFIX.length() + 1).split("[ ]+"));
167          return;
168        } else if (GMAIL_AUTH_SUCCESS_REGEX.matcher(message).matches() || IMAP_AUTH_SUCCESS_REGEX.matcher(message).matches()) {
169          log.info("Authentication success for user {}", config.getUsername());
170          loginSuccess.countDown();
171        } else {
172          Matcher matcher = COMMAND_FAILED_REGEX.matcher(message);
173          if (matcher.find()) {
174            // WARNING: DO NOT COUNTDOWN THE LOGIN LATCH ON FAILURE!!!
175
176            log.warn("Authentication failed for {} due to: {}", config.getUsername(), message);
177            errorStack.push(new Error(null /* logins have no completion */, extractError(matcher),
178                wireTrace.list()));
179            disconnectAbnormally(message);
180          }
181        }
182        return;
183      }
184
185      // Copy to local var as the value can change underneath us.
186      FolderObserver observer = this.observer;
187      if (idleRequested.get() || idleAcknowledged.get()) {
188        synchronized (idleMutex) {
189          if (IDLE_ENDED_REGEX.matcher(message).matches()) {
190            idleRequested.compareAndSet(true, false);
191            idleAcknowledged.set(false);
192
193            // Now fire the events.
194            PushedData data = pushedData;
195            pushedData = null;
196
197            idler.idleEnd();
198            observer.changed(data.pushAdds.isEmpty() ? null : data.pushAdds,
199                data.pushRemoves.isEmpty() ? null : data.pushRemoves);
200            return;
201          }
202
203          // Queue up any push notifications to publish to the client in a second.
204          Matcher existsMatcher = IDLE_EXISTS_REGEX.matcher(message);
205          boolean matched = false;
206          if (existsMatcher.matches()) {
207            int number = Integer.parseInt(existsMatcher.group(1));
208            pushedData.pushAdds.add(number);
209            matched = true;
210          } else {
211            Matcher expungeMatcher = IDLE_EXPUNGE_REGEX.matcher(message);
212            if (expungeMatcher.matches()) {
213              int number = Integer.parseInt(expungeMatcher.group(1));
214              pushedData.pushRemoves.add(number);
215              matched = true;
216            }
217          }
218
219          // Stop idling, when we get the idle ended message (next cycle) we can publish what's been gathered.
220          if (matched) {
221            if(!pushedData.idleExitSent) {
222              idler.done();
223              pushedData.idleExitSent = true;
224            }
225            return;
226          }
227        }
228      }
229
230      complete(message);
231    } catch (Exception ex) {
232      CommandCompletion completion = completions.poll();
233      if (completion != null)
234        completion.error(message, ex);
235      else {
236        log.error("Strange exception during mail processing (no completions available!): {}",
237            message, ex);
238        errorStack.push(new Error(null, "No completions available!", wireTrace.list()));
239      }
240      throw ex;
241    }
242  }
243
244  private void disconnectAbnormally(String message) {
245    try {
246      halt();
247      // Disconnect abnormally. The user code should reconnect using the mail client.
248      errorStack.push(new Error(completions.poll(), message, wireTrace.list()));
249
250      idler.disconnectAsync();
251    } finally {
252      disconnected();
253    }
254  }
255
256  private String extractError(Matcher matcher) {
257    return (matcher.groupCount()) > 1 ? matcher.group(2) : matcher.group();
258  }
259
260  /**
261   * This is synchronized to ensure that we process the queue serially.
262   */
263  private synchronized void complete(String message) {
264    
265    // This is a weird problem with writing stuff while idling. Need to investigate it more, but
266    // for now just ignore it.
267    if (MESSAGE_COULDNT_BE_FETCHED_REGEX.matcher(message).matches()) {
268      log.warn("Some messages in the batch could not be fetched for {}\n" +
269          "---cmd---\n{}\n---wire---\n{}\n---end---\n", new Object[] {
270          config.getUsername(),
271          getCommandTrace(),
272          getWireTrace()
273      });
274      errorStack.push(new Error(completions.peek(), message, wireTrace.list()));
275      final CommandCompletion completion = completions.peek();
276      String errorMsg = "Some messages in the batch could not be fetched for user " + config.getUsername();
277      RuntimeException ex = new RuntimeException(errorMsg);
278      if (completion != null) {
279        completion.error(errorMsg, new MailHandlingException(getWireTrace(), errorMsg, ex));
280        completions.poll();
281      } else {
282        throw ex;
283      }
284    }
285
286    CommandCompletion completion = completions.peek();
287    if (completion == null) {
288      if ("+ idling".equalsIgnoreCase(message)) {
289        synchronized (idleMutex) {
290          idler.idleStart();
291          log.trace("IDLE entered.");
292          idleAcknowledged.set(true);
293        }
294      } else {
295        log.error("Could not find the completion for message {} (Was it ever issued?)", message);
296        errorStack.push(new Error(null, "No completion found!", wireTrace.list()));
297      }
298      return;
299    }
300    
301    if (completion.complete(message)) {
302      completions.poll();
303    }
304  }
305
306  @Override
307  public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) throws Exception {
308    log.error("Exception caught! Disconnecting...", e.getCause());
309    disconnectAbnormally(e.getCause().getMessage());
310  }
311
312  public List<String> getCapabilities() {
313    return capabilities;
314  }
315
316  boolean awaitLogin() {
317    try {
318      if (!loginSuccess.await(10L, TimeUnit.SECONDS)) {
319        disconnectAbnormally("Timed out waiting for login response");
320        throw new RuntimeException("Timed out waiting for login response");
321      }
322
323      return isLoggedIn();
324    } catch (InterruptedException e) {
325      errorStack.push(new Error(null, e.getMessage(), wireTrace.list()));
326      throw new RuntimeException("Interruption while awaiting server login", e);
327    }
328  }
329
330  MailClient.WireError lastError() {
331    return errorStack.peek() != null ? errorStack.pop() : null;
332  }
333
334  List<String> getLastTrace() {
335    return wireTrace.list();
336  }
337
338  /**
339   * Registers a FolderObserver to receive events happening with a particular
340   * folder. Typically an IMAP IDLE feature. If called multiple times, will
341   * overwrite the currently set observer.
342   */
343  void observe(FolderObserver observer) {
344    synchronized (idleMutex) {
345      this.observer = observer;
346      pushedData = new PushedData();
347      idleAcknowledged.set(false);
348    }
349  }
350
351  void halt() {
352    halt = true;
353  }
354
355  public boolean isHalted() {
356    return halt;
357  }
358
359  public void disconnected() {
360  }
361
362  static class Error implements MailClient.WireError {
363    final CommandCompletion completion;
364    final String error;
365    final List<String> wireTrace;
366
367    Error(CommandCompletion completion, String error, List<String> wireTrace) {
368      this.completion = completion;
369      this.error = error;
370      this.wireTrace = wireTrace;
371    }
372
373    @Override
374    public String message() {
375      return error;
376    }
377
378    @Override
379    public List<String> trace() {
380      return wireTrace;
381    }
382
383    @Override
384    public String expected() {
385      return completion == null ? null : completion.toString();
386    }
387
388    @Override
389    public String toString() {
390      StringBuilder sout = new StringBuilder();
391      sout.append("WireError: ");
392      sout.append("Completion=").append(completion);
393      sout.append(", Error: ").append(error);
394      sout.append(", Trace:\n");
395      for (String s : wireTrace) {
396        sout.append("  ").append(s).append("\n");
397      }
398      return sout.toString();
399    }
400  }
401
402  @VisibleForTesting
403  static class InputBuffer {
404    volatile private StringBuilder buffer = new StringBuilder();
405
406    @VisibleForTesting
407    List<String> processMessage(String message) {
408      // Nuke all CR characters, and we'll only count LF.
409      message = message.replaceAll("\r", "");
410      // Split leaves a trailing empty line if there's a terminating newline.
411      ArrayList<String> split = Lists.newArrayList(message.split("\n", -1));
412      Preconditions.checkArgument(split.size() > 0);
413
414      synchronized (this) {
415        buffer.append(split.get(0));
416        if (split.size() == 1) // no newlines.
417          return ImmutableList.of();
418        split.set(0, buffer.toString());
419        buffer = new StringBuilder().append(split.remove(split.size() - 1));
420      }
421      return split;
422    }
423  }
424}