001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017 018package org.apache.commons.net.examples.mail; 019 020import java.io.BufferedWriter; 021import java.io.IOException; 022import java.io.UncheckedIOException; 023import java.net.URI; 024import java.net.URISyntaxException; 025import java.nio.charset.Charset; 026import java.nio.file.Files; 027import java.nio.file.Path; 028import java.nio.file.Paths; 029import java.nio.file.StandardOpenOption; 030import java.text.ParseException; 031import java.text.SimpleDateFormat; 032import java.util.ArrayList; 033import java.util.Date; 034import java.util.Iterator; 035import java.util.List; 036import java.util.TimeZone; 037import java.util.concurrent.atomic.AtomicInteger; 038import java.util.regex.Matcher; 039import java.util.regex.Pattern; 040 041import org.apache.commons.net.PrintCommandListener; 042import org.apache.commons.net.ProtocolCommandEvent; 043import org.apache.commons.net.imap.IMAP; 044import org.apache.commons.net.imap.IMAP.IMAPChunkListener; 045import org.apache.commons.net.imap.IMAPClient; 046import org.apache.commons.net.imap.IMAPReply; 047 048/** 049 * This is an example program demonstrating how to use the IMAP[S]Client class. This program connects to a IMAP[S] server and exports selected messages from a 050 * folder into an mbox file. 051 * <p> 052 * Usage: IMAPExportMbox imap[s]://user:password@host[:port]/folder/path <mboxfile> [sequence-set] [item-names] 053 * <p> 054 * An example sequence-set might be: 055 * <ul> 056 * <li>11,2,3:10,20:*</li> 057 * <li>1:* - this is the default</li> 058 * </ul> 059 * <p> 060 * Some example item-names might be: 061 * <ul> 062 * <li>BODY.PEEK[HEADER]</li> 063 * <li>'BODY.PEEK[HEADER.FIELDS (SUBJECT)]'</li> 064 * <li>ALL - macro equivalent to '(FLAGS INTERNALDATE RFC822.SIZE ENVELOPE)'</li> 065 * <li>FAST - macro equivalent to '(FLAGS INTERNALDATE RFC822.SIZE)'</li> 066 * <li>FULL - macro equivalent to '(FLAGS INTERNALDATE RFC822.SIZE ENVELOPE BODY)'</li> 067 * <li>ENVELOPE X-GM-LABELS</li> 068 * <li>'(INTERNALDATE BODY.PEEK[])' - this is the default</li> 069 * </ul> 070 * <p> 071 * Macro names cannot be combined with anything else; they must be used alone.<br> 072 * Note that using BODY will set the \Seen flag. This is why the default uses BODY.PEEK[].<br> 073 * The item name X-GM-LABELS is a Google Mail extension; it shows the labels for a message.<br> 074 * For example:<br> 075 * IMAPExportMbox imaps://user:password@imap.googlemail.com/messages_for_export exported.mbox 1:10,20<br> 076 * IMAPExportMbox imaps://user:password@imap.googlemail.com/messages_for_export exported.mbox 3 ENVELOPE X-GM-LABELS<br> 077 * <p> 078 * The sequence-set is passed unmodified to the FETCH command.<br> 079 * The item names are wrapped in parentheses if more than one is provided. Otherwise, the parameter is assumed to be wrapped if necessary.<br> 080 * Parameters with spaces must be quoted otherwise the OS shell will normally treat them as separate parameters.<br> 081 * Also, the listener that writes the mailbox only captures the multi-line responses (e.g. ones that include BODY references). It does not capture the output 082 * from FETCH commands using item names such as ENVELOPE or FLAGS that return a single line response. 083 */ 084public final class IMAPExportMbox { 085 086 private static final class MboxListener implements IMAPChunkListener { 087 088 private final BufferedWriter bufferedWriter; 089 volatile AtomicInteger total = new AtomicInteger(); 090 volatile String lastFetched; 091 volatile List<String> missingIds = new ArrayList<>(); 092 volatile long lastSeq = -1; 093 private final String lineSeparator; 094 private final SimpleDateFormat DATE_FORMAT // for mbox From_ lines 095 = new SimpleDateFormat("EEE MMM dd HH:mm:ss yyyy"); 096 097 // e.g. INTERNALDATE "27-Oct-2013 07:43:24 +0000" 098 // for parsing INTERNALDATE 099 private final SimpleDateFormat IDPARSE = new SimpleDateFormat("dd-MMM-yyyy HH:mm:ss Z"); 100 private final boolean printHash; 101 private final boolean printMarker; 102 private final boolean checkSequence; 103 104 MboxListener(final BufferedWriter bufferedWriter, final String lineSeparator, final boolean printHash, final boolean printMarker, 105 final boolean checkSequence) { 106 this.lineSeparator = lineSeparator; 107 this.printHash = printHash; 108 this.printMarker = printMarker; 109 DATE_FORMAT.setTimeZone(TimeZone.getTimeZone("GMT")); 110 this.bufferedWriter = bufferedWriter; 111 this.checkSequence = checkSequence; 112 } 113 114 @Override 115 public boolean chunkReceived(final IMAP imap) { 116 final String[] replyStrings = imap.getReplyStrings(); 117 Date received = new Date(); 118 final String firstLine = replyStrings[0]; 119 Matcher m = PATID.matcher(firstLine); 120 if (m.lookingAt()) { // found a match 121 final String date = m.group(PATID_DATE_GROUP); 122 try { 123 received = IDPARSE.parse(date); 124 } catch (final ParseException e) { 125 System.err.println(e); 126 } 127 } else { 128 System.err.println("No timestamp found in: " + firstLine + " - using current time"); 129 } 130 String replyTo = "MAILER-DAEMON"; // default 131 for (int i = 1; i < replyStrings.length - 1; i++) { 132 final String line = replyStrings[i]; 133 if (line.startsWith("Return-Path: ")) { 134 final String[] parts = line.split(" ", 2); 135 if (!parts[1].equals("<>")) { // Don't replace default with blank 136 replyTo = parts[1]; 137 if (replyTo.startsWith("<")) { 138 if (replyTo.endsWith(">")) { 139 replyTo = replyTo.substring(1, replyTo.length() - 1); // drop <> wrapper 140 } else { 141 System.err.println("Unexpected Return-path: '" + line + "' in " + firstLine); 142 } 143 } 144 } 145 break; 146 } 147 } 148 try { 149 // Add initial mbox header line 150 bufferedWriter.append("From "); 151 bufferedWriter.append(replyTo); 152 bufferedWriter.append(' '); 153 bufferedWriter.append(DATE_FORMAT.format(received)); 154 bufferedWriter.append(lineSeparator); 155 // Debug 156 bufferedWriter.append("X-IMAP-Response: ").append(firstLine).append(lineSeparator); 157 if (printMarker) { 158 System.err.println("[" + total + "] " + firstLine); 159 } 160 // Skip first and last lines 161 for (int i = 1; i < replyStrings.length - 1; i++) { 162 final String line = replyStrings[i]; 163 if (startsWith(line, PATFROM)) { 164 bufferedWriter.append('>'); // Escape a From_ line 165 } 166 bufferedWriter.append(line); 167 bufferedWriter.append(lineSeparator); 168 } 169 // The last line ends with the trailing closing ")" which needs to be stripped 170 final String lastLine = replyStrings[replyStrings.length - 1]; 171 final int lastLength = lastLine.length(); 172 if (lastLength > 1) { // there's some content, we need to save it 173 bufferedWriter.append(lastLine, 0, lastLength - 1); 174 bufferedWriter.append(lineSeparator); 175 } 176 bufferedWriter.append(lineSeparator); // blank line between entries 177 } catch (final IOException e) { 178 e.printStackTrace(); 179 throw new UncheckedIOException(e); // chunkReceived cannot throw a checked Exception 180 } 181 lastFetched = firstLine; 182 total.incrementAndGet(); 183 if (checkSequence) { 184 m = PATSEQ.matcher(firstLine); 185 if (m.lookingAt()) { // found a match 186 final long msgSeq = Long.parseLong(m.group(PATSEQ_SEQUENCE_GROUP)); // Cannot fail to parse 187 if (lastSeq != -1) { 188 final long missing = msgSeq - lastSeq - 1; 189 if (missing != 0) { 190 for (long j = lastSeq + 1; j < msgSeq; j++) { 191 missingIds.add(String.valueOf(j)); 192 } 193 System.err.println("*** Sequence error: current=" + msgSeq + " previous=" + lastSeq + " Missing=" + missing); 194 } 195 } 196 lastSeq = msgSeq; 197 } 198 } 199 if (printHash) { 200 System.err.print("."); 201 } 202 return true; 203 } 204 205 public void close() throws IOException { 206 if (bufferedWriter != null) { 207 bufferedWriter.close(); 208 } 209 } 210 } 211 212 private static final String CRLF = "\r\n"; 213 private static final String LF = "\n"; 214 215 private static final String EOL_DEFAULT = System.lineSeparator(); 216 private static final Pattern PATFROM = Pattern.compile(">*From "); // unescaped From_ 217 // e.g. * nnn (INTERNALDATE "27-Oct-2013 07:43:24 +0000" BODY[] {nn} ...) 218 private static final Pattern PATID = // INTERNALDATE 219 Pattern.compile(".*INTERNALDATE \"(\\d\\d-\\w{3}-\\d{4} \\d\\d:\\d\\d:\\d\\d [+-]\\d+)\""); 220 221 private static final int PATID_DATE_GROUP = 1; 222 private static final Pattern PATSEQ = Pattern.compile("\\* (\\d+) "); // Sequence number 223 224 private static final int PATSEQ_SEQUENCE_GROUP = 1; 225 226 // e.g. * 382 EXISTS 227 private static final Pattern PATEXISTS = Pattern.compile("\\* (\\d+) EXISTS"); // Response from SELECT 228 229 // AAAC NO [TEMPFAIL] FETCH Temporary failure on server [CODE: WBL] 230 private static final Pattern PATTEMPFAIL = Pattern.compile("[A-Z]{4} NO \\[TEMPFAIL\\] FETCH .*"); 231 private static final int CONNECT_TIMEOUT = 10; // Seconds 232 233 private static final int READ_TIMEOUT = 10; 234 235 public static void main(final String[] args) throws IOException, URISyntaxException { 236 int connect_timeout = CONNECT_TIMEOUT; 237 int read_timeout = READ_TIMEOUT; 238 239 int argIdx = 0; 240 String eol = EOL_DEFAULT; 241 boolean printHash = false; 242 boolean printMarker = false; 243 int retryWaitSecs = 0; 244 245 for (argIdx = 0; argIdx < args.length; argIdx++) { 246 if (args[argIdx].equals("-c")) { 247 connect_timeout = Integer.parseInt(args[++argIdx]); 248 } else if (args[argIdx].equals("-r")) { 249 read_timeout = Integer.parseInt(args[++argIdx]); 250 } else if (args[argIdx].equals("-R")) { 251 retryWaitSecs = Integer.parseInt(args[++argIdx]); 252 } else if (args[argIdx].equals("-LF")) { 253 eol = LF; 254 } else if (args[argIdx].equals("-CRLF")) { 255 eol = CRLF; 256 } else if (args[argIdx].equals("-.")) { 257 printHash = true; 258 } else if (args[argIdx].equals("-X")) { 259 printMarker = true; 260 } else { 261 break; 262 } 263 } 264 265 final int argCount = args.length - argIdx; 266 267 if (argCount < 2) { 268 System.err.println("Usage: IMAPExportMbox [-LF|-CRLF] [-c n] [-r n] [-R n] [-.] [-X]" 269 + " imap[s]://user:password@host[:port]/folder/path [+|-]<mboxfile> [sequence-set] [itemnames]"); 270 System.err.println("\t-LF | -CRLF set end-of-line to LF or CRLF (default is the line.separator system property)"); 271 System.err.println("\t-c connect timeout in seconds (default 10)"); 272 System.err.println("\t-r read timeout in seconds (default 10)"); 273 System.err.println("\t-R temporary failure retry wait in seconds (default 0; i.e. disabled)"); 274 System.err.println("\t-. print a . for each complete message received"); 275 System.err.println("\t-X print the X-IMAP line for each complete message received"); 276 System.err.println("\tthe mboxfile is where the messages are stored; use '-' to write to standard output."); 277 System.err.println("\tPrefix file name with '+' to append to the file. Prefix with '-' to allow overwrite."); 278 System.err.println("\ta sequence-set is a list of numbers/number ranges e.g. 1,2,3-10,20:* - default 1:*"); 279 System.err.println("\titemnames are the message data item name(s) e.g. BODY.PEEK[HEADER.FIELDS (SUBJECT)]" 280 + " or a macro e.g. ALL - default (INTERNALDATE BODY.PEEK[])"); 281 System.exit(1); 282 } 283 284 final String uriString = args[argIdx++]; 285 URI uri; 286 try { 287 uri = URI.create(uriString); 288 } catch (final IllegalArgumentException e) { // cannot parse the path as is; let's pull it apart and try again 289 final Matcher m = Pattern.compile("(imaps?://[^/]+)(/.*)").matcher(uriString); 290 if (!m.matches()) { 291 throw e; 292 } 293 uri = URI.create(m.group(1)); // Just the scheme and auth parts 294 uri = new URI(uri.getScheme(), uri.getAuthority(), m.group(2), null, null); 295 } 296 final String file = args[argIdx++]; 297 String sequenceSet = argCount > 2 ? args[argIdx++] : "1:*"; 298 final String itemNames; 299 // Handle 0, 1 or multiple item names 300 if (argCount > 3) { 301 if (argCount > 4) { 302 final StringBuilder sb = new StringBuilder(); 303 sb.append("("); 304 for (int i = 4; i <= argCount; i++) { 305 if (i > 4) { 306 sb.append(" "); 307 } 308 sb.append(args[argIdx++]); 309 } 310 sb.append(")"); 311 itemNames = sb.toString(); 312 } else { 313 itemNames = args[argIdx++]; 314 } 315 } else { 316 itemNames = "(INTERNALDATE BODY.PEEK[])"; 317 } 318 319 final boolean checkSequence = sequenceSet.matches("\\d+:(\\d+|\\*)"); // are we expecting a sequence? 320 final MboxListener mboxListener; 321 if (file.equals("-")) { 322 mboxListener = null; 323 } else if (file.startsWith("+")) { 324 final Path mboxPath = Paths.get(file.substring(1)); 325 System.out.println("Appending to file " + mboxPath); 326 mboxListener = new MboxListener(Files.newBufferedWriter(mboxPath, Charset.defaultCharset(), StandardOpenOption.CREATE, StandardOpenOption.APPEND), 327 eol, printHash, printMarker, checkSequence); 328 } else if (file.startsWith("-")) { 329 final Path mboxPath = Paths.get(file.substring(1)); 330 System.out.println("Writing to file " + mboxPath); 331 mboxListener = new MboxListener(Files.newBufferedWriter(mboxPath, Charset.defaultCharset(), StandardOpenOption.CREATE), eol, printHash, printMarker, 332 checkSequence); 333 } else { 334 final Path mboxPath = Paths.get(file); 335 if (Files.exists(mboxPath) && Files.size(mboxPath) > 0) { 336 throw new IOException("mailbox file: " + mboxPath + " already exists and is non-empty!"); 337 } 338 System.out.println("Creating file " + mboxPath); 339 mboxListener = new MboxListener(Files.newBufferedWriter(mboxPath, Charset.defaultCharset(), StandardOpenOption.CREATE), eol, printHash, printMarker, 340 checkSequence); 341 } 342 343 final String path = uri.getPath(); 344 if (path == null || path.length() < 1) { 345 throw new IllegalArgumentException("Invalid folderPath: '" + path + "'"); 346 } 347 final String folder = path.substring(1); // skip the leading / 348 349 // suppress login details 350 final PrintCommandListener listener = new PrintCommandListener(System.out, true) { 351 @Override 352 public void protocolReplyReceived(final ProtocolCommandEvent event) { 353 if (event.getReplyCode() != IMAPReply.PARTIAL) { // This is dealt with by the chunk listener 354 super.protocolReplyReceived(event); 355 } 356 } 357 }; 358 359 // Connect and login 360 final IMAPClient imap = IMAPUtils.imapLogin(uri, connect_timeout * 1000, listener); 361 362 String maxIndexInFolder = null; 363 364 try { 365 366 imap.setSoTimeout(read_timeout * 1000); 367 368 if (!imap.select(folder)) { 369 throw new IOException("Could not select folder: " + folder); 370 } 371 372 for (final String line : imap.getReplyStrings()) { 373 maxIndexInFolder = matches(line, PATEXISTS, 1); 374 if (maxIndexInFolder != null) { 375 break; 376 } 377 } 378 379 if (mboxListener != null) { 380 imap.setChunkListener(mboxListener); 381 } // else the command listener displays the full output without processing 382 383 while (true) { 384 final boolean ok = imap.fetch(sequenceSet, itemNames); 385 // If the fetch failed, can we retry? 386 if (ok || retryWaitSecs <= 0 || mboxListener == null || !checkSequence) { 387 break; 388 } 389 final String replyString = imap.getReplyString(); // includes EOL 390 if (!startsWith(replyString, PATTEMPFAIL)) { 391 throw new IOException("FETCH " + sequenceSet + " " + itemNames + " failed with " + replyString); 392 } 393 System.err.println("Temporary error detected, will retry in " + retryWaitSecs + "seconds"); 394 sequenceSet = mboxListener.lastSeq + 1 + ":*"; 395 try { 396 Thread.sleep(retryWaitSecs * 1000); 397 } catch (final InterruptedException e) { 398 // ignored 399 } 400 } 401 402 } catch (final IOException ioe) { 403 final String count = mboxListener == null ? "?" : mboxListener.total.toString(); 404 System.err.println("FETCH " + sequenceSet + " " + itemNames + " failed after processing " + count + " complete messages "); 405 if (mboxListener != null) { 406 System.err.println("Last complete response seen: " + mboxListener.lastFetched); 407 } 408 throw ioe; 409 } finally { 410 411 if (printHash) { 412 System.err.println(); 413 } 414 415 if (mboxListener != null) { 416 mboxListener.close(); 417 final Iterator<String> missingIds = mboxListener.missingIds.iterator(); 418 if (missingIds.hasNext()) { 419 final StringBuilder sb = new StringBuilder(); 420 for (;;) { 421 sb.append(missingIds.next()); 422 if (!missingIds.hasNext()) { 423 break; 424 } 425 sb.append(","); 426 } 427 System.err.println("*** Missing ids: " + sb.toString()); 428 } 429 } 430 imap.logout(); 431 imap.disconnect(); 432 } 433 if (mboxListener != null) { 434 System.out.println("Processed " + mboxListener.total + " messages."); 435 } 436 if (maxIndexInFolder != null) { 437 System.out.println("Folder contained " + maxIndexInFolder + " messages."); 438 } 439 } 440 441 private static String matches(final String input, final Pattern pat, final int index) { 442 final Matcher m = pat.matcher(input); 443 if (m.lookingAt()) { 444 return m.group(index); 445 } 446 return null; 447 } 448 449 private static boolean startsWith(final String input, final Pattern pat) { 450 final Matcher m = pat.matcher(input); 451 return m.lookingAt(); 452 } 453}