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 *      https://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.imap;
019
020import java.io.BufferedReader;
021import java.io.BufferedWriter;
022import java.io.EOFException;
023import java.io.IOException;
024import java.io.InputStreamReader;
025import java.io.OutputStreamWriter;
026import java.nio.charset.StandardCharsets;
027import java.util.ArrayList;
028import java.util.List;
029
030import org.apache.commons.net.SocketClient;
031import org.apache.commons.net.io.CRLFLineReader;
032import org.apache.commons.net.util.NetConstants;
033
034/**
035 * The IMAP class provides the basic the functionality necessary to implement your own IMAP client.
036 */
037public class IMAP extends SocketClient {
038
039    /**
040     * Implement this interface and register it via {@link #setChunkListener(IMAPChunkListener)} in order to get access to multi-line partial command responses.
041     * Useful when processing large FETCH responses.
042     */
043    public interface IMAPChunkListener {
044
045        /**
046         * Called when a multi-line partial response has been received.
047         *
048         * @param imap the instance, get the response by calling {@link #getReplyString()} or {@link #getReplyStrings()}
049         * @return {@code true} if the reply buffer is to be cleared on return
050         */
051        boolean chunkReceived(IMAP imap);
052    }
053
054    /**
055     * Enumerates IMAP states.
056     */
057    public enum IMAPState {
058
059        /** A constant representing the state where the client is not yet connected to a server. */
060        DISCONNECTED_STATE,
061
062        /** A constant representing the "not authenticated" state. */
063        NOT_AUTH_STATE,
064
065        /** A constant representing the "authenticated" state. */
066        AUTH_STATE,
067
068        /** A constant representing the "logout" state. */
069        LOGOUT_STATE
070    }
071
072    /** The default IMAP port (RFC 3501). */
073    public static final int DEFAULT_PORT = 143;
074
075    /**
076     * The default control socket encoding.
077     */
078    protected static final String __DEFAULT_ENCODING = StandardCharsets.ISO_8859_1.name();
079
080    /**
081     * <p>
082     * Implements {@link IMAPChunkListener} to returns {@code true} but otherwise does nothing.
083     * </p>
084     * <p>
085     * This is intended for use with a suitable ProtocolCommandListener. If the IMAP response contains multiple-line data, the protocol listener will be called
086     * 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
087     * 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.
088     * </p>
089     * <p>
090     * The ProtocolCommandListener must be prepared to accept partial responses. This should not be a problem for listeners that just log the input.
091     * </p>
092     *
093     * @see #setChunkListener(IMAPChunkListener)
094     * @since 3.4
095     */
096    public static final IMAPChunkListener TRUE_CHUNK_LISTENER = imap -> true;
097
098    /**
099     * 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
100     * empty string, "" is returned. If it contains a space then it is enclosed in double quotes, escaping the characters backslash and double-quote.
101     *
102     * @param input the value to be quoted, may be null
103     * @return the quoted value
104     */
105    static String quoteMailboxName(final String input) {
106        if (input == null) { // Don't throw NPE here
107            return null;
108        }
109        if (input.isEmpty()) {
110            return "\"\""; // return the string ""
111        }
112        // Length check is necessary to ensure a lone double-quote is quoted
113        if (input.length() > 1 && input.startsWith("\"") && input.endsWith("\"")) {
114            return input; // Assume already quoted
115        }
116        if (input.contains(" ")) {
117            // quoted strings must escape \ and "
118            return "\"" + input.replaceAll("([\\\\\"])", "\\\\$1") + "\"";
119        }
120        return input;
121
122    }
123
124    private IMAPState state;
125
126    /**
127     * Buffered writer.
128     */
129    protected BufferedWriter __writer;
130
131    /**
132     * Buffered reader.
133     */
134    protected BufferedReader _reader;
135
136    private int replyCode;
137    private final List<String> replyLines;
138
139    private volatile IMAPChunkListener chunkListener;
140
141    private final char[] initialID = { 'A', 'A', 'A', 'A' };
142
143    /**
144     * The default IMAPClient constructor. Initializes the state to {@code DISCONNECTED_STATE}.
145     */
146    public IMAP() {
147        setDefaultPort(DEFAULT_PORT);
148        state = IMAPState.DISCONNECTED_STATE;
149        _reader = null;
150        __writer = null;
151        replyLines = new ArrayList<>();
152        createCommandSupport();
153    }
154
155    /**
156     * Performs connection initialization and sets state to {@link IMAPState#NOT_AUTH_STATE}.
157     */
158    @Override
159    protected void _connectAction_() throws IOException {
160        super._connectAction_();
161        _reader = new CRLFLineReader(new InputStreamReader(_input_, __DEFAULT_ENCODING));
162        __writer = new BufferedWriter(new OutputStreamWriter(_output_, __DEFAULT_ENCODING));
163        final int tmo = getSoTimeout();
164        if (tmo <= 0) { // none set currently
165            setSoTimeout(connectTimeout); // use connect timeout to ensure we don't block forever
166        }
167        getReply(false); // untagged response
168        if (tmo <= 0) {
169            setSoTimeout(tmo); // restore the original value
170        }
171        setState(IMAPState.NOT_AUTH_STATE);
172    }
173
174    /**
175     * Disconnects the client from the server, and sets the state to {@code DISCONNECTED_STATE}. The reply text information from the last issued command
176     * is voided to allow garbage collection of the memory used to store that information.
177     *
178     * @throws IOException If there is an error in disconnecting.
179     */
180    @Override
181    public void disconnect() throws IOException {
182        super.disconnect();
183        _reader = null;
184        __writer = null;
185        replyLines.clear();
186        setState(IMAPState.DISCONNECTED_STATE);
187    }
188
189    /**
190     * Sends a command to the server and return whether successful.
191     *
192     * @param command The IMAP command to send (one of the IMAPCommand constants).
193     * @return {@code true} if the command was successful
194     * @throws IOException on error
195     */
196    public boolean doCommand(final IMAPCommand command) throws IOException {
197        return IMAPReply.isSuccess(sendCommand(command));
198    }
199
200    /**
201     * Sends a command and arguments to the server and return whether successful.
202     *
203     * @param command The IMAP command to send (one of the IMAPCommand constants).
204     * @param args    The command arguments.
205     * @return {@code true} if the command was successful
206     * @throws IOException on error
207     */
208    public boolean doCommand(final IMAPCommand command, final String args) throws IOException {
209        return IMAPReply.isSuccess(sendCommand(command, args));
210    }
211
212    /**
213     * Overrides {@link SocketClient#fireReplyReceived(int, String)} to avoid creating the reply string if there are no listeners to invoke.
214     *
215     * @param replyCode passed to the listeners
216     * @param ignored   the string is only created if there are listeners defined.
217     * @see #getReplyString()
218     * @since 3.4
219     */
220    @Override
221    protected void fireReplyReceived(final int replyCode, final String ignored) {
222        getCommandSupport().fireReplyReceived(replyCode, getReplyString());
223    }
224
225    /**
226     * Generates a new command ID (tag) for a command.
227     *
228     * @return a new command ID (tag) for an IMAP command.
229     */
230    protected String generateCommandID() {
231        final String res = new String(initialID);
232        // "increase" the ID for the next call
233        boolean carry = true; // want to increment initially
234        for (int i = initialID.length - 1; carry && i >= 0; i--) {
235            if (initialID[i] == 'Z') {
236                initialID[i] = 'A';
237            } else {
238                initialID[i]++;
239                carry = false; // did not wrap round
240            }
241        }
242        return res;
243    }
244
245    /**
246     * Gets the reply for a command that expects a tagged response.
247     *
248     * @throws IOException
249     */
250    private void getReply() throws IOException {
251        getReply(true); // tagged response
252    }
253
254    /**
255     * Gets the reply for a command, reading the response until the reply is found.
256     *
257     * @param wantTag {@code true} if the command expects a tagged response.
258     * @throws IOException
259     */
260    private void getReply(final boolean wantTag) throws IOException {
261        replyLines.clear();
262        String line = _reader.readLine();
263
264        if (line == null) {
265            throw new EOFException("Connection closed without indication.");
266        }
267
268        replyLines.add(line);
269
270        if (wantTag) {
271            while (IMAPReply.isUntagged(line)) {
272                int literalCount = IMAPReply.literalCount(line);
273                final boolean isMultiLine = literalCount >= 0;
274                while (literalCount >= 0) {
275                    line = _reader.readLine();
276                    if (line == null) {
277                        throw new EOFException("Connection closed without indication.");
278                    }
279                    replyLines.add(line);
280                    literalCount -= line.length() + 2; // Allow for CRLF
281                }
282                if (isMultiLine) {
283                    final IMAPChunkListener il = chunkListener;
284                    if (il != null) {
285                        final boolean clear = il.chunkReceived(this);
286                        if (clear) {
287                            fireReplyReceived(IMAPReply.PARTIAL, getReplyString());
288                            replyLines.clear();
289                        }
290                    }
291                }
292                line = _reader.readLine(); // get next chunk or final tag
293                if (line == null) {
294                    throw new EOFException("Connection closed without indication.");
295                }
296                replyLines.add(line);
297            }
298            // check the response code on the last line
299            replyCode = IMAPReply.getReplyCode(line);
300        } else {
301            replyCode = IMAPReply.getUntaggedReplyCode(line);
302        }
303
304        fireReplyReceived(replyCode, getReplyString());
305    }
306
307    /**
308     * Gets the reply to the last command sent to the server. The value is a single string containing all the reply lines including newlines.
309     *
310     * @return The last server response.
311     */
312    public String getReplyString() {
313        final StringBuilder buffer = new StringBuilder(256);
314        for (final String s : replyLines) {
315            buffer.append(s);
316            buffer.append(NETASCII_EOL);
317        }
318
319        return buffer.toString();
320    }
321
322    /**
323     * Gets an array of lines received as a reply to the last command sent to the server. The lines have end of lines truncated.
324     *
325     * @return The last server response.
326     */
327    public String[] getReplyStrings() {
328        return replyLines.toArray(NetConstants.EMPTY_STRING_ARRAY);
329    }
330
331    /**
332     * Gets the current IMAP client state.
333     *
334     * @return The current IMAP client state.
335     */
336    public IMAP.IMAPState getState() {
337        return state;
338    }
339
340    /**
341     * Sends a command with no arguments to the server and returns the reply code.
342     *
343     * @param command The IMAP command to send (one of the IMAPCommand constants).
344     * @return The server reply code (see IMAPReply).
345     * @throws IOException on error
346     **/
347    public int sendCommand(final IMAPCommand command) throws IOException {
348        return sendCommand(command, null);
349    }
350
351    /**
352     * Sends a command and arguments to the server and returns the reply code.
353     *
354     * @param command The IMAP command to send (one of the IMAPCommand constants).
355     * @param args    The command arguments.
356     * @return The server reply code (see IMAPReply).
357     * @throws IOException on error
358     */
359    public int sendCommand(final IMAPCommand command, final String args) throws IOException {
360        return sendCommand(command.getIMAPCommand(), args);
361    }
362
363    /**
364     * Sends a command with no arguments to the server and returns the reply code.
365     *
366     * @param command The IMAP command to send.
367     * @return The server reply code (see IMAPReply).
368     * @throws IOException on error
369     */
370    public int sendCommand(final String command) throws IOException {
371        return sendCommand(command, null);
372    }
373
374    /**
375     * Sends a command an arguments to the server and returns the reply code.
376     *
377     * @param command The IMAP command to send.
378     * @param args    The command arguments.
379     * @return The server reply code (see IMAPReply).
380     * @throws IOException on error
381     */
382    public int sendCommand(final String command, final String args) throws IOException {
383        return sendCommandWithID(generateCommandID(), command, args);
384    }
385
386    /**
387     * Sends a command an arguments to the server and returns the reply code.
388     *
389     * @param commandID The ID (tag) of the command.
390     * @param command   The IMAP command to send.
391     * @param args      The command arguments.
392     * @return The server reply code (either {@link IMAPReply#OK}, {@link IMAPReply#NO} or {@link IMAPReply#BAD}).
393     */
394    private int sendCommandWithID(final String commandID, final String command, final String args) throws IOException {
395        final StringBuilder builder = new StringBuilder();
396        if (commandID != null) {
397            builder.append(commandID);
398            builder.append(' ');
399        }
400        builder.append(command);
401        if (args != null) {
402            builder.append(' ');
403            builder.append(args);
404        }
405        builder.append(NETASCII_EOL);
406        final String message = builder.toString();
407        __writer.write(message);
408        __writer.flush();
409        fireCommandSent(command, message);
410        getReply();
411        return replyCode;
412    }
413
414    /**
415     * Sends data to the server and returns the reply code.
416     *
417     * @param command The IMAP command to send.
418     * @return The server reply code (see IMAPReply).
419     * @throws IOException on error
420     */
421    public int sendData(final String command) throws IOException {
422        return sendCommandWithID(null, command, null);
423    }
424
425    /**
426     * Sets the current chunk listener. If a listener is registered and the implementation returns true, then any registered
427     * {@link org.apache.commons.net.PrintCommandListener PrintCommandListener} instances will be invoked with the partial response and a status of
428     * {@link IMAPReply#PARTIAL} to indicate that the final reply code is not yet known.
429     *
430     * @param listener the class to use, or {@code null} to disable
431     * @see #TRUE_CHUNK_LISTENER
432     * @since 3.4
433     */
434    public void setChunkListener(final IMAPChunkListener listener) {
435        chunkListener = listener;
436    }
437
438    /**
439     * Sets IMAP client state. This must be one of the {@code _STATE} constants.
440     *
441     * @param state The new state.
442     */
443    protected void setState(final IMAP.IMAPState state) {
444        this.state = state;
445    }
446}
447