View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      https://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  
18  package org.apache.commons.net.imap;
19  
20  import java.io.BufferedReader;
21  import java.io.BufferedWriter;
22  import java.io.EOFException;
23  import java.io.IOException;
24  import java.io.InputStreamReader;
25  import java.io.OutputStreamWriter;
26  import java.nio.charset.StandardCharsets;
27  import java.util.ArrayList;
28  import java.util.List;
29  
30  import org.apache.commons.net.SocketClient;
31  import org.apache.commons.net.io.CRLFLineReader;
32  import org.apache.commons.net.util.NetConstants;
33  
34  /**
35   * The IMAP class provides the basic the functionality necessary to implement your own IMAP client.
36   */
37  public class IMAP extends SocketClient {
38      /**
39       * Implement this interface and register it via {@link #setChunkListener(IMAPChunkListener)} in order to get access to multi-line partial command responses.
40       * Useful when processing large FETCH responses.
41       */
42      public interface IMAPChunkListener {
43          /**
44           * Called when a multi-line partial response has been received.
45           *
46           * @param imap the instance, get the response by calling {@link #getReplyString()} or {@link #getReplyStrings()}
47           * @return {@code true} if the reply buffer is to be cleared on return
48           */
49          boolean chunkReceived(IMAP imap);
50      }
51  
52      /**
53       * Enumerates IMAP states.
54       */
55      public enum IMAPState {
56          /** A constant representing the state where the client is not yet connected to a server. */
57          DISCONNECTED_STATE,
58          /** A constant representing the "not authenticated" state. */
59          NOT_AUTH_STATE,
60          /** A constant representing the "authenticated" state. */
61          AUTH_STATE,
62          /** A constant representing the "logout" state. */
63          LOGOUT_STATE
64      }
65  
66      /** The default IMAP port (RFC 3501). */
67      public static final int DEFAULT_PORT = 143;
68  
69      // RFC 3501, section 5.1.3. It should be "modified UTF-7".
70      /**
71       * The default control socket encoding.
72       */
73      protected static final String __DEFAULT_ENCODING = StandardCharsets.ISO_8859_1.name();
74  
75      /**
76       * <p>
77       * Implements {@link IMAPChunkListener} to returns {@code true} but otherwise does nothing.
78       * </p>
79       * <p>
80       * This is intended for use with a suitable ProtocolCommandListener. If the IMAP response contains multiple-line data, the protocol listener will be called
81       * for each multi-line chunk. The accumulated reply data will be cleared after calling the listener. If the response is very long, this can significantly
82       * reduce memory requirements. The listener will also start receiving response data earlier, as it does not have to wait for the entire response to be read.
83       * </p>
84       * <p>
85       * The ProtocolCommandListener must be prepared to accept partial responses. This should not be a problem for listeners that just log the input.
86       * </p>
87       *
88       * @see #setChunkListener(IMAPChunkListener)
89       * @since 3.4
90       */
91      public static final IMAPChunkListener TRUE_CHUNK_LISTENER = imap -> true;
92  
93      /**
94       * Quote an input string if necessary. If the string is enclosed in double-quotes it is assumed to be quoted already and is returned unchanged. If it is the
95       * empty string, "" is returned. If it contains a space then it is enclosed in double quotes, escaping the characters backslash and double-quote.
96       *
97       * @param input the value to be quoted, may be null
98       * @return the quoted value
99       */
100     static String quoteMailboxName(final String input) {
101         if (input == null) { // Don't throw NPE here
102             return null;
103         }
104         if (input.isEmpty()) {
105             return "\"\""; // return the string ""
106         }
107         // Length check is necessary to ensure a lone double-quote is quoted
108         if (input.length() > 1 && input.startsWith("\"") && input.endsWith("\"")) {
109             return input; // Assume already quoted
110         }
111         if (input.contains(" ")) {
112             // quoted strings must escape \ and "
113             return "\"" + input.replaceAll("([\\\\\"])", "\\\\$1") + "\"";
114         }
115         return input;
116 
117     }
118 
119     private IMAPState state;
120 
121     /**
122      * Buffered writer.
123      */
124     protected BufferedWriter __writer;
125 
126     /**
127      * Buffered reader.
128      */
129     protected BufferedReader _reader;
130 
131     private int replyCode;
132     private final List<String> replyLines;
133 
134     private volatile IMAPChunkListener chunkListener;
135 
136     private final char[] initialID = { 'A', 'A', 'A', 'A' };
137 
138     /**
139      * The default IMAPClient constructor. Initializes the state to {@code DISCONNECTED_STATE}.
140      */
141     public IMAP() {
142         setDefaultPort(DEFAULT_PORT);
143         state = IMAPState.DISCONNECTED_STATE;
144         _reader = null;
145         __writer = null;
146         replyLines = new ArrayList<>();
147         createCommandSupport();
148     }
149 
150     /**
151      * Performs connection initialization and sets state to {@link IMAPState#NOT_AUTH_STATE}.
152      */
153     @Override
154     protected void _connectAction_() throws IOException {
155         super._connectAction_();
156         _reader = new CRLFLineReader(new InputStreamReader(_input_, __DEFAULT_ENCODING));
157         __writer = new BufferedWriter(new OutputStreamWriter(_output_, __DEFAULT_ENCODING));
158         final int tmo = getSoTimeout();
159         if (tmo <= 0) { // none set currently
160             setSoTimeout(connectTimeout); // use connect timeout to ensure we don't block forever
161         }
162         getReply(false); // untagged response
163         if (tmo <= 0) {
164             setSoTimeout(tmo); // restore the original value
165         }
166         setState(IMAPState.NOT_AUTH_STATE);
167     }
168 
169     /**
170      * Disconnects the client from the server, and sets the state to {@code DISCONNECTED_STATE}. The reply text information from the last issued command
171      * is voided to allow garbage collection of the memory used to store that information.
172      *
173      * @throws IOException If there is an error in disconnecting.
174      */
175     @Override
176     public void disconnect() throws IOException {
177         super.disconnect();
178         _reader = null;
179         __writer = null;
180         replyLines.clear();
181         setState(IMAPState.DISCONNECTED_STATE);
182     }
183 
184     /**
185      * Sends a command to the server and return whether successful.
186      *
187      * @param command The IMAP command to send (one of the IMAPCommand constants).
188      * @return {@code true} if the command was successful
189      * @throws IOException on error
190      */
191     public boolean doCommand(final IMAPCommand command) throws IOException {
192         return IMAPReply.isSuccess(sendCommand(command));
193     }
194 
195     /**
196      * Sends a command and arguments to the server and return whether successful.
197      *
198      * @param command The IMAP command to send (one of the IMAPCommand constants).
199      * @param args    The command arguments.
200      * @return {@code true} if the command was successful
201      * @throws IOException on error
202      */
203     public boolean doCommand(final IMAPCommand command, final String args) throws IOException {
204         return IMAPReply.isSuccess(sendCommand(command, args));
205     }
206 
207     /**
208      * Overrides {@link SocketClient#fireReplyReceived(int, String)} to avoid creating the reply string if there are no listeners to invoke.
209      *
210      * @param replyCode passed to the listeners
211      * @param ignored   the string is only created if there are listeners defined.
212      * @see #getReplyString()
213      * @since 3.4
214      */
215     @Override
216     protected void fireReplyReceived(final int replyCode, final String ignored) {
217         getCommandSupport().fireReplyReceived(replyCode, getReplyString());
218     }
219 
220     /**
221      * Generates a new command ID (tag) for a command.
222      *
223      * @return a new command ID (tag) for an IMAP command.
224      */
225     protected String generateCommandID() {
226         final String res = new String(initialID);
227         // "increase" the ID for the next call
228         boolean carry = true; // want to increment initially
229         for (int i = initialID.length - 1; carry && i >= 0; i--) {
230             if (initialID[i] == 'Z') {
231                 initialID[i] = 'A';
232             } else {
233                 initialID[i]++;
234                 carry = false; // did not wrap round
235             }
236         }
237         return res;
238     }
239 
240     /**
241      * Gets the reply for a command that expects a tagged response.
242      *
243      * @throws IOException
244      */
245     private void getReply() throws IOException {
246         getReply(true); // tagged response
247     }
248 
249     /**
250      * Gets the reply for a command, reading the response until the reply is found.
251      *
252      * @param wantTag {@code true} if the command expects a tagged response.
253      * @throws IOException
254      */
255     private void getReply(final boolean wantTag) throws IOException {
256         replyLines.clear();
257         String line = _reader.readLine();
258 
259         if (line == null) {
260             throw new EOFException("Connection closed without indication.");
261         }
262 
263         replyLines.add(line);
264 
265         if (wantTag) {
266             while (IMAPReply.isUntagged(line)) {
267                 int literalCount = IMAPReply.literalCount(line);
268                 final boolean isMultiLine = literalCount >= 0;
269                 while (literalCount >= 0) {
270                     line = _reader.readLine();
271                     if (line == null) {
272                         throw new EOFException("Connection closed without indication.");
273                     }
274                     replyLines.add(line);
275                     literalCount -= line.length() + 2; // Allow for CRLF
276                 }
277                 if (isMultiLine) {
278                     final IMAPChunkListener il = chunkListener;
279                     if (il != null) {
280                         final boolean clear = il.chunkReceived(this);
281                         if (clear) {
282                             fireReplyReceived(IMAPReply.PARTIAL, getReplyString());
283                             replyLines.clear();
284                         }
285                     }
286                 }
287                 line = _reader.readLine(); // get next chunk or final tag
288                 if (line == null) {
289                     throw new EOFException("Connection closed without indication.");
290                 }
291                 replyLines.add(line);
292             }
293             // check the response code on the last line
294             replyCode = IMAPReply.getReplyCode(line);
295         } else {
296             replyCode = IMAPReply.getUntaggedReplyCode(line);
297         }
298 
299         fireReplyReceived(replyCode, getReplyString());
300     }
301 
302     /**
303      * Gets the reply to the last command sent to the server. The value is a single string containing all the reply lines including newlines.
304      *
305      * @return The last server response.
306      */
307     public String getReplyString() {
308         final StringBuilder buffer = new StringBuilder(256);
309         for (final String s : replyLines) {
310             buffer.append(s);
311             buffer.append(NETASCII_EOL);
312         }
313 
314         return buffer.toString();
315     }
316 
317     /**
318      * Gets an array of lines received as a reply to the last command sent to the server. The lines have end of lines truncated.
319      *
320      * @return The last server response.
321      */
322     public String[] getReplyStrings() {
323         return replyLines.toArray(NetConstants.EMPTY_STRING_ARRAY);
324     }
325 
326     /**
327      * Gets the current IMAP client state.
328      *
329      * @return The current IMAP client state.
330      */
331     public IMAP.IMAPState getState() {
332         return state;
333     }
334 
335     /**
336      * Sends a command with no arguments to the server and returns the reply code.
337      *
338      * @param command The IMAP command to send (one of the IMAPCommand constants).
339      * @return The server reply code (see IMAPReply).
340      * @throws IOException on error
341      **/
342     public int sendCommand(final IMAPCommand command) throws IOException {
343         return sendCommand(command, null);
344     }
345 
346     /**
347      * Sends a command and arguments to the server and returns the reply code.
348      *
349      * @param command The IMAP command to send (one of the IMAPCommand constants).
350      * @param args    The command arguments.
351      * @return The server reply code (see IMAPReply).
352      * @throws IOException on error
353      */
354     public int sendCommand(final IMAPCommand command, final String args) throws IOException {
355         return sendCommand(command.getIMAPCommand(), args);
356     }
357 
358     /**
359      * Sends a command with no arguments to the server and returns the reply code.
360      *
361      * @param command The IMAP command to send.
362      * @return The server reply code (see IMAPReply).
363      * @throws IOException on error
364      */
365     public int sendCommand(final String command) throws IOException {
366         return sendCommand(command, null);
367     }
368 
369     /**
370      * Sends a command an arguments to the server and returns the reply code.
371      *
372      * @param command The IMAP command to send.
373      * @param args    The command arguments.
374      * @return The server reply code (see IMAPReply).
375      * @throws IOException on error
376      */
377     public int sendCommand(final String command, final String args) throws IOException {
378         return sendCommandWithID(generateCommandID(), command, args);
379     }
380 
381     /**
382      * Sends a command an arguments to the server and returns the reply code.
383      *
384      * @param commandID The ID (tag) of the command.
385      * @param command   The IMAP command to send.
386      * @param args      The command arguments.
387      * @return The server reply code (either {@link IMAPReply#OK}, {@link IMAPReply#NO} or {@link IMAPReply#BAD}).
388      */
389     private int sendCommandWithID(final String commandID, final String command, final String args) throws IOException {
390         final StringBuilder builder = new StringBuilder();
391         if (commandID != null) {
392             builder.append(commandID);
393             builder.append(' ');
394         }
395         builder.append(command);
396         if (args != null) {
397             builder.append(' ');
398             builder.append(args);
399         }
400         builder.append(NETASCII_EOL);
401         final String message = builder.toString();
402         __writer.write(message);
403         __writer.flush();
404         fireCommandSent(command, message);
405         getReply();
406         return replyCode;
407     }
408 
409     /**
410      * Sends data to the server and returns the reply code.
411      *
412      * @param command The IMAP command to send.
413      * @return The server reply code (see IMAPReply).
414      * @throws IOException on error
415      */
416     public int sendData(final String command) throws IOException {
417         return sendCommandWithID(null, command, null);
418     }
419 
420     /**
421      * Sets the current chunk listener. If a listener is registered and the implementation returns true, then any registered
422      * {@link org.apache.commons.net.PrintCommandListener PrintCommandListener} instances will be invoked with the partial response and a status of
423      * {@link IMAPReply#PARTIAL} to indicate that the final reply code is not yet known.
424      *
425      * @param listener the class to use, or {@code null} to disable
426      * @see #TRUE_CHUNK_LISTENER
427      * @since 3.4
428      */
429     public void setChunkListener(final IMAPChunkListener listener) {
430         chunkListener = listener;
431     }
432 
433     /**
434      * Sets IMAP client state. This must be one of the {@code _STATE} constants.
435      *
436      * @param state The new state.
437      */
438     protected void setState(final IMAP.IMAPState state) {
439         this.state = state;
440     }
441 }
442