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