/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

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