    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