();
- // try {
- // if (c.moveToFirst()) {
- // do{
- // Mailbox m = Mailbox.restoreFromId(mDatabase, c.getLong(ServerUploads.TO_MAILBOX_COLUMN));
- // String fn = c.getString(ServerUploads.FILENAME_COLUMN);
- // if (m != null) {
- // FileInputStream fi = null;
- // try {
- // fi = mContext.openFileInput(fn);
- // } catch (Exception e) {
- // logException(e);
- // }
- // if (fi != null) {
- // BufferedInputStream bin = new BufferedInputStream(fi);
- // mWriter.flush();
- // BufferedOutputStream bos = new BufferedOutputStream(mSocket.getOutputStream());
- // int len = fi.available();
- // byte[] buf;
- // try {
- // tag = writeCommand(mWriter, "append \"" + m.serverName + "\" (\\seen) {" + len + '}');
- // String line = in.readLine();
- // buf = new byte[CHUNK_SIZE];
- // if (line.startsWith("+")) {
- // userLog("append response: " + line);
- //
- // while (len > 0) {
- // int rlen = (len > CHUNK_SIZE) ? CHUNK_SIZE : len;
- // int rd = bin.read(buf, 0, rlen);
- // if (rd > 0) {
- // bos.write(buf, 0, rd);
- // } else if (rd < 0)
- // break;
- // len -= rd;
- // }
- //
- // bos.flush();
- // mWriter.write("\r\n");
- // mWriter.flush();
- // bin.close();
- // if (readResponse(in, tag).equals(IMAP_OK)) {
- // uploaded.add(c.getLong(ServerUploads.ID_COLUMN));
- // File f = mContext.getFileStreamPath(fn);
- // if (f.delete()) {
- // userLog("Upload file deleted: " + fn);
- // }
- // } else {
- // userLog("Append failed?");
- // uploaded.add(c.getLong(ServerUploads.ID_COLUMN));
- // }
- // } else {
- // userLog("Append failed: " + line);
- // uploaded.add(c.getLong(ServerUploads.ID_COLUMN));
- // }
- // } catch (Exception e) {
- // logException(e);
- // uploaded.add(c.getLong(ServerUploads.ID_COLUMN));
- // }
- //
- // buf = null;
- // if (m.name.equals(Mailbox.DRAFTS_NAME))
- // MailService.serviceRequest(m.id, 3000L);
- // } else {
- // userLog("Can't find file to upload, deleting upload record: " + fn);
- // uploaded.add(c.getLong(ServerUploads.ID_COLUMN));
- // }
- // }
- // } while (c.moveToNext());
- // }
- // } finally {
- // // Delete the upload records for those completed
- // for (Long id: uploaded) {
- // ServerUploads.deleteById(mDatabase, id);
- // }
- // c.close();
- // }
- // }
+ private void processUploads() {
+ Mailbox sentMailbox = Mailbox.restoreMailboxOfType(mContext, mAccountId, Mailbox.TYPE_SENT);
+ if (sentMailbox == null) {
+ // Nothing to do this time around; we'll check each time through the sync loop
+ return;
+ }
+ Cursor c = mResolver.query(Message.CONTENT_URI, Message.ID_COLUMN_PROJECTION,
+ MessageColumns.MAILBOX_KEY + "=? AND " + SyncColumns.SERVER_ID + " is null",
+ new String[] {Long.toString(sentMailbox.mId)}, null);
+ if (c != null) {
+ String sentMailboxServerId = sentMailbox.mServerId;
+ try {
+ // Upload these messages
+ while (c.moveToNext()) {
+ try {
+ doUpload(c.getLong(Message.ID_COLUMNS_ID_COLUMN), sentMailboxServerId);
+ } catch (IOException e) {
+ e.printStackTrace();
+ } catch (MessagingException e) {
+ e.printStackTrace();
+ }
+ }
+ } finally {
+ c.close();
+ }
+ }
+ }
+ private int[] getServerIds(String since) throws IOException {
+ String tag = writeCommand(mWriter, "uid search undeleted since " + since);
+
+ if (!readResponse(mReader, tag, "SEARCH").equals(IMAP_OK)) {
+ userLog("$$$ WHOA! Search failed? ");
+ return null;
+ }
+
+ userLog(">>> SEARCH RESULT");
+ String msgs;
+ Parser p;
+ if (mImapResponse.isEmpty()) {
+ return new int[0];
+ } else {
+ msgs = mImapResponse.get(0);
+ // Length of "* search"
+ p = new Parser(msgs, 8);
+ return p.gatherInts();
+ }
+ }
+
+ static private final int[] AUTO_WINDOW_VALUES = new int[] {
+ SyncWindow.SYNC_WINDOW_ALL, SyncWindow.SYNC_WINDOW_1_MONTH, SyncWindow.SYNC_WINDOW_2_WEEKS,
+ SyncWindow.SYNC_WINDOW_1_WEEK, SyncWindow.SYNC_WINDOW_3_DAYS};
+
+ /**
+ * Determine a sync window for this mailbox by trying different possibilities from among the
+ * allowed values (in AUTO_WINDOW_VALUES). We start testing with "all" unless there are more
+ * than AUTOMATIC_SYNC_WINDOW_LARGE_MAILBOX messages (we really don't want to load that many);
+ * otherwise, we start with one month. We'll pick any value that has fewer than
+ * AUTOMATIC_SYNC_WINDOW_MAX_MESSAGES messages (arbitrary, but reasonable)
+ * @return a reasonable sync window for this mailbox
+ * @throws IOException
+ */
+ private int getAutoSyncWindow() throws IOException {
+ int i = (mLastExists > AUTOMATIC_SYNC_WINDOW_LARGE_MAILBOX) ? 1 : 0;
+ for (; i < AUTO_WINDOW_VALUES.length; i++) {
+ int window = AUTO_WINDOW_VALUES[i];
+ long days = SyncWindow.toDays(window);
+ Date date = new Date(System.currentTimeMillis() - (days*DAYS));
+ String since = IMAP_DATE_FORMAT.format(date);
+ int msgCount = getServerIds(since).length;
+ if (msgCount < AUTOMATIC_SYNC_WINDOW_MAX_MESSAGES) {
+ return window;
+ }
+ }
+ return SyncWindow.SYNC_WINDOW_1_DAY;
+ }
+
+ /**
+ * Process our list of requested attachment loads
+ * @throws IOException
+ */
private void processRequests() throws IOException {
while (!mRequestQueue.isEmpty()) {
Request req = mRequestQueue.peek();
@@ -1792,33 +1861,39 @@ public class Imap2SyncService extends AbstractSyncService {
// Now, handle various requests
processRequests();
- long days;
- if (mMailbox.mSyncLookback == SyncWindow.SYNC_WINDOW_UNKNOWN) {
- days = 14;
- } else
- days = SyncWindow.toDays(mMailbox.mSyncLookback);
-
- long time = System.currentTimeMillis() - (days*DAYS);
- Date date = new Date(time);
- String since = IMAP_DATE_FORMAT.format(date);
- String tag = writeCommand(mWriter, "uid search undeleted since " + since);
-
- // TODO Handle multi-line search result (google)
- if (!readResponse(mReader, tag, "SEARCH").equals(IMAP_OK)) {
- userLog("$$$ WHOA! Search failed? ");
+ // We'll use 14 days as the "default"
+ long days = 14;
+ int lookback = mMailbox.mSyncLookback;
+ if (mMailbox.mType == Mailbox.TYPE_INBOX) {
+ lookback = mAccount.mSyncLookback;
+ }
+ if (lookback == SyncWindow.SYNC_WINDOW_AUTO) {
+ if (mLastExists >= 0) {
+ ContentValues values = new ContentValues();
+ lookback = getAutoSyncWindow();
+ Uri uri;
+ if (mMailbox.mType == Mailbox.TYPE_INBOX) {
+ values.put(AccountColumns.SYNC_LOOKBACK, lookback);
+ uri = ContentUris.withAppendedId(Account.CONTENT_URI, mAccountId);
+ } else {
+ values.put(MailboxColumns.SYNC_LOOKBACK, lookback);
+ uri = ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId);
+ }
+ mResolver.update(uri, values, null, null);
+ }
+ }
+ if (lookback != SyncWindow.SYNC_WINDOW_UNKNOWN) {
+ days = SyncWindow.toDays(lookback);
}
- userLog(">>> SEARCH RESULT");
- int[] serverList;
- String msgs;
- Parser p;
- if (mImapResponse.isEmpty()) {
- serverList = new int[0];
- } else {
- msgs = mImapResponse.get(0);
- //*** Magic number?
- p = new Parser(msgs, 8);
- serverList = p.gatherInts();
+ Date date = new Date(System.currentTimeMillis() - (days*DAYS));
+ String since = IMAP_DATE_FORMAT.format(date);
+ String tag;
+ int[] serverList = getServerIds(since);
+ if (serverList == null) {
+ // Do backoff; hope it works next time. Should never happen
+ mExitStatus = EXIT_IO_ERROR;
+ return;
}
Arrays.sort(serverList);
@@ -1831,7 +1906,7 @@ public class Imap2SyncService extends AbstractSyncService {
deviceList = null;
int cnt = loadList.size();
- // We load message headers 20 at a time at this point...
+ // We load message headers in batches
int idx= 1;
boolean loadedSome = false;
while (idx <= cnt) {
@@ -1875,6 +1950,8 @@ public class Imap2SyncService extends AbstractSyncService {
reconcileState(getFlaggedUidList(), since, "FLAGGED", "flagged",
MessageColumns.FLAG_FAVORITE, false);
+ processUploads();
+
// We're done if not pushing...
if (mMailbox.mSyncInterval != Mailbox.CHECK_INTERVAL_PUSH) {
mExitStatus = EXIT_DONE;
@@ -1899,97 +1976,151 @@ public class Imap2SyncService extends AbstractSyncService {
}
}
+ private void sendMail() {
+ long sentMailboxId = Mailbox.findMailboxOfType(mContext, mAccountId, Mailbox.TYPE_SENT);
+ if (sentMailboxId == Mailbox.NO_MAILBOX) {
+ // The user must choose a sent mailbox
+ mResolver.update(
+ ContentUris.withAppendedId(EmailContent.PICK_SENT_FOLDER_URI, mAccountId),
+ new ContentValues(), null, null);
+ }
+ Account account = Account.restoreAccountWithId(mContext, mAccountId);
+ if (account == null) {
+ return;
+ }
+ TrafficStats.setThreadStatsTag(TrafficFlags.getSmtpFlags(mContext, account));
+ // 1. Loop through all messages in the account's outbox
+ long outboxId = Mailbox.findMailboxOfType(mContext, account.mId, Mailbox.TYPE_OUTBOX);
+ if (outboxId == Mailbox.NO_MAILBOX) {
+ return;
+ }
+ Cursor c = mResolver.query(Message.CONTENT_URI, Message.ID_COLUMN_PROJECTION,
+ Message.MAILBOX_KEY + "=?", new String[] { Long.toString(outboxId) }, null);
+ ContentValues values = new ContentValues();
+ values.put(MessageColumns.MAILBOX_KEY, sentMailboxId);
+ try {
+ // 2. exit early
+ if (c.getCount() <= 0) {
+ return;
+ }
+
+ SmtpSender sender = new SmtpSender(mContext, account, mUserLog);
+
+ // 3. loop through the available messages and send them
+ while (c.moveToNext()) {
+ long messageId = -1;
+ try {
+ messageId = c.getLong(Message.ID_COLUMNS_ID_COLUMN);
+ // Don't send messages with unloaded attachments
+ if (Utility.hasUnloadedAttachments(mContext, messageId)) {
+ userLog("Can't send #" + messageId + "; unloaded attachments");
+ continue;
+ }
+ sender.sendMessage(messageId);
+ // Move to sent folder
+ mResolver.update(ContentUris.withAppendedId(Message.CONTENT_URI, messageId),
+ values, null, null);
+ } catch (MessagingException me) {
+ continue;
+ }
+ }
+ } finally {
+ c.close();
+ }
+ }
+
@Override
public void run() {
try {
- // If we've been stopped, we're done
- if (mStop) return;
+ // Check for Outbox (special "sync") and stopped
+ if (mMailbox.mType == Mailbox.TYPE_OUTBOX) {
+ sendMail();
+ mExitStatus = EXIT_DONE;
+ return;
+ } else if (mStop) {
+ return;
+ }
- // Whether or not we're the account mailbox
- try {
- if ((mMailbox == null) || (mAccount == null)) {
- return;
- } else {
- int trafficFlags = TrafficFlags.getSyncFlags(mContext, mAccount);
- TrafficStats.setThreadStatsTag(trafficFlags | TrafficFlags.DATA_EMAIL);
+ if ((mMailbox == null) || (mAccount == null)) {
+ return;
+ } else {
+ int trafficFlags = TrafficFlags.getSyncFlags(mContext, mAccount);
+ TrafficStats.setThreadStatsTag(trafficFlags | TrafficFlags.DATA_EMAIL);
- // We loop because someone might have put a request in while we were syncing
- // and we've missed that opportunity...
- do {
- if (mRequestTime != 0) {
- userLog("Looping for user request...");
- mRequestTime = 0;
- }
- if (mSyncReason >= Imap2SyncManager.SYNC_CALLBACK_START) {
- try {
- Imap2SyncManager.callback().syncMailboxStatus(mMailboxId,
- EmailServiceStatus.IN_PROGRESS, 0);
- } catch (RemoteException e1) {
- // Don't care if this fails
- }
- }
- sync();
- } while (mRequestTime != 0);
- }
- } catch (IOException e) {
- String message = e.getMessage();
- userLog("Caught IOException: ", (message == null) ? "No message" : message);
- mExitStatus = EXIT_IO_ERROR;
- } catch (Exception e) {
- userLog("Uncaught exception in EasSyncService", e);
- } finally {
- int status;
- Imap2SyncManager.done(this);
- if (!mStop) {
- userLog("Sync finished");
- switch (mExitStatus) {
- case EXIT_IO_ERROR:
- status = EmailServiceStatus.CONNECTION_ERROR;
- break;
- case EXIT_DONE:
- status = EmailServiceStatus.SUCCESS;
- ContentValues cv = new ContentValues();
- cv.put(Mailbox.SYNC_TIME, System.currentTimeMillis());
- String s = "S" + mSyncReason + ':' + status + ':' + mChangeCount;
- cv.put(Mailbox.SYNC_STATUS, s);
- mContext.getContentResolver().update(
- ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId),
- cv, null, null);
- break;
- case EXIT_LOGIN_FAILURE:
- status = EmailServiceStatus.LOGIN_FAILED;
- break;
- default:
- status = EmailServiceStatus.REMOTE_EXCEPTION;
- errorLog("Sync ended due to an exception.");
- break;
+ // We loop because someone might have put a request in while we were syncing
+ // and we've missed that opportunity...
+ do {
+ if (mRequestTime != 0) {
+ userLog("Looping for user request...");
+ mRequestTime = 0;
}
- } else {
- userLog("Stopped sync finished.");
+ if (mSyncReason >= Imap2SyncManager.SYNC_CALLBACK_START) {
+ try {
+ Imap2SyncManager.callback().syncMailboxStatus(mMailboxId,
+ EmailServiceStatus.IN_PROGRESS, 0);
+ } catch (RemoteException e1) {
+ // Don't care if this fails
+ }
+ }
+ sync();
+ } while (mRequestTime != 0);
+ }
+ } catch (IOException e) {
+ String message = e.getMessage();
+ userLog("Caught IOException: ", (message == null) ? "No message" : message);
+ mExitStatus = EXIT_IO_ERROR;
+ } catch (Exception e) {
+ userLog("Uncaught exception in EasSyncService", e);
+ } finally {
+ int status;
+ Imap2SyncManager.done(this);
+ if (!mStop) {
+ userLog("Sync finished");
+ switch (mExitStatus) {
+ case EXIT_IO_ERROR:
+ status = EmailServiceStatus.CONNECTION_ERROR;
+ break;
+ case EXIT_DONE:
+ status = EmailServiceStatus.SUCCESS;
+ ContentValues cv = new ContentValues();
+ cv.put(Mailbox.SYNC_TIME, System.currentTimeMillis());
+ String s = "S" + mSyncReason + ':' + status + ':' + mChangeCount;
+ cv.put(Mailbox.SYNC_STATUS, s);
+ mContext.getContentResolver().update(
+ ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId),
+ cv, null, null);
+ break;
+ case EXIT_LOGIN_FAILURE:
+ status = EmailServiceStatus.LOGIN_FAILED;
+ break;
+ default:
+ status = EmailServiceStatus.REMOTE_EXCEPTION;
+ errorLog("Sync ended due to an exception.");
+ break;
+ }
+ } else {
+ userLog("Stopped sync finished.");
+ status = EmailServiceStatus.SUCCESS;
+ }
+
+ // Send a callback (doesn't matter how the sync was started)
+ try {
+ // Unless the user specifically asked for a sync, we don't want to report
+ // connection issues, as they are likely to be transient. In this case, we
+ // simply report success, so that the progress indicator terminates without
+ // putting up an error banner
+ //***
+ if (mSyncReason != Imap2SyncManager.SYNC_UI_REQUEST &&
+ status == EmailServiceStatus.CONNECTION_ERROR) {
status = EmailServiceStatus.SUCCESS;
}
-
- // Send a callback (doesn't matter how the sync was started)
- try {
- // Unless the user specifically asked for a sync, we don't want to report
- // connection issues, as they are likely to be transient. In this case, we
- // simply report success, so that the progress indicator terminates without
- // putting up an error banner
- //***
- if (mSyncReason != Imap2SyncManager.SYNC_UI_REQUEST &&
- status == EmailServiceStatus.CONNECTION_ERROR) {
- status = EmailServiceStatus.SUCCESS;
- }
- Imap2SyncManager.callback().syncMailboxStatus(mMailboxId, status, 0);
- } catch (RemoteException e1) {
- // Don't care if this fails
- }
-
- // Make sure ExchangeService knows about this
- Imap2SyncManager.kick("sync finished");
+ Imap2SyncManager.callback().syncMailboxStatus(mMailboxId, status, 0);
+ } catch (RemoteException e1) {
+ // Don't care if this fails
}
- } catch (ProviderUnavailableException e) {
- Log.e(TAG, "EmailProvider unavailable; sync ended prematurely");
+
+ // Make sure ExchangeService knows about this
+ Imap2SyncManager.kick("sync finished");
}
}
diff --git a/imap2/src/com/android/imap2/smtp/MailTransport.java b/imap2/src/com/android/imap2/smtp/MailTransport.java
new file mode 100644
index 000000000..f3dd19db8
--- /dev/null
+++ b/imap2/src/com/android/imap2/smtp/MailTransport.java
@@ -0,0 +1,368 @@
+/*
+ * Copyright (C) 2008 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.imap2.smtp;
+
+import com.android.emailcommon.Logging;
+import com.android.emailcommon.mail.CertificateValidationException;
+import com.android.emailcommon.mail.MessagingException;
+import com.android.emailcommon.mail.Transport;
+import com.android.emailcommon.utility.SSLUtils;
+
+import android.util.Log;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.SocketAddress;
+import java.net.SocketException;
+
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.SSLException;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLSession;
+import javax.net.ssl.SSLSocket;
+
+/**
+ * This class implements the common aspects of "transport", one layer below the
+ * specific wire protocols such as POP3, IMAP, or SMTP.
+ */
+public class MailTransport implements Transport {
+
+ // TODO protected eventually
+ /*protected*/ public static final int SOCKET_CONNECT_TIMEOUT = 10000;
+ /*protected*/ public static final int SOCKET_READ_TIMEOUT = 60000;
+
+ private static final HostnameVerifier HOSTNAME_VERIFIER =
+ HttpsURLConnection.getDefaultHostnameVerifier();
+
+ private String mHost;
+ private int mPort;
+ private String[] mUserInfoParts;
+
+ /**
+ * One of the {@code Transport.CONNECTION_SECURITY_*} values.
+ */
+ private int mConnectionSecurity;
+
+ /**
+ * Whether or not to trust all server certificates (i.e. skip host verification) in SSL
+ * handshakes
+ */
+ private boolean mTrustCertificates;
+
+ private Socket mSocket;
+ private InputStream mIn;
+ private OutputStream mOut;
+ private boolean mLog = true; // STOPSHIP Don't ship with this set to true
+
+ /**
+ * Simple constructor for starting from scratch. Call setUri() and setSecurity() to
+ * complete the configuration.
+ * @param debugLabel Label used for Log.d calls
+ */
+ public MailTransport(boolean log) {
+ super();
+ mLog = log;
+ }
+
+ /**
+ * Returns a new transport, using the current transport as a model. The new transport is
+ * configured identically (as if {@link #setSecurity(int, boolean)}, {@link #setPort(int)}
+ * and {@link #setHost(String)} were invoked), but not opened or connected in any way.
+ */
+ @Override
+ public Transport clone() {
+ MailTransport newObject = new MailTransport(mLog);
+
+ newObject.mLog = mLog;
+ newObject.mHost = mHost;
+ newObject.mPort = mPort;
+ if (mUserInfoParts != null) {
+ newObject.mUserInfoParts = mUserInfoParts.clone();
+ }
+ newObject.mConnectionSecurity = mConnectionSecurity;
+ newObject.mTrustCertificates = mTrustCertificates;
+ return newObject;
+ }
+
+ @Override
+ public void setHost(String host) {
+ mHost = host;
+ }
+
+ @Override
+ public void setPort(int port) {
+ mPort = port;
+ }
+
+ @Override
+ public String getHost() {
+ return mHost;
+ }
+
+ @Override
+ public int getPort() {
+ return mPort;
+ }
+
+ @Override
+ public void setSecurity(int connectionSecurity, boolean trustAllCertificates) {
+ mConnectionSecurity = connectionSecurity;
+ mTrustCertificates = trustAllCertificates;
+ }
+
+ @Override
+ public int getSecurity() {
+ return mConnectionSecurity;
+ }
+
+ @Override
+ public boolean canTrySslSecurity() {
+ return mConnectionSecurity == Transport.CONNECTION_SECURITY_SSL;
+ }
+
+ @Override
+ public boolean canTryTlsSecurity() {
+ return mConnectionSecurity == Transport.CONNECTION_SECURITY_TLS;
+ }
+
+ @Override
+ public boolean canTrustAllCertificates() {
+ return mTrustCertificates;
+ }
+
+ /**
+ * Attempts to open a connection using the Uri supplied for connection parameters. Will attempt
+ * an SSL connection if indicated.
+ */
+ @Override
+ public void open() throws MessagingException, CertificateValidationException {
+ if (mLog) {
+ Log.d(Logging.LOG_TAG, "*** SMTP open " +
+ getHost() + ":" + String.valueOf(getPort()));
+ }
+
+ try {
+ SocketAddress socketAddress = new InetSocketAddress(getHost(), getPort());
+ if (canTrySslSecurity()) {
+ mSocket = SSLUtils.getSSLSocketFactory(canTrustAllCertificates()).createSocket();
+ } else {
+ mSocket = new Socket();
+ }
+ mSocket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT);
+ // After the socket connects to an SSL server, confirm that the hostname is as expected
+ if (canTrySslSecurity() && !canTrustAllCertificates()) {
+ verifyHostname(mSocket, getHost());
+ }
+ mIn = new BufferedInputStream(mSocket.getInputStream(), 1024);
+ mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512);
+
+ } catch (SSLException e) {
+ if (mLog) {
+ Log.d(Logging.LOG_TAG, e.toString());
+ }
+ throw new CertificateValidationException(e.getMessage(), e);
+ } catch (IOException ioe) {
+ if (mLog) {
+ Log.d(Logging.LOG_TAG, ioe.toString());
+ }
+ throw new MessagingException(MessagingException.IOERROR, ioe.toString());
+ }
+ }
+
+ /**
+ * Attempts to reopen a TLS connection using the Uri supplied for connection parameters.
+ *
+ * NOTE: No explicit hostname verification is required here, because it's handled automatically
+ * by the call to createSocket().
+ *
+ * TODO should we explicitly close the old socket? This seems funky to abandon it.
+ */
+ @Override
+ public void reopenTls() throws MessagingException {
+ try {
+ mSocket = SSLUtils.getSSLSocketFactory(canTrustAllCertificates())
+ .createSocket(mSocket, getHost(), getPort(), true);
+ mSocket.setSoTimeout(SOCKET_READ_TIMEOUT);
+ mIn = new BufferedInputStream(mSocket.getInputStream(), 1024);
+ mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512);
+
+ } catch (SSLException e) {
+ if (mLog) {
+ Log.d(Logging.LOG_TAG, e.toString());
+ }
+ throw new CertificateValidationException(e.getMessage(), e);
+ } catch (IOException ioe) {
+ if (mLog) {
+ Log.d(Logging.LOG_TAG, ioe.toString());
+ }
+ throw new MessagingException(MessagingException.IOERROR, ioe.toString());
+ }
+ }
+
+ /**
+ * Lightweight version of SSLCertificateSocketFactory.verifyHostname, which provides this
+ * service but is not in the public API.
+ *
+ * Verify the hostname of the certificate used by the other end of a
+ * connected socket. You MUST call this if you did not supply a hostname
+ * to SSLCertificateSocketFactory.createSocket(). It is harmless to call this method
+ * redundantly if the hostname has already been verified.
+ *
+ * Wildcard certificates are allowed to verify any matching hostname,
+ * so "foo.bar.example.com" is verified if the peer has a certificate
+ * for "*.example.com".
+ *
+ * @param socket An SSL socket which has been connected to a server
+ * @param hostname The expected hostname of the remote server
+ * @throws IOException if something goes wrong handshaking with the server
+ * @throws SSLPeerUnverifiedException if the server cannot prove its identity
+ */
+ private void verifyHostname(Socket socket, String hostname) throws IOException {
+ // The code at the start of OpenSSLSocketImpl.startHandshake()
+ // ensures that the call is idempotent, so we can safely call it.
+ SSLSocket ssl = (SSLSocket) socket;
+ ssl.startHandshake();
+
+ SSLSession session = ssl.getSession();
+ if (session == null) {
+ throw new SSLException("Cannot verify SSL socket without session");
+ }
+ // TODO: Instead of reporting the name of the server we think we're connecting to,
+ // we should be reporting the bad name in the certificate. Unfortunately this is buried
+ // in the verifier code and is not available in the verifier API, and extracting the
+ // CN & alts is beyond the scope of this patch.
+ if (!HOSTNAME_VERIFIER.verify(hostname, session)) {
+ throw new SSLPeerUnverifiedException(
+ "Certificate hostname not useable for server: " + hostname);
+ }
+ }
+
+ /**
+ * Set the socket timeout.
+ * @param timeoutMilliseconds the read timeout value if greater than {@code 0}, or
+ * {@code 0} for an infinite timeout.
+ */
+ @Override
+ public void setSoTimeout(int timeoutMilliseconds) throws SocketException {
+ mSocket.setSoTimeout(timeoutMilliseconds);
+ }
+
+ @Override
+ public boolean isOpen() {
+ return (mIn != null && mOut != null &&
+ mSocket != null && mSocket.isConnected() && !mSocket.isClosed());
+ }
+
+ /**
+ * Close the connection. MUST NOT return any exceptions - must be "best effort" and safe.
+ */
+ @Override
+ public void close() {
+ try {
+ mIn.close();
+ } catch (Exception e) {
+ // May fail if the connection is already closed.
+ }
+ try {
+ mOut.close();
+ } catch (Exception e) {
+ // May fail if the connection is already closed.
+ }
+ try {
+ mSocket.close();
+ } catch (Exception e) {
+ // May fail if the connection is already closed.
+ }
+ mIn = null;
+ mOut = null;
+ mSocket = null;
+ }
+
+ @Override
+ public InputStream getInputStream() {
+ return mIn;
+ }
+
+ @Override
+ public OutputStream getOutputStream() {
+ return mOut;
+ }
+
+ /**
+ * Writes a single line to the server using \r\n termination.
+ */
+ @Override
+ public void writeLine(String s, String sensitiveReplacement) throws IOException {
+ if (mLog) {
+ if (sensitiveReplacement != null && !Logging.DEBUG_SENSITIVE) {
+ Log.d(Logging.LOG_TAG, ">>> " + sensitiveReplacement);
+ } else {
+ Log.d(Logging.LOG_TAG, ">>> " + s);
+ }
+ }
+
+ OutputStream out = getOutputStream();
+ out.write(s.getBytes());
+ out.write('\r');
+ out.write('\n');
+ out.flush();
+ }
+
+ /**
+ * Reads a single line from the server, using either \r\n or \n as the delimiter. The
+ * delimiter char(s) are not included in the result.
+ */
+ @Override
+ public String readLine() throws IOException {
+ StringBuffer sb = new StringBuffer();
+ InputStream in = getInputStream();
+ int d;
+ while ((d = in.read()) != -1) {
+ if (((char)d) == '\r') {
+ continue;
+ } else if (((char)d) == '\n') {
+ break;
+ } else {
+ sb.append((char)d);
+ }
+ }
+ if (d == -1 && mLog) {
+ Log.d(Logging.LOG_TAG, "End of stream reached while trying to read line.");
+ }
+ String ret = sb.toString();
+ if (mLog) {
+ Log.d(Logging.LOG_TAG, "<<< " + ret);
+ }
+ return ret;
+ }
+
+ @Override
+ public InetAddress getLocalAddress() {
+ if (isOpen()) {
+ return mSocket.getLocalAddress();
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/imap2/src/com/android/imap2/smtp/SmtpSender.java b/imap2/src/com/android/imap2/smtp/SmtpSender.java
new file mode 100644
index 000000000..fce48d58e
--- /dev/null
+++ b/imap2/src/com/android/imap2/smtp/SmtpSender.java
@@ -0,0 +1,325 @@
+/*
+ * Copyright (C) 2008 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.imap2.smtp;
+
+import android.content.Context;
+import android.util.Base64;
+import android.util.Log;
+
+import com.android.emailcommon.Logging;
+import com.android.emailcommon.internet.Rfc822Output;
+import com.android.emailcommon.mail.Address;
+import com.android.emailcommon.mail.AuthenticationFailedException;
+import com.android.emailcommon.mail.CertificateValidationException;
+import com.android.emailcommon.mail.MessagingException;
+import com.android.emailcommon.mail.Transport;
+import com.android.emailcommon.provider.Account;
+import com.android.emailcommon.provider.EmailContent.Message;
+import com.android.emailcommon.provider.HostAuth;
+import com.android.emailcommon.utility.EOLConvertingOutputStream;
+
+import java.io.IOException;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+
+import javax.net.ssl.SSLException;
+
+/**
+ * This class handles all of the protocol-level aspects of sending messages via SMTP.
+ * TODO Remove dependence upon URI; there's no reason why we need it here
+ */
+public class SmtpSender {
+
+ private static final int DEFAULT_SMTP_PORT = 587;
+ private static final int DEFAULT_SMTP_SSL_PORT = 465;
+
+ private final Context mContext;
+ private Transport mTransport;
+ private String mUsername;
+ private String mPassword;
+ private boolean mLog;
+
+ /**
+ * Creates a new sender for the given account.
+ */
+ public SmtpSender(Context context, Account account, boolean log) {
+ mContext = context;
+ mLog = log;
+ HostAuth sendAuth = account.getOrCreateHostAuthSend(context);
+ // defaults, which can be changed by security modifiers
+ int connectionSecurity = Transport.CONNECTION_SECURITY_NONE;
+ int defaultPort = DEFAULT_SMTP_PORT;
+
+ // check for security flags and apply changes
+ if ((sendAuth.mFlags & HostAuth.FLAG_SSL) != 0) {
+ connectionSecurity = Transport.CONNECTION_SECURITY_SSL;
+ defaultPort = DEFAULT_SMTP_SSL_PORT;
+ } else if ((sendAuth.mFlags & HostAuth.FLAG_TLS) != 0) {
+ connectionSecurity = Transport.CONNECTION_SECURITY_TLS;
+ }
+ boolean trustCertificates = ((sendAuth.mFlags & HostAuth.FLAG_TRUST_ALL) != 0);
+ int port = defaultPort;
+ if (sendAuth.mPort != HostAuth.PORT_UNKNOWN) {
+ port = sendAuth.mPort;
+ }
+ mTransport = new MailTransport(mLog);
+ mTransport.setHost(sendAuth.mAddress);
+ mTransport.setPort(port);
+ mTransport.setSecurity(connectionSecurity, trustCertificates);
+
+ String[] userInfoParts = sendAuth.getLogin();
+ if (userInfoParts != null) {
+ mUsername = userInfoParts[0];
+ mPassword = userInfoParts[1];
+ }
+ }
+
+ /**
+ * For testing only. Injects a different transport. The transport should already be set
+ * up and ready to use. Do not use for real code.
+ * @param testTransport The Transport to inject and use for all future communication.
+ */
+ /* package */ void setTransport(Transport testTransport) {
+ mTransport = testTransport;
+ }
+
+ public void open() throws MessagingException {
+ try {
+ mTransport.open();
+
+ // Eat the banner
+ executeSimpleCommand(null);
+
+ String localHost = "localhost";
+ // Try to get local address in the proper format.
+ InetAddress localAddress = mTransport.getLocalAddress();
+ if (localAddress != null) {
+ // Address Literal formatted in accordance to RFC2821 Sec. 4.1.3
+ StringBuilder sb = new StringBuilder();
+ sb.append('[');
+ if (localAddress instanceof Inet6Address) {
+ sb.append("IPv6:");
+ }
+ sb.append(localAddress.getHostAddress());
+ sb.append(']');
+ localHost = sb.toString();
+ }
+ String result = executeSimpleCommand("EHLO " + localHost);
+
+ /*
+ * TODO may need to add code to fall back to HELO I switched it from
+ * using HELO on non STARTTLS connections because of AOL's mail
+ * server. It won't let you use AUTH without EHLO.
+ * We should really be paying more attention to the capabilities
+ * and only attempting auth if it's available, and warning the user
+ * if not.
+ */
+ if (mTransport.canTryTlsSecurity()) {
+ if (result.contains("STARTTLS")) {
+ executeSimpleCommand("STARTTLS");
+ mTransport.reopenTls();
+ /*
+ * Now resend the EHLO. Required by RFC2487 Sec. 5.2, and more specifically,
+ * Exim.
+ */
+ result = executeSimpleCommand("EHLO " + localHost);
+ } else {
+ if (mLog) {
+ Log.d(Logging.LOG_TAG, "TLS not supported but required");
+ }
+ throw new MessagingException(MessagingException.TLS_REQUIRED);
+ }
+ }
+
+ /*
+ * result contains the results of the EHLO in concatenated form
+ */
+ boolean authLoginSupported = result.matches(".*AUTH.*LOGIN.*$");
+ boolean authPlainSupported = result.matches(".*AUTH.*PLAIN.*$");
+
+ if (mUsername != null && mUsername.length() > 0 && mPassword != null
+ && mPassword.length() > 0) {
+ if (authPlainSupported) {
+ saslAuthPlain(mUsername, mPassword);
+ }
+ else if (authLoginSupported) {
+ saslAuthLogin(mUsername, mPassword);
+ }
+ else {
+ if (mLog) {
+ Log.d(Logging.LOG_TAG, "No valid authentication mechanism found.");
+ }
+ throw new MessagingException(MessagingException.AUTH_REQUIRED);
+ }
+ }
+ } catch (SSLException e) {
+ if (mLog) {
+ Log.d(Logging.LOG_TAG, e.toString());
+ }
+ throw new CertificateValidationException(e.getMessage(), e);
+ } catch (IOException ioe) {
+ if (mLog) {
+ Log.d(Logging.LOG_TAG, ioe.toString());
+ }
+ throw new MessagingException(MessagingException.IOERROR, ioe.toString());
+ }
+ }
+
+ public void sendMessage(long messageId) throws MessagingException {
+ close();
+ open();
+
+ Message message = Message.restoreMessageWithId(mContext, messageId);
+ if (message == null) {
+ throw new MessagingException("Trying to send non-existent message id="
+ + Long.toString(messageId));
+ }
+ Address from = Address.unpackFirst(message.mFrom);
+ Address[] to = Address.unpack(message.mTo);
+ Address[] cc = Address.unpack(message.mCc);
+ Address[] bcc = Address.unpack(message.mBcc);
+
+ try {
+ executeSimpleCommand("MAIL FROM: " + "<" + from.getAddress() + ">");
+ for (Address address : to) {
+ executeSimpleCommand("RCPT TO: " + "<" + address.getAddress() + ">");
+ }
+ for (Address address : cc) {
+ executeSimpleCommand("RCPT TO: " + "<" + address.getAddress() + ">");
+ }
+ for (Address address : bcc) {
+ executeSimpleCommand("RCPT TO: " + "<" + address.getAddress() + ">");
+ }
+ executeSimpleCommand("DATA");
+ // TODO byte stuffing
+ Rfc822Output.writeTo(mContext, messageId,
+ new EOLConvertingOutputStream(mTransport.getOutputStream()),
+ false /* do not use smart reply */,
+ false /* do not send BCC */);
+ executeSimpleCommand("\r\n.");
+ } catch (IOException ioe) {
+ throw new MessagingException("Unable to send message", ioe);
+ }
+ }
+
+ /**
+ * Close the protocol (and the transport below it).
+ *
+ * MUST NOT return any exceptions.
+ */
+ public void close() {
+ mTransport.close();
+ }
+
+ /**
+ * Send a single command and wait for a single response. Handles responses that continue
+ * onto multiple lines. Throws MessagingException if response code is 4xx or 5xx. All traffic
+ * is logged (if debug logging is enabled) so do not use this function for user ID or password.
+ *
+ * @param command The command string to send to the server.
+ * @return Returns the response string from the server.
+ */
+ private String executeSimpleCommand(String command) throws IOException, MessagingException {
+ return executeSensitiveCommand(command, null);
+ }
+
+ /**
+ * Send a single command and wait for a single response. Handles responses that continue
+ * onto multiple lines. Throws MessagingException if response code is 4xx or 5xx.
+ *
+ * @param command The command string to send to the server.
+ * @param sensitiveReplacement If the command includes sensitive data (e.g. authentication)
+ * please pass a replacement string here (for logging).
+ * @return Returns the response string from the server.
+ */
+ private String executeSensitiveCommand(String command, String sensitiveReplacement)
+ throws IOException, MessagingException {
+ if (command != null) {
+ mTransport.writeLine(command, sensitiveReplacement);
+ }
+
+ String line = mTransport.readLine();
+
+ String result = line;
+
+ while (line.length() >= 4 && line.charAt(3) == '-') {
+ line = mTransport.readLine();
+ result += line.substring(3);
+ }
+
+ if (result.length() > 0) {
+ char c = result.charAt(0);
+ if ((c == '4') || (c == '5')) {
+ throw new MessagingException(result);
+ }
+ }
+
+ return result;
+ }
+
+
+// C: AUTH LOGIN
+// S: 334 VXNlcm5hbWU6
+// C: d2VsZG9u
+// S: 334 UGFzc3dvcmQ6
+// C: dzNsZDBu
+// S: 235 2.0.0 OK Authenticated
+//
+// Lines 2-5 of the conversation contain base64-encoded information. The same conversation, with base64 strings decoded, reads:
+//
+//
+// C: AUTH LOGIN
+// S: 334 Username:
+// C: weldon
+// S: 334 Password:
+// C: w3ld0n
+// S: 235 2.0.0 OK Authenticated
+
+ private void saslAuthLogin(String username, String password) throws MessagingException,
+ AuthenticationFailedException, IOException {
+ try {
+ executeSimpleCommand("AUTH LOGIN");
+ executeSensitiveCommand(
+ Base64.encodeToString(username.getBytes(), Base64.NO_WRAP),
+ "/username redacted/");
+ executeSensitiveCommand(
+ Base64.encodeToString(password.getBytes(), Base64.NO_WRAP),
+ "/password redacted/");
+ }
+ catch (MessagingException me) {
+ if (me.getMessage().length() > 1 && me.getMessage().charAt(1) == '3') {
+ throw new AuthenticationFailedException(me.getMessage());
+ }
+ throw me;
+ }
+ }
+
+ private void saslAuthPlain(String username, String password) throws MessagingException,
+ AuthenticationFailedException, IOException {
+ byte[] data = ("\000" + username + "\000" + password).getBytes();
+ data = Base64.encode(data, Base64.NO_WRAP);
+ try {
+ executeSensitiveCommand("AUTH PLAIN " + new String(data), "AUTH PLAIN /redacted/");
+ }
+ catch (MessagingException me) {
+ if (me.getMessage().length() > 1 && me.getMessage().charAt(1) == '3') {
+ throw new AuthenticationFailedException(me.getMessage());
+ }
+ throw me;
+ }
+ }
+}
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 7ad3bcb2d..c157e0ce5 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -1296,7 +1296,7 @@ as %s.