Home | History | Annotate | Download | only in transport
      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 
     17 package com.android.email.mail.transport;
     18 
     19 import android.content.Context;
     20 import android.util.Base64;
     21 
     22 import com.android.email.DebugUtils;
     23 import com.android.email.mail.Sender;
     24 import com.android.email.mail.internet.AuthenticationCache;
     25 import com.android.emailcommon.Logging;
     26 import com.android.emailcommon.internet.Rfc822Output;
     27 import com.android.emailcommon.mail.Address;
     28 import com.android.emailcommon.mail.AuthenticationFailedException;
     29 import com.android.emailcommon.mail.CertificateValidationException;
     30 import com.android.emailcommon.mail.MessagingException;
     31 import com.android.emailcommon.provider.Account;
     32 import com.android.emailcommon.provider.Credential;
     33 import com.android.emailcommon.provider.EmailContent.Message;
     34 import com.android.emailcommon.provider.HostAuth;
     35 import com.android.emailcommon.utility.EOLConvertingOutputStream;
     36 import com.android.mail.utils.LogUtils;
     37 
     38 import java.io.IOException;
     39 import java.net.Inet6Address;
     40 import java.net.InetAddress;
     41 
     42 import javax.net.ssl.SSLException;
     43 
     44 /**
     45  * This class handles all of the protocol-level aspects of sending messages via SMTP.
     46  */
     47 public 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 (DebugUtils.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 (DebugUtils.DEBUG) {
    168                 LogUtils.d(Logging.LOG_TAG, e.toString());
    169             }
    170             throw new CertificateValidationException(e.getMessage(), e);
    171         } catch (IOException ioe) {
    172             if (DebugUtils.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