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     * 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    /**
053     * Enumerates IMAP states.
054     */
055    public enum IMAPState {
056        /** A constant representing the state where the client is not yet connected to a server. */
057        DISCONNECTED_STATE,
058        /** A constant representing the "not authenticated" state. */
059        NOT_AUTH_STATE,
060        /** A constant representing the "authenticated" state. */
061        AUTH_STATE,
062        /** A constant representing the "logout" state. */
063        LOGOUT_STATE
064    }
065
066    /** The default IMAP port (RFC 3501). */
067    public static final int DEFAULT_PORT = 143;
068
069    // RFC 3501, section 5.1.3. It should be "modified UTF-7".
070    /**
071     * The default control socket encoding.
072     */
073    protected static final String __DEFAULT_ENCODING = StandardCharsets.ISO_8859_1.name();
074
075    /**
076     * <p>
077     * Implements {@link IMAPChunkListener} to returns {@code true} but otherwise does nothing.
078     * </p>
079     * <p>
080     * This is intended for use with a suitable ProtocolCommandListener. If the IMAP response contains multiple-line data, the protocol listener will be called
081     * 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
082     * 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.
083     * </p>
084     * <p>
085     * The ProtocolCommandListener must be prepared to accept partial responses. This should not be a problem for listeners that just log the input.
086     * </p>
087     *
088     * @see #setChunkListener(IMAPChunkListener)
089     * @since 3.4
090     */
091    public static final IMAPChunkListener TRUE_CHUNK_LISTENER = imap -> true;
092
093    /**
094     * 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
095     * empty string, "" is returned. If it contains a space then it is enclosed in double quotes, escaping the characters backslash and double-quote.
096     *
097     * @param input the value to be quoted, may be null
098     * @return the quoted value
099     */
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