SmtpSender.java revision 96c5af40d639d629267794f4f0338a267ff94ce5
1/*
2 * Copyright (C) 2008 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.email.mail.transport;
18
19import com.android.email.Email;
20import com.android.email.codec.binary.Base64;
21import com.android.email.mail.Address;
22import com.android.email.mail.AuthenticationFailedException;
23import com.android.email.mail.CertificateValidationException;
24import com.android.email.mail.Message;
25import com.android.email.mail.MessagingException;
26import com.android.email.mail.Sender;
27import com.android.email.mail.Transport;
28import com.android.email.mail.Message.RecipientType;
29
30import android.util.Config;
31import android.util.Log;
32
33import java.io.BufferedOutputStream;
34import java.io.IOException;
35import java.net.InetAddress;
36import java.net.URI;
37import java.net.URISyntaxException;
38
39import javax.net.ssl.SSLException;
40
41/**
42 * This class handles all of the protocol-level aspects of sending messages via SMTP.
43 */
44public class SmtpSender extends Sender {
45
46    public static final int CONNECTION_SECURITY_NONE = 0;
47    public static final int CONNECTION_SECURITY_TLS_OPTIONAL = 1;
48    public static final int CONNECTION_SECURITY_TLS_REQUIRED = 2;
49    public static final int CONNECTION_SECURITY_SSL_REQUIRED = 3;
50    public static final int CONNECTION_SECURITY_SSL_OPTIONAL = 4;
51
52    private Transport mTransport;
53    String mUsername;
54    String mPassword;
55
56    /**
57     * Allowed formats for the Uri:
58     * smtp://user:password@server:port CONNECTION_SECURITY_NONE
59     * smtp+tls://user:password@server:port CONNECTION_SECURITY_TLS_OPTIONAL
60     * smtp+tls+://user:password@server:port CONNECTION_SECURITY_TLS_REQUIRED
61     * smtp+ssl+://user:password@server:port CONNECTION_SECURITY_SSL_REQUIRED
62     * smtp+ssl://user:password@server:port CONNECTION_SECURITY_SSL_OPTIONAL
63     *
64     * @param uriString the Uri containing information to configure this sender
65     */
66    public SmtpSender(String uriString) throws MessagingException {
67        URI uri;
68        try {
69            uri = new URI(uriString);
70        } catch (URISyntaxException use) {
71            throw new MessagingException("Invalid SmtpTransport URI", use);
72        }
73
74        String scheme = uri.getScheme();
75        int connectionSecurity = Transport.CONNECTION_SECURITY_NONE;
76        int defaultPort = -1;
77        if (scheme.equals("smtp")) {
78            connectionSecurity = CONNECTION_SECURITY_NONE;
79            defaultPort = 25;
80        } else if (scheme.equals("smtp+tls")) {
81            connectionSecurity = CONNECTION_SECURITY_TLS_OPTIONAL;
82            defaultPort = 25;
83        } else if (scheme.equals("smtp+tls+")) {
84            connectionSecurity = CONNECTION_SECURITY_TLS_REQUIRED;
85            defaultPort = 25;
86        } else if (scheme.equals("smtp+ssl+")) {
87            connectionSecurity = CONNECTION_SECURITY_SSL_REQUIRED;
88            defaultPort = 465;
89        } else if (scheme.equals("smtp+ssl")) {
90            connectionSecurity = CONNECTION_SECURITY_SSL_OPTIONAL;
91            defaultPort = 465;
92        } else {
93            throw new MessagingException("Unsupported protocol");
94        }
95
96        mTransport = new MailTransport("SMTP");
97        mTransport.setUri(uri, defaultPort);
98        mTransport.setSecurity(connectionSecurity);
99
100        String[] userInfoParts = mTransport.getUserInfoParts();
101        if (userInfoParts != null) {
102            mUsername = userInfoParts[0];
103            if (userInfoParts.length > 1) {
104                mPassword = userInfoParts[1];
105            }
106        }
107    }
108
109    /**
110     * For testing only.  Injects a different transport.  The transport should already be set
111     * up and ready to use.  Do not use for real code.
112     * @param testTransport The Transport to inject and use for all future communication.
113     */
114    /* package */ void setTransport(Transport testTransport) {
115        mTransport = testTransport;
116    }
117
118    public void open() throws MessagingException {
119        try {
120            mTransport.open();
121
122            // Eat the banner
123            executeSimpleCommand(null);
124
125            String localHost = "localhost";
126            try {
127                InetAddress localAddress = InetAddress.getLocalHost();
128                localHost = localAddress.getHostName();
129            } catch (Exception e) {
130                if (Config.LOGD && Email.DEBUG) {
131                    Log.d(Email.LOG_TAG, "Unable to look up localhost");
132                }
133            }
134
135            String result = executeSimpleCommand("EHLO " + localHost);
136
137            /*
138             * TODO may need to add code to fall back to HELO I switched it from
139             * using HELO on non STARTTLS connections because of AOL's mail
140             * server. It won't let you use AUTH without EHLO.
141             * We should really be paying more attention to the capabilities
142             * and only attempting auth if it's available, and warning the user
143             * if not.
144             */
145            if (mTransport.canTryTlsSecurity()) {
146                if (result.contains("-STARTTLS")) {
147                    executeSimpleCommand("STARTTLS");
148                    mTransport.reopenTls();
149                    /*
150                     * Now resend the EHLO. Required by RFC2487 Sec. 5.2, and more specifically,
151                     * Exim.
152                     */
153                    result = executeSimpleCommand("EHLO " + localHost);
154                } else if (mTransport.getSecurity() ==
155                        Transport.CONNECTION_SECURITY_TLS_REQUIRED) {
156                    if (Config.LOGD && Email.DEBUG) {
157                        Log.d(Email.LOG_TAG, "TLS not supported but required");
158                    }
159                    throw new MessagingException(MessagingException.TLS_REQUIRED);
160                }
161            }
162
163            /*
164             * result contains the results of the EHLO in concatenated form
165             */
166            boolean authLoginSupported = result.matches(".*AUTH.*LOGIN.*$");
167            boolean authPlainSupported = result.matches(".*AUTH.*PLAIN.*$");
168
169            if (mUsername != null && mUsername.length() > 0 && mPassword != null
170                    && mPassword.length() > 0) {
171                if (authPlainSupported) {
172                    saslAuthPlain(mUsername, mPassword);
173                }
174                else if (authLoginSupported) {
175                    saslAuthLogin(mUsername, mPassword);
176                }
177                else {
178                    if (Config.LOGD && Email.DEBUG) {
179                        Log.d(Email.LOG_TAG, "No valid authentication mechanism found.");
180                    }
181                    throw new MessagingException(MessagingException.AUTH_REQUIRED);
182                }
183            }
184        } catch (SSLException e) {
185            if (Config.LOGD && Email.DEBUG) {
186                Log.d(Email.LOG_TAG, e.toString());
187            }
188            throw new CertificateValidationException(e.getMessage(), e);
189        } catch (IOException ioe) {
190            if (Config.LOGD && Email.DEBUG) {
191                Log.d(Email.LOG_TAG, ioe.toString());
192            }
193            throw new MessagingException(MessagingException.IOERROR, ioe.toString());
194        }
195    }
196
197    public void sendMessage(Message message) throws MessagingException {
198        close();
199        open();
200        Address[] from = message.getFrom();
201
202        try {
203            executeSimpleCommand("MAIL FROM: " + "<" + from[0].getAddress() + ">");
204            for (Address address : message.getRecipients(RecipientType.TO)) {
205                executeSimpleCommand("RCPT TO: " + "<" + address.getAddress() + ">");
206            }
207            for (Address address : message.getRecipients(RecipientType.CC)) {
208                executeSimpleCommand("RCPT TO: " + "<" + address.getAddress() + ">");
209            }
210            for (Address address : message.getRecipients(RecipientType.BCC)) {
211                executeSimpleCommand("RCPT TO: " + "<" + address.getAddress() + ">");
212            }
213            message.setRecipients(RecipientType.BCC, null);
214            executeSimpleCommand("DATA");
215            // TODO byte stuffing
216            // TODO most of the MIME writeTo functions layer on *additional* buffering
217            // streams, making this one possibly not-necessary.  Need to get to the bottom
218            // of that.
219            // TODO Also, need to be absolutely positively sure that flush() is called
220            // on the wrappered outputs before sending the final \r\n via the regular mOut.
221            message.writeTo(
222                    new EOLConvertingOutputStream(
223                            new BufferedOutputStream(mTransport.getOutputStream(), 1024)));
224            executeSimpleCommand("\r\n.");
225        } catch (IOException ioe) {
226            throw new MessagingException("Unable to send message", ioe);
227        }
228    }
229
230    /**
231     * Close the protocol (and the transport below it).
232     *
233     * MUST NOT return any exceptions.
234     */
235    public void close() {
236        mTransport.close();
237    }
238
239    /**
240     * Send a single command and wait for a single response.  Handles responses that continue
241     * onto multiple lines.  Throws MessagingException if response code is 4xx or 5xx.  All traffic
242     * is logged (if debug logging is enabled) so do not use this function for user ID or password.
243     *
244     * @param command The command string to send to the server.
245     * @return Returns the response string from the server.
246     */
247    private String executeSimpleCommand(String command) throws IOException, MessagingException {
248        return executeSensitiveCommand(command, null);
249    }
250
251    /**
252     * Send a single command and wait for a single response.  Handles responses that continue
253     * onto multiple lines.  Throws MessagingException if response code is 4xx or 5xx.
254     *
255     * @param command The command string to send to the server.
256     * @param sensitiveReplacement If the command includes sensitive data (e.g. authentication)
257     * please pass a replacement string here (for logging).
258     * @return Returns the response string from the server.
259     */
260    private String executeSensitiveCommand(String command, String sensitiveReplacement)
261            throws IOException, MessagingException {
262        if (command != null) {
263            mTransport.writeLine(command, sensitiveReplacement);
264        }
265
266        String line = mTransport.readLine();
267
268        String result = line;
269
270        while (line.length() >= 4 && line.charAt(3) == '-') {
271            line = mTransport.readLine();
272            result += line.substring(3);
273        }
274
275        char c = result.charAt(0);
276        if ((c == '4') || (c == '5')) {
277            throw new MessagingException(result);
278        }
279
280        return result;
281    }
282
283
284//    C: AUTH LOGIN
285//    S: 334 VXNlcm5hbWU6
286//    C: d2VsZG9u
287//    S: 334 UGFzc3dvcmQ6
288//    C: dzNsZDBu
289//    S: 235 2.0.0 OK Authenticated
290//
291//    Lines 2-5 of the conversation contain base64-encoded information. The same conversation, with base64 strings decoded, reads:
292//
293//
294//    C: AUTH LOGIN
295//    S: 334 Username:
296//    C: weldon
297//    S: 334 Password:
298//    C: w3ld0n
299//    S: 235 2.0.0 OK Authenticated
300
301    private void saslAuthLogin(String username, String password) throws MessagingException,
302        AuthenticationFailedException, IOException {
303        try {
304            executeSimpleCommand("AUTH LOGIN");
305            executeSensitiveCommand(new String(Base64.encodeBase64(username.getBytes())),
306                    "/username redacted/");
307            executeSensitiveCommand(new String(Base64.encodeBase64(password.getBytes())),
308                    "/password redacted/");
309        }
310        catch (MessagingException me) {
311            if (me.getMessage().length() > 1 && me.getMessage().charAt(1) == '3') {
312                throw new AuthenticationFailedException(me.getMessage());
313            }
314            throw me;
315        }
316    }
317
318    private void saslAuthPlain(String username, String password) throws MessagingException,
319            AuthenticationFailedException, IOException {
320        byte[] data = ("\000" + username + "\000" + password).getBytes();
321        data = new Base64().encode(data);
322        try {
323            executeSensitiveCommand("AUTH PLAIN " + new String(data), "AUTH PLAIN /redacted/");
324        }
325        catch (MessagingException me) {
326            if (me.getMessage().length() > 1 && me.getMessage().charAt(1) == '3') {
327                throw new AuthenticationFailedException(me.getMessage());
328            }
329            throw me;
330        }
331    }
332}
333