com.zimbra.cs.mailclient.smtp.SmtpConnection.java Source code

Java tutorial

Introduction

Here is the source code for com.zimbra.cs.mailclient.smtp.SmtpConnection.java

Source

/*
 * ***** BEGIN LICENSE BLOCK *****
 * Zimbra Collaboration Suite Server
 * Copyright (C) 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016 Synacor, Inc.
 *
 * This program is free software: you can redistribute it and/or modify it under
 * the terms of the GNU General Public License as published by the Free Software Foundation,
 * version 2 of the License.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
 * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU General Public License for more details.
 * You should have received a copy of the GNU General Public License along with this program.
 * If not, see <https://www.gnu.org/licenses/>.
 * ***** END LICENSE BLOCK *****
 */
package com.zimbra.cs.mailclient.smtp;

import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.mail.Address;
import javax.mail.MessagingException;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import javax.security.sasl.SaslException;

import org.apache.commons.codec.binary.Base64;

import com.google.common.base.CharMatcher;
import com.google.common.base.Preconditions;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.Iterables;
import com.sun.mail.smtp.SMTPMessage;
import com.zimbra.cs.mailclient.CommandFailedException;
import com.zimbra.cs.mailclient.MailConfig;
import com.zimbra.cs.mailclient.MailConnection;
import com.zimbra.cs.mailclient.MailException;
import com.zimbra.cs.mailclient.MailInputStream;
import com.zimbra.cs.mailclient.MailOutputStream;
import com.zimbra.cs.mailclient.auth.SaslAuthenticator;
import com.zimbra.cs.mailclient.util.Ascii;

public final class SmtpConnection extends MailConnection {

    static final String EHLO = "EHLO";
    static final String HELO = "HELO";
    static final String MAIL = "MAIL";
    static final String RCPT = "RCPT";
    static final String DATA = "DATA";
    static final String QUIT = "QUIT";
    static final String AUTH = "AUTH";
    static final String STARTTLS = "STARTTLS";
    static final String RSET = "RSET";
    private static final String LOGIN = "LOGIN";

    // Same headers that SMTPTransport passes to MimeMessage.writeTo().
    private static final String[] IGNORE_HEADERS = new String[] { "Bcc", "Resent-Bcc", "Content-Length" };

    private Set<String> invalidRecipients = new TreeSet<String>(String.CASE_INSENSITIVE_ORDER);
    private Set<String> validRecipients = new TreeSet<String>(String.CASE_INSENSITIVE_ORDER);
    private Set<String> serverAuthMechanisms = new HashSet<String>();
    private Set<String> serverExtensions = new HashSet<String>();

    public SmtpConnection(SmtpConfig config) {
        super(config);
        Preconditions.checkNotNull(config.getHost());
        Preconditions.checkArgument(config.getPort() >= 0);
    }

    public Set<String> getValidRecipients() {
        return Collections.unmodifiableSet(validRecipients);
    }

    public Set<String> getInvalidRecipients() {
        return Collections.unmodifiableSet(invalidRecipients);
    }

    /**
     * Transform message data transmission.
     * <ul>
     *  <li>Bare CR or LF characters are converted to {@code <CRLF>}.
     *  See RFC 2821 2.3.7 Lines.
     *  <li>If the first character of the line is a period, one additional
     *  period is inserted at the beginning of the line.
     *  See RFC 2821 4.5.2 Transparency.
     * </ul>
     */
    private static final class SmtpDataOutputStream extends FilterOutputStream {

        private int last = -1;

        private SmtpDataOutputStream(OutputStream out) {
            super(out);
        }

        @Override
        public void write(int b) throws IOException {
            switch (b) {
            case '\r':
                crlf();
                break;
            case '\n':
                if (last != '\r') {
                    crlf();
                }
                break;
            case '.':
                switch (last) {
                case '\r':
                case '\n':
                case -1:
                    dot();
                    break;
                }
                dot();
                break;
            default:
                super.write(b);
                break;
            }
            last = b;
        }

        /**
         * End of data.
         * <p>
         * RFC 2822 4.1.1.4 DATA (DATA)
         * <p>
         * The mail data is terminated by a line containing only a period, that
         * is, the character sequence {@code <CRLF>.<CRLF>}. This is the end of
         * mail data indication. Note that the first {@code <CRLF>} of this
         * terminating sequence is also the {@code <CRLF>} that ends the final
         * line of the data (message text) or, if there was no data, ends the
         * DATA command itself. An extra {@code <CRLF>} MUST NOT be added, as
         * that would cause an empty line to be added to the message. The only
         * exception to this rule would arise if the message body were passed to
         * the originating SMTP-sender with a final "line" that did not end in
         * {@code <CRLF>}; in that case, the originating SMTP system MUST either
         * reject the message as invalid or add {@code <CRLF>} in order to have
         * the receiving SMTP server recognize the "end of data" condition.
         */
        public void end() throws IOException {
            switch (last) {
            case '\r':
            case '\n':
            case -1:
                break;
            default:
                crlf();
                break;
            }
            dot();
            crlf();
        }

        private void dot() throws IOException {
            super.write('.');
        }

        private void crlf() throws IOException {
            super.write('\r');
            super.write('\n');
        }

        /**
         * For efficiency, don't flush until the last {@code <CRLF>.<CRLF>}.
         */
        @Override
        public void flush() {
        }
    }

    @Override
    protected MailInputStream newMailInputStream(InputStream is) {
        if (getLogger().isTraceEnabled()) {
            return new MailInputStream(is, getLogger());
        } else {
            return new MailInputStream(is);
        }
    }

    @Override
    protected MailOutputStream newMailOutputStream(OutputStream os) {
        if (getLogger().isTraceEnabled()) {
            return new MailOutputStream(os, getLogger());
        } else {
            return new MailOutputStream(os);
        }
    }

    @Override
    protected void processGreeting() throws IOException {
        // Server greeting, can be multiline.
        while (true) {
            Reply reply = Reply.parse(mailIn.readLine());
            mailIn.trace();
            if (reply == null) {
                throw new MailException("Did not receive greeting from server");
            }
            if (reply.code != 220) {
                throw new IOException("Expected greeting, but got: " + reply);
            }
            if (reply.last) {
                break;
            }
        }

        // Send hello, read extensions and auth mechanisms.
        if (ehlo() != 250) {
            helo();
        }

        // check auth extensions now if starttls is not being used
        if (config.getSecurity() != MailConfig.Security.TLS_IF_AVAILABLE || !serverExtensions.contains(STARTTLS)) {
            checkAuthExtensions();
        }

        setState(State.NOT_AUTHENTICATED);
    }

    /**
     * Sends the {@code EHLO} command and processes the reply.
     *
     * @return the server reply code
     * @throws IOException if the server response was invalid
     */
    private int ehlo() throws IOException {
        Reply reply = sendCommand(EHLO, getSmtpConfig().getDomain());
        return readHelloReplies(EHLO, reply);
    }

    /**
     * Sends the {@code HELO} command and processes the reply.
     *
     * @throws IOException if the server did not respond with reply code {@code 250}
     */
    private void helo() throws IOException {
        Reply reply = sendCommand(HELO, getSmtpConfig().getDomain());
        int replyCode = readHelloReplies(HELO, reply);
        if (replyCode != 250) {
            throw new CommandFailedException(HELO, reply.toString());
        }
    }

    private static final Pattern PAT_EXTENSION = Pattern.compile("([^\\s]+)(.*)");

    /**
     * Reads replies to {@code EHLO} or {@code HELO}.
     * <p>
     * Returns the reply code.  Handles multiple {@code 250} responses.
     *
     * @param command the command name
     * @param firstReply the first reply line returned from sending {@code EHLO} or {@code HELO}
     */
    private int readHelloReplies(String command, Reply firstReply) throws IOException {

        Reply reply = firstReply;
        int line = 1;
        serverExtensions.clear();
        serverAuthMechanisms.clear();

        while (true) {
            if (reply.text == null) {
                throw new CommandFailedException(command, "Invalid server response at line " + line + ": " + reply);
            }
            if (reply.code != 250) {
                return reply.code;
            }

            if (line > 1) {
                // Parse server extensions.
                Matcher matcher = PAT_EXTENSION.matcher(reply.text);
                if (matcher.matches()) {
                    String extName = matcher.group(1).toUpperCase();
                    serverExtensions.add(extName);
                    if (extName.equals(AUTH)) {
                        // Parse auth mechanisms.
                        Splitter splitter = Splitter.on(CharMatcher.WHITESPACE).trimResults().omitEmptyStrings();
                        for (String mechanism : splitter.split(matcher.group(2))) {
                            serverAuthMechanisms.add(mechanism.toUpperCase());
                        }
                    }
                }
            }
            if (reply.last) { // Last 250 response.
                mailIn.trace();
                return 250;
            } else { // Multiple response lines.
                reply = Reply.parse(mailIn.readLine());
            }
            line++;
        }
    }

    private void checkAuthExtensions() throws MailException {
        if (config.getAuthenticationId() == null) {
            return;
        }
        if (!serverExtensions.contains(AUTH)) {
            throw new MailException("The server doesn't support SMTP-AUTH.");
        }
        String mech = config.getMechanism();
        if (mech != null) {
            if (!serverAuthMechanisms.contains(mech.toUpperCase())) {
                throw new MailException(
                        "Auth mechanism mismatch client=" + mech + ",server=" + serverAuthMechanisms);
            }
        } else {
            if (serverAuthMechanisms.contains(LOGIN)) {
                config.setMechanism(LOGIN);
            } else if (serverAuthMechanisms.contains(SaslAuthenticator.PLAIN)) {
                config.setMechanism(SaslAuthenticator.PLAIN);
            } else if (serverAuthMechanisms.contains(SaslAuthenticator.CRAM_MD5)) {
                config.setMechanism(SaslAuthenticator.CRAM_MD5);
            } else if (serverAuthMechanisms.contains(SaslAuthenticator.DIGEST_MD5)) {
                config.setMechanism(SaslAuthenticator.DIGEST_MD5);
            } else if (serverAuthMechanisms.contains(SaslAuthenticator.XOAUTH2)) {
                config.setMechanism(SaslAuthenticator.XOAUTH2);
            } else {
                throw new MailException("No auth mechanism supported: " + serverAuthMechanisms);
            }
        }
    }

    @Override
    protected void sendLogin(String user, String pass) throws IOException {
        // Send AUTH LOGIN command.
        Reply reply = sendCommand(AUTH, LOGIN);
        if (reply.code != 334) {
            throw new CommandFailedException(AUTH, reply.toString());
        }

        // Send username.
        reply = sendCommand(Base64.encodeBase64(user.getBytes()), null);
        if (reply.code != 334) {
            if (reply.isPositive()) {
                return;
            } else {
                throw new CommandFailedException(AUTH, "AUTH LOGIN failed: " + reply);
            }
        }

        // Send password.
        reply = sendCommand(Base64.encodeBase64(pass.getBytes()), null);
        if (!reply.isPositive()) {
            throw new CommandFailedException(AUTH, "AUTH LOGIN failed: " + reply);
        }
    }

    @Override
    protected void sendAuthenticate(boolean ir) throws IOException {
        Reply reply;
        if (authenticator.hasInitialResponse()) {
            reply = sendCommand(AUTH, authenticator.getMechanism() + ' '
                    + Ascii.toString(Base64.encodeBase64(authenticator.getInitialResponse())));
        } else {
            reply = sendCommand(AUTH, authenticator.getMechanism());
        }

        while (true) {
            switch (reply.code) {
            case 235: // success
                if (authenticator.isComplete()) {
                    return;
                } else {
                    throw new SaslException("SASL client auth not complete yet S: " + reply.toString());
                }
            case 334: // continue
                byte[] challenge = Strings.isNullOrEmpty(reply.text) ? new byte[0]
                        : Base64.decodeBase64(reply.text);
                byte[] response = authenticator.evaluateChallenge(challenge);
                if (response != null) {
                    reply = sendCommand(Ascii.toString(Base64.encodeBase64(response)), null);
                } else {
                    reply = sendCommand("", null);
                }
                continue;
            default:
                throw new CommandFailedException(AUTH, reply.toString());
            }
        }
    }

    @Override
    public void logout() {
    }

    /**
     * Overrides the superclass implementation, in order to send {@code EHLO}
     * and reread the server extension list.
     */
    @Override
    protected void startTls() throws IOException {
        super.startTls();
        if (ehlo() != 250) {
            helo();
        }
        checkAuthExtensions();
    }

    @Override
    protected void sendStartTls() throws IOException {
        sendCommand(STARTTLS, null);
    }

    private String getSender(MimeMessage msg) throws MessagingException {
        String sender = null;
        if (msg instanceof SMTPMessage) {
            sender = ((SMTPMessage) msg).getEnvelopeFrom();
            if (sender != null && sender.length() >= 2 && sender.startsWith("<") && sender.endsWith(">")) {
                // Strip brackets.
                sender = sender.substring(1, sender.length() - 1);
            }
        }
        if (sender == null) {
            Address[] fromAddrs = msg.getFrom();
            if (fromAddrs != null && fromAddrs.length > 0) {
                sender = getAddress(fromAddrs[0]);
            }
        }
        return sender;
    }

    private String[] toString(Address[] addrs) {
        List<String> result = new ArrayList<String>();
        for (Address addr : addrs) {
            String str = getAddress(addr);
            if (!Strings.isNullOrEmpty(str)) {
                result.add(str);
            }
        }
        return Iterables.toArray(result, String.class);
    }

    private String getAddress(Address address) {
        if (address instanceof InternetAddress) {
            return ((InternetAddress) address).getAddress();
        } else {
            return null;
        }
    }

    /**
     * Sends the message.
     * <p>
     * Uses the sender and recipients from the headers in the {@link MimeMessage}.
     *
     * @see #sendMessage(String, String[], MimeMessage)
     */
    public void sendMessage(MimeMessage msg) throws IOException, MessagingException {
        sendMessage(getSender(msg), toString(msg.getAllRecipients()), msg);
    }

    /**
     * Sends the message.
     * <p>
     * Uses the sender from the headers in the {@link MimeMessage}.
     *
     * @see #sendMessage(String, String[], MimeMessage)
     */
    void sendMessage(Address[] rcpts, MimeMessage msg) throws IOException, MessagingException {
        sendMessage(getSender(msg), toString(rcpts), msg);
    }

    /**
     * Sends the message.
     *
     * @see #sendMessage(String, String[], MimeMessage)
     */
    void sendMessage(String sender, Address[] rcpts, MimeMessage msg) throws IOException, MessagingException {
        sendMessage(sender, toString(rcpts), msg);
    }

    /**
     * Sends the message.
     * <p>
     * Implicitly connects to the MTA, if necessary, and disconnects.
     *
     * @param sender envelope from
     * @param rcpts envelope recipients
     * @param msg message to send
     * @throws IOException SMTP error
     * @throws MessagingException MIME serialization error
     */
    public void sendMessage(String sender, String[] rcpts, MimeMessage msg) throws IOException, MessagingException {
        connect();
        try {
            sendInternal(sender, rcpts, msg, null);
        } finally {
            quit();
        }
    }

    /**
     * Sends the message.
     * <p>
     * Implicitly connects to the MTA, if necessary, and disconnects.
     *
     * @param sender envelope from
     * @param rcpts envelope recipients
     * @param msg message to send
     * @throws IOException SMTP error
     * @throws MessagingException MIME serialization error
     */
    public void sendMessage(String sender, String[] rcpts, String msg) throws IOException, MessagingException {
        connect();
        try {
            sendInternal(sender, rcpts, null, msg);
        } finally {
            quit();
        }
    }

    /**
     * Return notification options as an RFC 1891 string.
     * Returns null if no options set.
     */
    private String getDSNNotify(SMTPMessage message) {
        if (message.getNotifyOptions() == 0)
            return null;
        if (message.getNotifyOptions() == SMTPMessage.NOTIFY_NEVER)
            return "NEVER";
        StringBuffer sb = new StringBuffer();
        if ((message.getNotifyOptions() & SMTPMessage.NOTIFY_SUCCESS) != 0)
            sb.append("SUCCESS");
        if ((message.getNotifyOptions() & SMTPMessage.NOTIFY_FAILURE) != 0) {
            if (sb.length() != 0)
                sb.append(',');
            sb.append("FAILURE");
        }
        if ((message.getNotifyOptions() & SMTPMessage.NOTIFY_DELAY) != 0) {
            if (sb.length() != 0)
                sb.append(',');
            sb.append("DELAY");
        }
        return sb.toString();
    }

    private void sendInternal(String sender, String[] recipients, MimeMessage javaMailMessage, String messageString)
            throws IOException, MessagingException {

        invalidRecipients.clear();
        validRecipients.clear();
        Collections.addAll(validRecipients, recipients);

        mail(sender);

        String notify = null;
        if (serverExtensions.contains("DSN")) {
            if (javaMailMessage instanceof SMTPMessage)
                notify = getDSNNotify((SMTPMessage) javaMailMessage);
            if (notify == null)
                notify = getSmtpConfig().getDsn();
        }

        rcpt(recipients, notify);
        Reply reply = sendCommand(DATA, null);
        if (reply.code != 354) {
            throw new CommandFailedException(DATA, reply.toString());
        }

        SmtpDataOutputStream smtpData = new SmtpDataOutputStream(mailOut);
        try {
            if (javaMailMessage != null) {
                javaMailMessage.writeTo(smtpData, IGNORE_HEADERS);
            } else {
                smtpData.write(messageString.getBytes());
            }
        } catch (MessagingException e) { // close without QUIT
            close();
            throw e;
        } catch (IOException e) { // close without QUIT
            close();
            throw e;
        }
        smtpData.end();
        mailOut.flush();
        mailOut.trace();
        reply = Reply.parse(mailIn.readLine());
        mailIn.trace();
        if (reply == null) {
            throw new CommandFailedException(DATA, "No response");
        }
        if (!reply.isPositive()) {
            throw new CommandFailedException(DATA, reply.toString());
        }
    }

    /**
     * Sends the given command and returns the first line from the server reply.
     *
     * @throws CommandFailedException if the server did not respond
     */
    private Reply sendCommand(String command, String args) throws IOException {
        return sendCommand(command.getBytes(), args);
    }

    /**
     * Sends the given command and returns the first line from the server reply.
     *
     * @throws CommandFailedException if the server did not respond
     */
    private Reply sendCommand(byte[] command, String args) throws IOException {
        mailOut.write(command);
        if (!Strings.isNullOrEmpty(args)) {
            mailOut.write(' ');
            mailOut.write(args);
        }
        mailOut.newLine();
        mailOut.flush();
        mailOut.trace();
        Reply reply = Reply.parse(mailIn.readLine());
        mailIn.trace();
        if (reply == null) {
            throw new CommandFailedException(new String(command), "No response from server");
        }
        return reply;
    }

    /**
     * Sends {@code MAIL FROM} command to the server.
     *
     * @param from sender address
     * @throws CommandFailedException SMTP error
     * @throws IOException socket error
     */
    void mail(String from) throws IOException {
        if (from == null) {
            from = "";
        }
        Reply reply = sendCommand(MAIL, "FROM:" + normalizeAddress(from));
        if (!reply.isPositive()) {
            validRecipients.clear();
            throw new CommandFailedException(MAIL, reply.toString());
        }
    }

    private String normalizeAddress(String addr) {
        if ((!addr.startsWith("<")) && (!addr.endsWith(">"))) {
            return "<" + addr + ">";
        } else {
            return addr;
        }
    }

    /**
     * Sends {@code RSET} command to the server.
     *
     * @throws CommandFailedException SMTP error
     * @throws IOException socket error
     */
    void rset() throws IOException {
        Reply reply = sendCommand(RSET, null);
        if (!reply.isPositive()) {
            throw new CommandFailedException(RSET, reply.toString());
        }
    }

    private void rcpt(String[] recipients, String dsn) throws IOException {
        for (String recipient : recipients) {
            if (recipient == null) {
                recipient = "";
            }
            String cmd = "TO:" + normalizeAddress(recipient);
            if (dsn != null)
                cmd += " NOTIFY=" + dsn;
            Reply reply = sendCommand(RCPT, cmd);
            if (!reply.isPositive()) {
                validRecipients.remove(recipient);
                invalidRecipients.add(recipient);
                if (!getSmtpConfig().isPartialSendAllowed()) {
                    throw new InvalidRecipientException(recipient, reply.toString());
                }
            }
        }
        if (validRecipients.isEmpty()) {
            throw new CommandFailedException(RCPT, "No valid recipients");
        }
    }

    private void quit() throws IOException {
        if (isClosed()) {
            return;
        }
        try {
            sendCommand(QUIT, null);
        } catch (CommandFailedException e) { // no reason to make it an error
            getLogger().warn(e.getMessage());
        } finally {
            close();
        }
    }

    private SmtpConfig getSmtpConfig() {
        return (SmtpConfig) config;
    }

    private static final class Reply {
        final int code;
        String text;
        boolean last;

        private Reply(String line) throws IOException {
            if (line == null || line.length() < 3) {
                throw new IOException("Invalid server response: '" + line + "'");
            }
            try {
                code = Integer.parseInt(line.substring(0, 3));
            } catch (NumberFormatException e) {
                throw new IOException("Invalid server response: '" + line + "'");
            }
            if (line.length() > 4) {
                switch (line.charAt(3)) {
                case '-':
                    last = false;
                    break;
                case ' ':
                    last = true;
                    break;
                default:
                    throw new IOException("Invalid server response: '" + line + "'");
                }
                text = line.substring(4).trim();
            }
        }

        static Reply parse(String line) throws IOException {
            if (Strings.isNullOrEmpty(line)) {
                return null;
            } else {
                return new Reply(line);
            }
        }

        /**
         * Returns true if the reply code is between {@code 200} and {@code 299}.
         */
        boolean isPositive() {
            return 200 <= code && code <= 299;
        }

        @Override
        public String toString() {
            return text == null ? String.valueOf(code) : (code + " " + text);
        }
    }

}