/*
 * @(#)Smtp.java
 *
 * Copyright (c) 2001-2002 JScape
 * 1147 S. 53rd Pl., Mesa, Arizona, 85206, U.S.A.
 * All rights reserved.
 *
 * This software is the confidential and proprietary information of
 * JScape. ("Confidential Information").  You shall not disclose such
 * Confidential Information and shall use it only in accordance with
 * the terms of the license agreement you entered into with JScape.
 */
package com.jscape.inet.smtp;

import com.jscape.inet.email.EmailAddress;
import com.jscape.inet.email.EmailAddressList;
import com.jscape.inet.email.EmailMessage;
import com.jscape.inet.mime.MimeException;
import com.jscape.inet.mime.MimeHeader;
import com.jscape.inet.proxy.Proxies;
import com.jscape.inet.proxy.Proxy;
import com.jscape.inet.proxy.ProxyDescriptor;
import com.jscape.inet.proxy.ProxySocketConnector;
import com.jscape.inet.util.MD5;
import com.jscape.util.Base64Format;
import com.jscape.util.SuppressObfuscation;
import com.jscape.util.net.ConnectionDescriptor;
import com.jscape.util.net.TransportAddress;
import com.jscape.util.net.connection.Connector;
import com.jscape.util.net.connection.SyncRawConnection;
import com.jscape.util.net.socket.SocketConnector;
import com.jscape.util.net.socket.SocketParameters;

import java.io.*;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.Enumeration;
import java.util.StringTokenizer;
import java.util.Vector;

import static com.jscape.util.Parameters.assertNotNull;


/**
 * Implements the basic functionality of a SMTP client. <P>
 * Example Usage:
 * <p/>
 * <P>
 * <pre>
 * // create new instance
 * Smtp smtp = new Smtp("smtp.myserver.com");
 *  // address the message
 * EmailMessage message = new EmailMessage();
 * message.setTo("mjones@myserver.com");
 * message.setFrom("jsmith@myserver.com");
 * message.setSubject("Meeting today at 8");
 * message setBody("see you then");
 *  // connect, send the message, disconnect
 * try
 * {
 *   smtp.connect();
 *   smtp.send(message);
 *   smtp.disconnect();
 * }
 * catch(Exception e)
 * {
 *   System.out.println(e);
 * }
 * </pre>
 * </p>
 */
@SuppressWarnings("unchecked")
public class Smtp
        implements AutoCloseable,
                   Serializable {

    /**
     * Standard username/password authentication.
     *
     * @see #login
     */
    public static final int AUTH_LOGIN = 0;

    /**
     * CRAM_MD5 authentication.
     *
     * @see #login
     */
    public static final int AUTH_CRAM_MD5 = 1;

    public static final int AUTH_XOAUTH2 = 2;

    protected static final int DEFAULT_COMMAND_LENGTH = 512;

    protected static final int DEFAULT_PROXY_PORT = 1080;
    protected static final String DEFAULT_PROXY_TYPE = "SOCKS5";

    protected transient SyncRawConnection connection = null;
    protected transient BufferedOutputStream out = null;
    protected transient BufferedReader in = null;
    protected String hostname = "";
    protected String heloAddress;
    protected transient Vector smtpListeners = new Vector();
    protected boolean debug;
    protected int connectTimeout = 30000;
    protected int port = 25;
    protected int readTimeout = 30000;
    protected int timeout = 30000;
    protected Authentication authentication = Authentication.BASIC;

    /**
     * Holds value of property debugStream.
     */
    protected transient PrintStream debugStream = System.out;
    protected String fromAddress;
    protected String toAddress;
    protected String proxyHostname = null;
    protected int proxyPort = DEFAULT_PROXY_PORT;
    protected String proxyUsername = null;
    protected String proxyPassword = null;
    protected String proxyType = DEFAULT_PROXY_TYPE;

    /**
     * Constructs a new Smtp instance.
     */
    public Smtp() {
        
    }

    /**
     * Constructs a new Smtp instance.
     *
     * @param hostname the hostname of SMTP server
     */
    public Smtp(String hostname) {
        this();
        this.hostname = hostname;
    }

    /**
     * Sets the timeout for opening the connection.
     * <p/>
     * Default is 30 seconds.
     *
     * @param timeout the timeout in milliseconds
     */
    public void setConnectTimeout(int timeout) {
        this.connectTimeout = timeout;
    }

    /**
     * Gets the timeout used when opening a connection to SMTP server.
     * <p/>
     * Default is 30 seconds.
     *
     * @return timeout server connection.
     */
    public int getConnectTimeout() {
        return this.connectTimeout;
    }

    public Authentication getAuthentication() {
        return this.authentication;
    }

    public Smtp setAuthentication(Authentication value) {
        assertNotNull(value);
        this.authentication = value;

        return this;
    }

    /**
     * Checks if client is connected to SMTP server.
     *
     * @return connection status
     */
    public boolean isConnected() {
        if (out != null) {
            boolean connected = true;

            try {
                String response = issueCommand("NOOP");

                if (response.startsWith("421") || response.startsWith("221")) {
                    connected = false;
                }
            } catch (SmtpException se) {
                // ignore if NOOP command is not supported
                if (!se.getMessage().startsWith("500")) {
                    connected = false;
                }
            }

            return connected;
        } else {
            return false;
        }
    }

    /**
     * Sets debugging state. All debug statements are reported to debug stream.
     * Default debug stream is System.out
     *
     * @param debug <code>true</code> to turn debugging on
     *
     * @see #setDebugStream
     */
    public void setDebug(boolean debug) {
        this.debug = debug;
    }

    /**
     * Gets debug level of SMTP session. Debug messages are reported to debug
     * stream.  Default debug stream is System.out
     *
     * @return <code>true</code> if debugging is on
     *
     * @see #setDebugStream
     */
    public boolean isDebug() {
        return debug;
    }

    /**
     * Sets PrintStream used in reporting debug statements. Default PrintStream
     * is System.out
     *
     * @param debugStream the PrintStream to send debug statements to
     *
     * @see java.io.PrintStream
     */
    public void setDebugStream(PrintStream debugStream) {
        this.debugStream = debugStream;
    }

    /**
     * Gets PrintStream used in reporting debug statements. Default PrintStream
     * is System.out
     *
     * @return the PrintStream used in debugging
     *
     * @see java.io.PrintStream
     */
    public PrintStream getDebugStream() {
        return this.debugStream;
    }

    /**
     * Sets the source address to use when issuing an EHLO or HELO command to SMTP server.
     *
     * @param heloAddress the IP address or hostname to use
     */
    public void setHeloAddress(String heloAddress) {
        this.heloAddress = heloAddress;
    }

    /**
     * Sets the hostname or IP address of SMTP server.
     *
     * @param hostname the hostname of SMTP server
     */
    public void setHostname(String hostname) {
        synchronized (this) {
            this.hostname = hostname;
        }
    }

    /**
     * Gets the hostname or IP address of SMTP server.
     *
     * @return hostname
     */
    public String getHostname() {
        return hostname;
    }

    /**
     * Sets port of SMTP server. Default port value is 25.
     *
     * @param port the port of SMTP server
     */
    public void setPort(int port) {
        this.port = port;
    }

    /**
     * Gets port of SMTP server. Default port value is 25.
     *
     * @return port
     */
    public int getPort() {
        return port;
    }

    /**
     * Sets the timeout for reading responses from SMTP server.
     * Default is 30 seconds.
     *
     * @param timeout the timeout in milliseconds
     */
    public void setReadTimeout(int timeout) {
        this.readTimeout = timeout;
    }

    /**
     * Gets the timeout used when reading responses from SMTP server.
     * Default is 30 seconds.
     *
     * @return reading from server timeout.
     */
    public int getReadTimeout() {
        return this.readTimeout;
    }

    /**
     * Sets the timeout for opening connection to SMTP server.
     * Default is 30 seconds.
     *
     * @param timeout the timeout in milliseconds
     *
     * @deprecated Replaced by setConnectTimeout and setReadTimeout
     */
    public void setTimeout(int timeout) {
        this.timeout = timeout;
        setConnectTimeout(timeout);
        setReadTimeout(timeout);
    }

    /**
     * Gets the timeout for opening connection to SMTP server.
     * Default is 30 seconds.
     *
     * @return timeout in milliseconds
     *
     * @deprecated Replaced by getConnectTimeout and getReadTimeout
     */
    public int getTimeout() {
        return timeout;
    }

    /**
     * Sets the username and password to use when for authentication with
     * proxy server.
     * To clear these settings invoke the <code>#clearProxySettings</code> method.
     *
     * @param proxyUsername the proxy username
     * @param proxyPassword the proxy password
     *
     * @see #clearProxySettings
     */
    public void setProxyAuthentication(String proxyUsername,
                                       String proxyPassword) {
        this.proxyUsername = proxyUsername;
        this.proxyPassword = proxyPassword;
    }

    // PROXY CONTROL

    /**
     * Sets the proxy hostname and port for this connection.
     * To clear these settings invoke the <code>#clearProxySettings</code> method.
     *
     * @param proxyHostname the hostname or ip address of the proxy server
     * @param proxyPort     the port of the proxy server
     *
     * @see #clearProxySettings
     */
    public void setProxyHost(String proxyHostname, int proxyPort) {
        this.proxyHostname = proxyHostname;
        this.proxyPort = proxyPort;
    }

    /**
     * Sets the proxy type will be used for this connection.
     *
     * @param proxyType The proxy type. Valid values: HTTP, SOCKS5
     */
    public void setProxyType(String proxyType) {
        this.proxyType = proxyType;
    }

    /**
     * Clears proxy server values.
     */
    public void clearProxySettings() {
        setProxyAuthentication(null, null);
        setProxyHost(null, DEFAULT_PROXY_PORT);
        setProxyType(DEFAULT_PROXY_TYPE);
    }

    /**
     * Adds Smtp event listener.
     *
     * @param listener the SmtpListener to be added
     *
     * @see com.jscape.inet.smtp.SmtpListener
     */
    public synchronized void addSmtpListener(SmtpListener listener) {
        smtpListeners.addElement(listener);
    }

    /**
     * Connects to SMTP server.
     *
     * @throws SmtpException thrown for I/O or SMTP related error
     */
    public Smtp connect()
            throws SmtpException {
        synchronized (this) {
            try {
                Connector<SyncRawConnection> rawConnector = rawConnector();
                this.connection = rawConnector.connect();

                in = new BufferedReader(new InputStreamReader(new SyncRawConnection.ConnectionInputStream(this.connection), "8859_1"));
                out = new BufferedOutputStream(new SyncRawConnection.ConnectionOutputStream(this.connection));

                fireSmtpConnected(hostname);

                // read SMTP banner
                readResponse();

                // get HELO address
                String ip = null;

                if ((heloAddress != null) && (heloAddress.trim().length() > 0)) {
                    ip = heloAddress;
                } else {
                    try {
                        ip = InetAddress.getLocalHost().getHostAddress();
                    } catch (UnknownHostException uh) {
                        ip = "localhost";
                    }
                }

                // issue HELO command
                try {
                    writeCommand(out, "EHLO " + ip);
                    readResponse();
                } catch (Exception ez) {
                    writeCommand(out, "HELO " + ip);
                    readResponse();
                }

                return this;
            } catch (IOException ioe) {
                throw new SmtpException("an I/O error occured: " + ioe.getMessage(), ioe);
            }
        }
    }

    /**
     * Disconnects from SMTP server.
     *
     * @throws SmtpException thrown to handle SMTP and I/O related errors
     * @see SmtpException
     */
    public void disconnect()
            throws SmtpException {
        synchronized (this) {
            try {
                if (out != null) {
                    writeCommand(out, "QUIT");
                    readResponse();
                    out.close();
                    in.close();
                    connection.close();
                    out = null;
                    in = null;
                    connection = null;
                }
            } catch (IOException ioe) {
                throw new SmtpException("I/O error during disconnect", ioe);
            }
        }

        fireSmtpDisconnected(hostname);
    }

    @Override
    public void close()
            throws Exception {
        disconnect();
    }

    /**
     * Sends command to SMTP server Command must be a valid SMTP command.
     *
     * @param command an SMTP command
     *
     * @return the SMTP response
     *
     * @throws SmtpException if an I/O or SMTP related error occurs
     */
    public String issueCommand(String command)
            throws SmtpException {
        String response = null;

        synchronized (this) {
            try {
                writeCommand(out, command);
                response = readResponse();
            } catch (Exception ioe) {
                throw new SmtpException(ioe.getMessage(), ioe);
            }
        }

        return response;
    }

    /**
     * Login to SMTP server using provided username, password and
     * authentication mode. Valid authentication modes are Smtp.AUTH_LOGIN and
     * Smtp.AUTH_CRAM_MD5.  Default authentication mode is Smtp.AUTH_LOGIN
     *
     * @param username       the SMTP username
     * @param password       the SMTP password
     * @param authenticationCode the authentication mode to use
     *
     * @throws SmtpException thrown for I/O or SMTP related error
     * @see #AUTH_LOGIN
     * @see #AUTH_CRAM_MD5
     */
    public void login(String username, String password, int authenticationCode)
            throws SmtpException {
        login(username, password, Authentication.authenticationFor(authenticationCode));
    }

    public void login(String username, String password, Authentication authentication)
            throws SmtpException {
        authentication.apply(this, username, password);
    }

    /**
     * Login to SMTP server using standard username / password authentication.
     *
     * @param username the SMTP username
     * @param password the SMTP password
     *
     * @throws SmtpException thrown for I/O or SMTP related error
     * @see #AUTH_LOGIN
     */
    public void login(String username, String password)
            throws SmtpException {
        login(username, password, this.authentication);
    }

    /**
     * Removes specified Smtp event listener.
     *
     * @param listener the SmtpListener to be removed
     *
     * @see com.jscape.inet.smtp.SmtpListener
     */
    public synchronized void removeSmtpListener(SmtpListener listener) {
        smtpListeners.removeElement(listener);
    }

    /**
     * Sends basic email message. For more control over the content of the
     * email message see the overloaded version of send method.
     *
     * @param from    the from email address
     * @param to      the to email address
     * @param subject the subject of the email message
     * @param body    the body of the email message
     *
     * @throws SmtpException if an I/O or SMTP related error occurs
     * @throws MimeException if a MIME related error occurs
     */
    public void send(String from, String to, String subject, String body)
            throws SmtpException, MimeException {
        EmailMessage message = new EmailMessage();
        message.setFrom(from);
        message.setTo(to);
        message.setSubject(subject);
        message.setBody(body);
        send(message);
    }

    /**
     * Sends email message.
     *
     * @param message an <code>EmailMessage</code>
     * @param from    the email address to send this message from when
     *                communicating with SMTP server, overriding the From header in
     *                the message
     * @param to      the email address to send this message to when communicating
     *                with SMTP server, overriding the To header in the message
     *
     * @throws SmtpException if an I/O or SMTP related error occurs
     * @see com.jscape.inet.email.EmailMessage
     */
    public void send(EmailMessage message, String from, String to)
            throws SmtpException {
        this.fromAddress = from;
        this.toAddress = to;
        send(message);
    }

    /**
     * Sends email message.
     *
     * @param message an <code>EmailMessage</code>
     *
     * @throws SmtpException if an I/O or SMTP related error occurs
     * @see com.jscape.inet.email.EmailMessage
     */
    public void send(EmailMessage message)
            throws SmtpException {
        synchronized (this) {
            MimeHeader from = message.getHeader("From");
            MimeHeader to = message.getHeader("To");
            MimeHeader cc = message.getHeader("Cc");
            MimeHeader bcc = message.getHeader("Bcc");

            if (from == null) {
                throw new SmtpException("From address not defined");
            }

            if (to == null && cc == null && bcc == null) {
                throw new SmtpException("To or Cc or Bcc address not defined");
            }

            try {
                // check if from address was overridden or is in message header
                EmailAddressList fromList = (this.fromAddress != null)
                                            ? new EmailAddressList(this.fromAddress)
                                            : new EmailAddressList(from.getValue());

                // loop thru all addresses
                Enumeration fromAddresses = fromList.getEmailAddresses();

                while (fromAddresses.hasMoreElements()) {
                    EmailAddress email = (EmailAddress) fromAddresses.nextElement();
                    writeCommand(this.out, "MAIL FROM:<" + email.getEmail() + ">");
                    readResponse();

                    // break after first MAIL FROM command
                    break;
                }

                // send RCPT TO
                // check if to address was overridden or is in message header
                EmailAddressList toList = (this.toAddress != null)
                                          ? new EmailAddressList(this.toAddress)
                                          : new EmailAddressList(to != null ? to.getValue() : "");

                // loop thru all addresses
                Enumeration toAddresses = toList.getEmailAddresses();

                while (toAddresses.hasMoreElements()) {
                    EmailAddress email = (EmailAddress) toAddresses.nextElement();
                    writeCommand(this.out, "RCPT TO:<" + email.getEmail() + ">");
                    readResponse();
                }

                // send to carbon copy addresses
                if (cc != null) {
                    EmailAddressList ccList = new EmailAddressList(cc.getValue());
                    Enumeration ccAddresses = ccList.getEmailAddresses();

                    while (ccAddresses.hasMoreElements()) {
                        EmailAddress email = (EmailAddress) ccAddresses.nextElement();
                        writeCommand(this.out, "RCPT TO:<" + email.getEmail() + ">");
                        readResponse();
                    }
                }

                if (bcc != null) {
                    EmailAddressList bccList = new EmailAddressList(bcc.getValue());
                    Enumeration bccAddresses = bccList.getEmailAddresses();

                    while (bccAddresses.hasMoreElements()) {
                        EmailAddress email = (EmailAddress) bccAddresses.nextElement();
                        writeCommand(this.out, "RCPT TO:<" + email.getEmail() + ">");
                        readResponse();
                    }
                }

                // send DATA
                writeCommand(this.out, "DATA");
                readResponse();

                // send message
                byte[] messageData = escapePeriods(message.getMessage());
                writeBytes(this.out, messageData);

                // send terminiation
                writeCommand(this.out, "\r\n.");
                readResponse();
            } catch (Exception ioe) {
                throw new SmtpException(ioe.getMessage(), ioe);
            }
        }
    }

    protected Connector<SyncRawConnection> rawConnector() {
        ConnectionDescriptor connectionDescriptor = new ConnectionDescriptor(new TransportAddress(this.hostname, this.port), this.connectTimeout);
        SocketParameters socketParameters = SocketParameters.defaultParameters();

        if (this.proxyType != null && this.proxyHostname != null) {
            ProxyDescriptor proxyDescriptor = new ProxyDescriptor(this.proxyHostname,
                                                                  this.proxyPort,
                                                                  this.proxyUsername,
                                                                  this.proxyPassword);
            Proxy proxy = Proxies.proxyFor(this.proxyType);
            return new ProxySocketConnector(proxyDescriptor, proxy, connectionDescriptor, socketParameters);
        } else {
            return new SocketConnector(connectionDescriptor, socketParameters);
        }
    }

    /**
     * Fires <code>SmtpCommandEvent</code>. This event is fired upon sending a
     * command to SMTP server.
     *
     * @param command the command sent
     *
     * @see SmtpCommandEvent
     */
    protected void fireSmtpCommand(String command) {
        Vector thisList;
        SmtpCommandEvent thisEvent = new SmtpCommandEvent(this, command);

        // Make a copy of the list so potential changes to it by
        // other threads won't affect traversal.
        synchronized (this) {
            thisList = (Vector) smtpListeners.clone();
        }

        // Send a SmtpConnectedEvent to every listener.
        for (int elem = 0; elem < thisList.size(); elem++) {
            ((SmtpListener) thisList.elementAt(elem)).commandSent(thisEvent);
        }
    }

    /**
     * Fires <code>SmtpConnectedEvent</code>. This event is fired upon
     * connecting to SMTP server.
     *
     * @param hostname the SMTP server hostname
     *
     * @see SmtpConnectedEvent
     */
    protected void fireSmtpConnected(String hostname) {
        Vector thisList;
        SmtpConnectedEvent thisEvent = new SmtpConnectedEvent(this, hostname);

        // Make a copy of the list so potential changes to it by
        // other threads won't affect traversal.
        synchronized (this) {
            thisList = (Vector) smtpListeners.clone();
        }

        // Send a SmtpConnectedEvent to every listener.
        for (int elem = 0; elem < thisList.size(); elem++) {
            ((SmtpListener) thisList.elementAt(elem)).connected(thisEvent);
        }
    }

    /**
     * Fires <code>SmtpDisconnectedEvent</code>. This event is fired upon
     * disconnecting from SMTP server.
     *
     * @param hostname the SMTP server hostname
     *
     * @see SmtpDisconnectedEvent
     */
    protected void fireSmtpDisconnected(String hostname) {
        Vector thisList;
        SmtpDisconnectedEvent thisEvent = new SmtpDisconnectedEvent(this,
                                                                    hostname);

        // Make a copy of the list so potential changes to it by
        // other threads won't affect traversal.
        synchronized (this) {
            thisList = (Vector) smtpListeners.clone();
        }

        // Send a SmtpDisconnectedEvent to every listener.
        for (int elem = 0; elem < thisList.size(); elem++) {
            ((SmtpListener) thisList.elementAt(elem)).disconnected(thisEvent);
        }
    }

    /**
     * Fires <code>SmtpResponseEvent</code>. This event is fired upon receiving
     * a response from SMTP server.
     *
     * @param response the SMTP server response
     *
     * @see SmtpResponseEvent
     */
    protected void fireSmtpResponse(String response) {
        Vector thisList;
        SmtpResponseEvent thisEvent = new SmtpResponseEvent(this, response);

        // Make a copy of the list so potential changes to it by
        // other threads won't affect traversal.
        synchronized (this) {
            thisList = (Vector) smtpListeners.clone();
        }

        // Send a SmtpConnectedEvent to every listener.
        for (int elem = 0; elem < thisList.size(); elem++) {
            ((SmtpListener) thisList.elementAt(elem)).responseReceived(thisEvent);
        }
    }

    /**
     * Reads response sent by SMTP server.
     *
     * @return the SMTP server response
     *
     * @throws IOException   thrown for I/O related error
     * @throws SmtpException thrown for Smtp related error
     */
    protected String readResponse()
            throws IOException, SmtpException {
        // checks for multi-line response
        StringBuffer response = new StringBuffer();

        while (true) {
            String line = in.readLine();

            if (line != null) {
                debug(line);
                response.append(line);
                response.append("\r\n");

                try {
                    Integer.parseInt(line.substring(0, 1));
                } catch (NumberFormatException nfe) {
                    throw new SmtpException("Unexpected response from SMTP server " + line);
                }

                if (line.startsWith("5")) {
                    throw new SmtpException(line);
                } else if (line.startsWith("451")) {
                    throw new SmtpException(line);
                }

                if (!line.substring(3, 4).equals("-")) {
                    break;
                }
            } else {
                break;
            }
        }

        fireSmtpResponse(response.toString());

        return response.toString();
    }

    /**
     * Sends data to SMTP server.
     *
     * @param writer the BufferedOutputStream to use
     * @param data   the data to send
     *
     * @throws IOException if an I/O related error occurs
     */
    protected void writeBytes(BufferedOutputStream writer, byte[] data)
            throws IOException {
        writer.write(data, 0, data.length);
        writer.flush();
    }

    /**
     * Writes command to SMTP server.
     *
     * @param writer the OutputStreamWriter to use
     * @param line   the command to send
     *
     * @throws IOException if an I/O related error occurs
     */
    protected void writeCommand(OutputStream writer, String line)
            throws IOException {
        debug(line);
        line += "\r\n";

        byte[] data = line.getBytes(StandardCharsets.US_ASCII);
        writer.write(data, 0, data.length);
        writer.flush();
        fireSmtpCommand(line);
    }

    /**
     * Prints information to debug stream. Default debug stream is System.out
     *
     * @param line the message to debug
     */
    protected void debug(String line) {
        if (debug) {
            debugStream.println(line);
        }
    }

    /**
     * Escapes periods with double periods.
     * Used when sending an email message
     *
     * @param data the data to escape
     *
     * @return the escaped data
     */
    protected byte[] escapePeriods(byte[] data) {
        ByteArrayOutputStream bout = new ByteArrayOutputStream();
        byte lf = 10;
        byte cr = 13;
        byte period = 46;
        byte previous = 0;
        byte current = 0;

        for (int i = 0; i < data.length; ++i) {
            current = data[i];

            if ((current == period) && ((previous == lf) || (previous == cr))) {
                bout.write(current);
                bout.write(current);
            } else {
                bout.write(current);
            }

            previous = current;
        }

        return bout.toByteArray();
    }

    @SuppressObfuscation
    public enum Authentication {

        BASIC(0) {
            @Override
            public void apply(Smtp client, String username, String password)
                    throws SmtpException {
                synchronized (client) {
                    try {
                        client.writeCommand(client.out, "AUTH LOGIN");
                        client.readResponse();

                        // write username
                        String encUsername = Base64Format.formatData(username.getBytes(StandardCharsets.UTF_8), DEFAULT_COMMAND_LENGTH);

                        client.writeCommand(client.out, encUsername);
                        client.readResponse();

                        // write password
                        String encPassword = Base64Format.formatData(password.getBytes(StandardCharsets.UTF_8), DEFAULT_COMMAND_LENGTH);

                        client.writeCommand(client.out, encPassword);
                        client.readResponse();
                    } catch (IOException ioe) {
                        throw new SmtpException("an I/O error occurred: " + ioe.getMessage(), ioe);
                    }
                }
            }
        },
        CRAM_MD5(1) {
            @Override
            public void apply(Smtp client, String username, String password)
                    throws SmtpException {
                String command = "AUTH CRAM-MD5";

                try {
                    client.writeCommand(client.out, command);

                    // send command notification
                    client.fireSmtpCommand(command);

                    // read response
                    String response = client.readResponse();

                    // output hashed value
                    StringTokenizer tokenizer = new StringTokenizer(response);
                    tokenizer.nextToken();

                    String value = tokenizer.nextToken();
                    byte[] data = Base64Format.parseData(value);
                    MD5 md5 = new MD5(new String(data, StandardCharsets.US_ASCII), password);

                    String credentials = username + " " + md5.getHash();
                    client.writeCommand(client.out, Base64Format.formatData(credentials.getBytes(StandardCharsets.US_ASCII), DEFAULT_COMMAND_LENGTH));
                    client.readResponse();
                } catch (IOException ioe) {
                    throw new SmtpException("I/O error occurred", ioe);
                }
            }
        },
        XOAUTH2(2) {
            @Override
            public void apply(Smtp client, String username, String token)
                    throws SmtpException {
                try {
                    synchronized (this) {
                        client.writeCommand(client.out, "AUTH XOAUTH2");
                        client.readResponse();

                        String credentials = String.format("user=%s\001auth=Bearer %s\001\001", username, token);
                        client.writeCommand(client.out, Base64Format.formatData(credentials.getBytes(StandardCharsets.UTF_8)));
                        String response = client.readResponse();

                        if (response.startsWith("334")) {
                            client.writeCommand(client.out, "");
                            client.readResponse();
                        }
                    }
                } catch (IOException ioe) {
                    throw new SmtpException("an I/O error occurred: " + ioe.getMessage(), ioe);
                }
            }
        };

        public final int code;

        public static Authentication authenticationFor(int code) {
            for (Authentication candidate : values()) {
                if (candidate.code == code) {
                    return candidate;
                }
            }

            throw new IllegalArgumentException(String.format("Unsupported authentication code: %s.", code));
        }

        Authentication(int code) {
            this.code = code;
        }

        public abstract void apply(Smtp client, String username, String password)
                throws SmtpException;
    }
}
