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 android.content.Context;
20import android.util.Base64;
21
22import com.android.email.mail.Sender;
23import com.android.email.mail.internet.AuthenticationCache;
24import com.android.email2.ui.MailActivityEmail;
25import com.android.emailcommon.Logging;
26import com.android.emailcommon.internet.Rfc822Output;
27import com.android.emailcommon.mail.Address;
28import com.android.emailcommon.mail.AuthenticationFailedException;
29import com.android.emailcommon.mail.CertificateValidationException;
30import com.android.emailcommon.mail.MessagingException;
31import com.android.emailcommon.provider.Account;
32import com.android.emailcommon.provider.Credential;
33import com.android.emailcommon.provider.EmailContent.Message;
34import com.android.emailcommon.provider.HostAuth;
35import com.android.emailcommon.utility.EOLConvertingOutputStream;
36import com.android.mail.utils.LogUtils;
37
38import java.io.IOException;
39import java.net.Inet6Address;
40import java.net.InetAddress;
41
42import javax.net.ssl.SSLException;
43
44/**
45 * This class handles all of the protocol-level aspects of sending messages via SMTP.
46 */
47public class SmtpSender extends Sender {
48
49    private final Context mContext;
50    private MailTransport mTransport;
51    private Account mAccount;
52    private String mUsername;
53    private String mPassword;
54    private boolean mUseOAuth;
55
56    /**
57     * Static named constructor.
58     */
59    public static Sender newInstance(Account account, Context context) throws MessagingException {
60        return new SmtpSender(context, account);
61    }
62
63    /**
64     * Creates a new sender for the given account.
65     */
66    public SmtpSender(Context context, Account account) {
67        mContext = context;
68        mAccount = account;
69        HostAuth sendAuth = account.getOrCreateHostAuthSend(context);
70        mTransport = new MailTransport(context, "SMTP", sendAuth);
71        String[] userInfoParts = sendAuth.getLogin();
72        mUsername = userInfoParts[0];
73        mPassword = userInfoParts[1];
74        Credential cred = sendAuth.getCredential(context);
75        if (cred != null) {
76            mUseOAuth = true;
77        }
78    }
79
80    /**
81     * For testing only.  Injects a different transport.  The transport should already be set
82     * up and ready to use.  Do not use for real code.
83     * @param testTransport The Transport to inject and use for all future communication.
84     */
85    public void setTransport(MailTransport testTransport) {
86        mTransport = testTransport;
87    }
88
89    @Override
90    public void open() throws MessagingException {
91        try {
92            mTransport.open();
93
94            // Eat the banner
95            executeSimpleCommand(null);
96
97            String localHost = "localhost";
98            // Try to get local address in the proper format.
99            InetAddress localAddress = mTransport.getLocalAddress();
100            if (localAddress != null) {
101                // Address Literal formatted in accordance to RFC2821 Sec. 4.1.3
102                StringBuilder sb = new StringBuilder();
103                sb.append('[');
104                if (localAddress instanceof Inet6Address) {
105                    sb.append("IPv6:");
106                }
107                sb.append(localAddress.getHostAddress());
108                sb.append(']');
109                localHost = sb.toString();
110            }
111            String result = executeSimpleCommand("EHLO " + localHost);
112
113            /*
114             * TODO may need to add code to fall back to HELO I switched it from
115             * using HELO on non STARTTLS connections because of AOL's mail
116             * server. It won't let you use AUTH without EHLO.
117             * We should really be paying more attention to the capabilities
118             * and only attempting auth if it's available, and warning the user
119             * if not.
120             */
121            if (mTransport.canTryTlsSecurity()) {
122                if (result.contains("STARTTLS")) {
123                    executeSimpleCommand("STARTTLS");
124                    mTransport.reopenTls();
125                    /*
126                     * Now resend the EHLO. Required by RFC2487 Sec. 5.2, and more specifically,
127                     * Exim.
128                     */
129                    result = executeSimpleCommand("EHLO " + localHost);
130                } else {
131                    if (MailActivityEmail.DEBUG) {
132                        LogUtils.d(Logging.LOG_TAG, "TLS not supported but required");
133                    }
134                    throw new MessagingException(MessagingException.TLS_REQUIRED);
135                }
136            }
137
138            /*
139             * result contains the results of the EHLO in concatenated form
140             */
141            boolean authLoginSupported = result.matches(".*AUTH.*LOGIN.*$");
142            boolean authPlainSupported = result.matches(".*AUTH.*PLAIN.*$");
143            boolean authOAuthSupported = result.matches(".*AUTH.*XOAUTH2.*$");
144
145            if (mUseOAuth) {
146                if (!authOAuthSupported) {
147                    LogUtils.w(Logging.LOG_TAG, "OAuth requested, but not supported.");
148                    throw new MessagingException(MessagingException.OAUTH_NOT_SUPPORTED);
149                }
150                saslAuthOAuth(mUsername);
151            } else if (mUsername != null && mUsername.length() > 0 && mPassword != null
152                    && mPassword.length() > 0) {
153                if (authPlainSupported) {
154                    saslAuthPlain(mUsername, mPassword);
155                }
156                else if (authLoginSupported) {
157                    saslAuthLogin(mUsername, mPassword);
158                }
159                else {
160                    LogUtils.w(Logging.LOG_TAG, "No valid authentication mechanism found.");
161                    throw new MessagingException(MessagingException.AUTH_REQUIRED);
162                }
163            } else {
164                // It is acceptable to hvae no authentication at all for SMTP.
165            }
166        } catch (SSLException e) {
167            if (MailActivityEmail.DEBUG) {
168                LogUtils.d(Logging.LOG_TAG, e.toString());
169            }
170            throw new CertificateValidationException(e.getMessage(), e);
171        } catch (IOException ioe) {
172            if (MailActivityEmail.DEBUG) {
173                LogUtils.d(Logging.LOG_TAG, ioe.toString());
174            }
175            throw new MessagingException(MessagingException.IOERROR, ioe.toString());
176        }
177    }
178
179    @Override
180    public void sendMessage(long messageId) throws MessagingException {
181        close();
182        open();
183
184        Message message = Message.restoreMessageWithId(mContext, messageId);
185        if (message == null) {
186            throw new MessagingException("Trying to send non-existent message id="
187                    + Long.toString(messageId));
188        }
189        Address from = Address.firstAddress(message.mFrom);
190        Address[] to = Address.fromHeader(message.mTo);
191        Address[] cc = Address.fromHeader(message.mCc);
192        Address[] bcc = Address.fromHeader(message.mBcc);
193
194        try {
195            executeSimpleCommand("MAIL FROM:" + "<" + from.getAddress() + ">");
196            for (Address address : to) {
197                executeSimpleCommand("RCPT TO:" + "<" + address.getAddress().trim() + ">");
198            }
199            for (Address address : cc) {
200                executeSimpleCommand("RCPT TO:" + "<" + address.getAddress().trim() + ">");
201            }
202            for (Address address : bcc) {
203                executeSimpleCommand("RCPT TO:" + "<" + address.getAddress().trim() + ">");
204            }
205            executeSimpleCommand("DATA");
206            // TODO byte stuffing
207            Rfc822Output.writeTo(mContext, message,
208                    new EOLConvertingOutputStream(mTransport.getOutputStream()),
209                    false /* do not use smart reply */,
210                    false /* do not send BCC */,
211                    null  /* attachments are in the message itself */);
212            executeSimpleCommand("\r\n.");
213        } catch (IOException ioe) {
214            throw new MessagingException("Unable to send message", ioe);
215        }
216    }
217
218    /**
219     * Close the protocol (and the transport below it).
220     *
221     * MUST NOT return any exceptions.
222     */
223    @Override
224    public void close() {
225        mTransport.close();
226    }
227
228    /**
229     * Send a single command and wait for a single response.  Handles responses that continue
230     * onto multiple lines.  Throws MessagingException if response code is 4xx or 5xx.  All traffic
231     * is logged (if debug logging is enabled) so do not use this function for user ID or password.
232     *
233     * @param command The command string to send to the server.
234     * @return Returns the response string from the server.
235     */
236    private String executeSimpleCommand(String command) throws IOException, MessagingException {
237        return executeSensitiveCommand(command, null);
238    }
239
240    /**
241     * Send a single command and wait for a single response.  Handles responses that continue
242     * onto multiple lines.  Throws MessagingException if response code is 4xx or 5xx.
243     *
244     * @param command The command string to send to the server.
245     * @param sensitiveReplacement If the command includes sensitive data (e.g. authentication)
246     * please pass a replacement string here (for logging).
247     * @return Returns the response string from the server.
248     */
249    private String executeSensitiveCommand(String command, String sensitiveReplacement)
250            throws IOException, MessagingException {
251        if (command != null) {
252            mTransport.writeLine(command, sensitiveReplacement);
253        }
254
255        String line = mTransport.readLine(true);
256
257        String result = line;
258
259        while (line.length() >= 4 && line.charAt(3) == '-') {
260            line = mTransport.readLine(true);
261            result += line.substring(3);
262        }
263
264        if (result.length() > 0) {
265            char c = result.charAt(0);
266            if ((c == '4') || (c == '5')) {
267                throw new MessagingException(result);
268            }
269        }
270
271        return result;
272    }
273
274
275//    C: AUTH LOGIN
276//    S: 334 VXNlcm5hbWU6
277//    C: d2VsZG9u
278//    S: 334 UGFzc3dvcmQ6
279//    C: dzNsZDBu
280//    S: 235 2.0.0 OK Authenticated
281//
282//    Lines 2-5 of the conversation contain base64-encoded information. The same conversation, with base64 strings decoded, reads:
283//
284//
285//    C: AUTH LOGIN
286//    S: 334 Username:
287//    C: weldon
288//    S: 334 Password:
289//    C: w3ld0n
290//    S: 235 2.0.0 OK Authenticated
291
292    private void saslAuthLogin(String username, String password) throws MessagingException,
293        AuthenticationFailedException, IOException {
294        try {
295            executeSimpleCommand("AUTH LOGIN");
296            executeSensitiveCommand(
297                    Base64.encodeToString(username.getBytes(), Base64.NO_WRAP),
298                    "/username redacted/");
299            executeSensitiveCommand(
300                    Base64.encodeToString(password.getBytes(), Base64.NO_WRAP),
301                    "/password redacted/");
302        }
303        catch (MessagingException me) {
304            if (me.getMessage().length() > 1 && me.getMessage().charAt(1) == '3') {
305                throw new AuthenticationFailedException(me.getMessage());
306            }
307            throw me;
308        }
309    }
310
311    private void saslAuthPlain(String username, String password) throws MessagingException,
312            AuthenticationFailedException, IOException {
313        byte[] data = ("\000" + username + "\000" + password).getBytes();
314        data = Base64.encode(data, Base64.NO_WRAP);
315        try {
316            executeSensitiveCommand("AUTH PLAIN " + new String(data), "AUTH PLAIN /redacted/");
317        }
318        catch (MessagingException me) {
319            if (me.getMessage().length() > 1 && me.getMessage().charAt(1) == '3') {
320                throw new AuthenticationFailedException(me.getMessage());
321            }
322            throw me;
323        }
324    }
325
326    private void saslAuthOAuth(String username) throws MessagingException,
327            AuthenticationFailedException, IOException {
328        final AuthenticationCache cache = AuthenticationCache.getInstance();
329        String accessToken = cache.retrieveAccessToken(mContext, mAccount);
330        try {
331            saslAuthOAuth(username, accessToken);
332        } catch (AuthenticationFailedException e) {
333            accessToken = cache.refreshAccessToken(mContext, mAccount);
334            saslAuthOAuth(username, accessToken);
335        }
336    }
337
338    private void saslAuthOAuth(final String username, final String accessToken) throws IOException,
339            MessagingException {
340        final String authPhrase = "user=" + username + '\001' + "auth=Bearer " + accessToken +
341                '\001' + '\001';
342        byte[] data = Base64.encode(authPhrase.getBytes(), Base64.NO_WRAP);
343        try {
344            executeSensitiveCommand("AUTH XOAUTH2 " + new String(data),
345                    "AUTH XOAUTH2 /redacted/");
346        } catch (MessagingException me) {
347            if (me.getMessage().length() > 1 && me.getMessage().charAt(1) == '3') {
348                throw new AuthenticationFailedException(me.getMessage());
349            }
350            throw me;
351        }
352    }
353}
354