diff --git a/src/com/android/email/mail/Transport.java b/emailcommon/src/com/android/emailcommon/mail/Transport.java similarity index 97% rename from src/com/android/email/mail/Transport.java rename to emailcommon/src/com/android/emailcommon/mail/Transport.java index 8f8ad2e25..5fe3a57ed 100644 --- a/src/com/android/email/mail/Transport.java +++ b/emailcommon/src/com/android/emailcommon/mail/Transport.java @@ -14,10 +14,8 @@ * limitations under the License. */ -package com.android.email.mail; +package com.android.emailcommon.mail; -import com.android.emailcommon.mail.CertificateValidationException; -import com.android.emailcommon.mail.MessagingException; import java.io.IOException; import java.io.InputStream; diff --git a/emailcommon/src/com/android/emailcommon/provider/EmailContent.java b/emailcommon/src/com/android/emailcommon/provider/EmailContent.java index 256f3958f..9427681ab 100755 --- a/emailcommon/src/com/android/emailcommon/provider/EmailContent.java +++ b/emailcommon/src/com/android/emailcommon/provider/EmailContent.java @@ -73,6 +73,8 @@ public abstract class EmailContent { public static final Uri PICK_TRASH_FOLDER_URI = Uri.parse("content://" + EmailContent.AUTHORITY + "/pickTrashFolder"); + public static final Uri PICK_SENT_FOLDER_URI = + Uri.parse("content://" + EmailContent.AUTHORITY + "/pickSentFolder"); public static final Uri MAILBOX_NOTIFICATION_URI = Uri.parse("content://" + EmailContent.AUTHORITY + "/mailboxNotification"); diff --git a/src/com/android/email/mail/transport/CountingOutputStream.java b/emailcommon/src/com/android/emailcommon/utility/CountingOutputStream.java similarity index 96% rename from src/com/android/email/mail/transport/CountingOutputStream.java rename to emailcommon/src/com/android/emailcommon/utility/CountingOutputStream.java index a5f10b5e4..ec71634b9 100644 --- a/src/com/android/email/mail/transport/CountingOutputStream.java +++ b/emailcommon/src/com/android/emailcommon/utility/CountingOutputStream.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.email.mail.transport; +package com.android.emailcommon.utility; import java.io.IOException; import java.io.OutputStream; diff --git a/src/com/android/email/mail/transport/EOLConvertingOutputStream.java b/emailcommon/src/com/android/emailcommon/utility/EOLConvertingOutputStream.java similarity index 96% rename from src/com/android/email/mail/transport/EOLConvertingOutputStream.java rename to emailcommon/src/com/android/emailcommon/utility/EOLConvertingOutputStream.java index d94ec1345..6e7380bb3 100644 --- a/src/com/android/email/mail/transport/EOLConvertingOutputStream.java +++ b/emailcommon/src/com/android/emailcommon/utility/EOLConvertingOutputStream.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.email.mail.transport; +package com.android.emailcommon.utility; import java.io.FilterOutputStream; import java.io.IOException; diff --git a/emailsync/src/com/android/emailsync/SyncManager.java b/emailsync/src/com/android/emailsync/SyncManager.java index 080dc984d..7cb826ea0 100644 --- a/emailsync/src/com/android/emailsync/SyncManager.java +++ b/emailsync/src/com/android/emailsync/SyncManager.java @@ -34,6 +34,7 @@ import android.net.NetworkInfo; import android.net.NetworkInfo.State; import android.net.Uri; import android.os.Bundle; +import android.os.Debug; import android.os.Handler; import android.os.PowerManager; import android.os.PowerManager.WakeLock; diff --git a/imap2/src/com/android/imap2/Imap2SyncManager.java b/imap2/src/com/android/imap2/Imap2SyncManager.java index 258775ca6..51d72cef4 100644 --- a/imap2/src/com/android/imap2/Imap2SyncManager.java +++ b/imap2/src/com/android/imap2/Imap2SyncManager.java @@ -17,7 +17,6 @@ package com.android.imap2; import android.os.Bundle; -import android.os.Debug; import android.os.Handler; import android.os.IBinder; import android.os.RemoteCallbackList; @@ -29,7 +28,6 @@ import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.database.Cursor; -import android.util.Log; import com.android.emailcommon.Api; import com.android.emailcommon.provider.EmailContent; @@ -212,10 +210,16 @@ public class Imap2SyncManager extends SyncManager { return new AccountObserver(handler) { @Override public void newAccount(long acctId) { - // Create the Inbox for the account - Account acct = Account.restoreAccountWithId(getContext(), acctId); + // Create the Inbox for the account if it doesn't exist + Context context = getContext(); + Account acct = Account.restoreAccountWithId(context, acctId); + if (acct == null) return; + long inboxId = Mailbox.findMailboxOfType(context, acctId, Mailbox.TYPE_INBOX); + if (inboxId != Mailbox.NO_MAILBOX) { + return; + } Mailbox inbox = new Mailbox(); - inbox.mDisplayName = "Inbox"; // Localize + inbox.mDisplayName = context.getString(R.string.mailbox_name_server_inbox); inbox.mServerId = "Inbox"; inbox.mAccountKey = acct.mId; inbox.mType = Mailbox.TYPE_INBOX; diff --git a/imap2/src/com/android/imap2/Imap2SyncService.java b/imap2/src/com/android/imap2/Imap2SyncService.java index ac08aedac..a9e162427 100644 --- a/imap2/src/com/android/imap2/Imap2SyncService.java +++ b/imap2/src/com/android/imap2/Imap2SyncService.java @@ -24,16 +24,18 @@ import android.content.Context; import android.content.OperationApplicationException; import android.database.Cursor; import android.net.TrafficStats; +import android.net.Uri; import android.os.Bundle; import android.os.RemoteException; -import android.util.Log; import com.android.emailcommon.TrafficFlags; import com.android.emailcommon.internet.MimeUtility; +import com.android.emailcommon.internet.Rfc822Output; import com.android.emailcommon.mail.Address; import com.android.emailcommon.mail.CertificateValidationException; import com.android.emailcommon.mail.MessagingException; import com.android.emailcommon.provider.Account; +import com.android.emailcommon.provider.EmailContent.AccountColumns; import com.android.emailcommon.provider.EmailContent.Attachment; import com.android.emailcommon.provider.EmailContent.Body; import com.android.emailcommon.provider.EmailContent.MailboxColumns; @@ -43,11 +45,12 @@ import com.android.emailcommon.provider.EmailContent; import com.android.emailcommon.provider.HostAuth; import com.android.emailcommon.provider.Mailbox; import com.android.emailcommon.provider.MailboxUtilities; -import com.android.emailcommon.provider.ProviderUnavailableException; import com.android.emailcommon.provider.EmailContent.Message; import com.android.emailcommon.service.EmailServiceProxy; import com.android.emailcommon.service.EmailServiceStatus; import com.android.emailcommon.service.SyncWindow; +import com.android.emailcommon.utility.CountingOutputStream; +import com.android.emailcommon.utility.EOLConvertingOutputStream; import com.android.emailcommon.utility.SSLUtils; import com.android.emailcommon.utility.TextUtilities; import com.android.emailcommon.utility.Utility; @@ -55,6 +58,7 @@ import com.android.emailsync.AbstractSyncService; import com.android.emailsync.PartRequest; import com.android.emailsync.Request; import com.android.emailsync.SyncManager; +import com.android.imap2.smtp.SmtpSender; import com.android.mail.providers.UIProvider; import com.beetstra.jutf7.CharsetProvider; @@ -103,17 +107,18 @@ public class Imap2SyncService extends AbstractSyncService { private static Pattern IMAP_RESPONSE_PATTERN = Pattern.compile("\\*(\\s(\\d+))?\\s(\\w+).*"); - private static final int HEADER_BATCH_COUNT = 10; + private static final int HEADER_BATCH_COUNT = 20; - // private static final int IDLE_TIMEOUT_MILLIS = 12*MINS; private static final int SECONDS = 1000; private static final int MINS = 60*SECONDS; private static final int IDLE_ASLEEP_MILLIS = 11*MINS; - // private static final int COMMAND_TIMEOUT_MILLIS = 24*SECS; private static final int SOCKET_CONNECT_TIMEOUT = 10*SECONDS; private static final int SOCKET_TIMEOUT = 20*SECONDS; + private static final int AUTOMATIC_SYNC_WINDOW_MAX_MESSAGES = 250; + private static final int AUTOMATIC_SYNC_WINDOW_LARGE_MAILBOX = 1000; + private ContentResolver mResolver; private int mWriterTag = 1; private boolean mIsGmail = false; @@ -123,6 +128,7 @@ public class Imap2SyncService extends AbstractSyncService { private ArrayList mImapResponse = null; private String mImapResult; private String mImapErrorLine = null; + private String mImapSuccessLine = null; private Socket mSocket = null; private boolean mStop = false; @@ -144,10 +150,23 @@ public class Imap2SyncService extends AbstractSyncService { private HostAuth mHostAuth; private String mPrefix; + private long mTrashMailboxId = Mailbox.NO_MAILBOX; + private long mAccountId; + + private final ArrayList mUpdatedIds = new ArrayList(); + private final ArrayList mDeletedIds = new ArrayList(); + private final Stack mDeletes = new Stack(); + private final Stack mReadUpdates = new Stack(); + private final Stack mUnreadUpdates = new Stack(); + private final Stack mFlaggedUpdates = new Stack(); + private final Stack mUnflaggedUpdates = new Stack(); public Imap2SyncService(Context _context, Mailbox _mailbox) { super(_context, _mailbox); mResolver = _context.getContentResolver(); + if (mAccount != null) { + mAccountId = mAccount.mId; + } MAILBOX_SERVER_ID_ARGS[0] = Long.toString(mMailboxId); } @@ -160,8 +179,10 @@ public class Imap2SyncService extends AbstractSyncService { mContext = _context; mResolver = _context.getContentResolver(); mAccount = _account; + mAccountId = _account.mId; mHostAuth = HostAuth.restoreHostAuthWithId(_context, mAccount.mHostAuthKeyRecv); mPrefix = mHostAuth.mDomain; + mTrashMailboxId = Mailbox.findMailboxOfType(_context, _account.mId, Mailbox.TYPE_TRASH); } @Override @@ -282,8 +303,9 @@ public class Imap2SyncService extends AbstractSyncService { out.write(cmd); out.write("\r\n"); out.flush(); - if (!cmd.startsWith("login")) + if (!cmd.startsWith("login")) { userLog(tag + cmd); + } return tag; } catch (IOException e) { userLog("IOException in writeCommand"); @@ -317,20 +339,21 @@ public class Imap2SyncService extends AbstractSyncService { mLastExists = val; } } catch (NumberFormatException e) { - } else if (mMailbox.mSyncKey == null || mMailbox.mSyncKey == "0") { - str = str.toLowerCase(); - int idx = str.indexOf("uidvalidity"); - if (idx > 0) { - //*** 12? - long num = readLong(str, idx + 12); - mMailbox.mSyncKey = Long.toString(num); - ContentValues cv = new ContentValues(); - cv.put(MailboxColumns.SYNC_KEY, mMailbox.mSyncKey); - mContext.getContentResolver().update( - ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailbox.mId), cv, - null, null); - } } + else if (mMailbox != null && mMailbox.mSyncKey == null || mMailbox.mSyncKey == "0") { + str = str.toLowerCase(); + int idx = str.indexOf("uidvalidity"); + if (idx > 0) { + // 12 = length of "uidvalidity" + 1 + long num = readLong(str, idx + 12); + mMailbox.mSyncKey = Long.toString(num); + ContentValues cv = new ContentValues(); + cv.put(MailboxColumns.SYNC_KEY, mMailbox.mSyncKey); + mContext.getContentResolver().update( + ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailbox.mId), cv, + null, null); + } + } } userLog("Untagged: " + type); @@ -373,7 +396,9 @@ public class Imap2SyncService extends AbstractSyncService { } } - if (!mImapResult.equals(IMAP_OK)) { + if (mImapResult.equals(IMAP_OK)) { + mImapSuccessLine = str; + } else { userLog("$$$ Error result = " + mImapResult); mImapErrorLine = str; } @@ -471,7 +496,7 @@ public class Imap2SyncService extends AbstractSyncService { //msg.bodyId = 0; //msg.parts = parts.toString(); - msg.mAccountKey = mAccount.mId; + msg.mAccountKey = mAccountId; msg.mFlagLoaded = Message.FLAG_LOADED_UNLOADED; msg.mFlags = flag; @@ -620,24 +645,6 @@ public class Imap2SyncService extends AbstractSyncService { return folder; } - private static class ServerUpdate { - final long id; - final int serverId; - - ServerUpdate(long _id, int _serverId) { - id = _id; - serverId = _serverId; - } - } - - private ArrayList mUpdatedIds = new ArrayList(); - private ArrayList mDeletedIds = new ArrayList(); - private Stack mDeletes = new Stack(); - private Stack mReadUpdates = new Stack(); - private Stack mUnreadUpdates = new Stack(); - private Stack mFlaggedUpdates = new Stack(); - private Stack mUnflaggedUpdates = new Stack(); - private Cursor getUpdatesCursor() { Cursor c = mResolver.query(Message.UPDATED_CONTENT_URI, UPDATE_DELETE_PROJECTION, MessageColumns.MAILBOX_KEY + '=' + mMailbox.mId, null, null); @@ -676,7 +683,7 @@ public class Imap2SyncService extends AbstractSyncService { try { while (c.moveToNext()) { long id = c.getLong(UPDATE_DELETE_ID_COLUMN); - mDeletes.add(new ServerUpdate(id, c.getInt(UPDATE_DELETE_SERVER_ID_COLUMN))); + mDeletes.add(c.getInt(UPDATE_DELETE_SERVER_ID_COLUMN)); mDeletedIds.add(id); } sendUpdate(mDeletes, "+FLAGS (\\Deleted)"); @@ -758,7 +765,7 @@ public class Imap2SyncService extends AbstractSyncService { continue; } - ServerUpdate update = new ServerUpdate(id, serverId); + Integer update = serverId; if (readChange) { if (read == 1) { mReadUpdates.add(update); @@ -796,16 +803,16 @@ public class Imap2SyncService extends AbstractSyncService { } } - private void sendUpdate(Stack updates, String command) throws IOException { + private void sendUpdate(Stack updates, String command) throws IOException { // First, generate the appropriate String while (!updates.isEmpty()) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < 20 && !updates.empty(); i++) { - ServerUpdate update = updates.pop(); + Integer update = updates.pop(); if (i != 0) { sb.append(','); } - sb.append(update.serverId); + sb.append(update); } String tag = writeCommand(mConnection.writer, "uid store " + sb.toString() + " " + command); @@ -831,28 +838,39 @@ public class Imap2SyncService extends AbstractSyncService { } private void saveNewMessages (ArrayList msgList) { - // Cursor dc = getLocalDeletedCursor(); - // ArrayList dl = new ArrayList(); - // boolean newDeletions = false; - // try { - // if (dc.moveToFirst()) { - // do { - // dl.add(dc.getInt(Email.UID_COLUMN)); - // newDeletions = true; - // } while (dc.moveToNext()); - // } - // } finally { - // dc.close(); - // } + // Get the ids of updated messages in this mailbox (usually there won't be any) + Cursor c = getUpdatesCursor(); + ArrayList updatedIds = new ArrayList(); + boolean newUpdates = false; + + if (c != null) { + try { + if (c.moveToFirst()) { + do { + updatedIds.add(c.getInt(UPDATE_DELETE_SERVER_ID_COLUMN)); + newUpdates = true; + } while (c.moveToNext()); + } + } finally { + c.close(); + } + } ArrayList ops = new ArrayList(); for (Message msg: msgList) { - //if (newDeletions && dl.contains(msg.uid)) { - // userLog("PHEW! Didn't save deleted message with uid: " + msg.uid); - // continue; - //} + // If the message is updated, make sure it's not deleted (we don't want to reload it) + if (newUpdates && updatedIds.contains(msg.mServerId)) { + Message currentMsg = Message.restoreMessageWithId(mContext, msg.mId); + if (currentMsg.mMailboxKey == mTrashMailboxId) { + userLog("PHEW! Didn't save deleted message with uid: " + msg.mServerId); + continue; + } + } + // Add the CPO's for this message msg.addSaveOps(ops); } + + // Commit these messages applyBatch(ops); } @@ -1034,7 +1052,7 @@ public class Imap2SyncService extends AbstractSyncService { values.put(MessageColumns.FLAG_LOADED, Message.FLAG_LOADED_COMPLETE); // Save the attachments... for (Attachment att: attachments) { - att.mAccountKey = mAccount.mId; + att.mAccountKey = mAccountId; att.mMessageKey = msg.mId; att.save(mContext); } @@ -1166,7 +1184,7 @@ public class Imap2SyncService extends AbstractSyncService { } Mailbox m = new Mailbox(); m.mDisplayName = displayName; - m.mAccountKey = mAccount.mId; + m.mAccountKey = mAccountId; m.mServerId = serverId; if (parentName != null && !parentList.contains(parentName)) { parentList.add(parentName); @@ -1184,7 +1202,7 @@ public class Imap2SyncService extends AbstractSyncService { // TODO: Use narrower projection Cursor c = mResolver.query(Mailbox.CONTENT_URI, Mailbox.CONTENT_PROJECTION, - Mailbox.ACCOUNT_KEY + "=?", new String[] {Long.toString(mAccount.mId)}, + Mailbox.ACCOUNT_KEY + "=?", new String[] {Long.toString(mAccountId)}, MailboxColumns.SERVER_ID + " asc"); if (c == null) return; int cnt = c.getCount(); @@ -1307,7 +1325,7 @@ public class Imap2SyncService extends AbstractSyncService { applyBatch(ops); // Fixup parent stuff, flags... MailboxUtilities.fixupUninitializedParentKeys(mContext, - Mailbox.ACCOUNT_KEY + "=" + mAccount.mId); + Mailbox.ACCOUNT_KEY + "=" + mAccountId); } finally { SyncManager.kick("folder list"); } @@ -1353,7 +1371,7 @@ public class Imap2SyncService extends AbstractSyncService { } } - private void processServerUpdates(ArrayList deleteList, ContentValues values) { + private void processIntegers(ArrayList deleteList, ContentValues values) { int cnt = deleteList.size(); if (cnt > 0) { ArrayList ops = @@ -1473,9 +1491,9 @@ public class Imap2SyncService extends AbstractSyncService { Reconciled r = reconcile(flag, deviceList, serverList); ContentValues values = new ContentValues(); values.put(column, sense); - processServerUpdates(r.delete, values); + processIntegers(r.delete, values); values.put(column, !sense); - processServerUpdates(r.insert, values); + processIntegers(r.insert, values); } } @@ -1659,88 +1677,139 @@ public class Imap2SyncService extends AbstractSyncService { } } - // Upload sent messages to server + private void doUpload(long messageId, String mailboxServerId) throws IOException, + MessagingException { + ContentValues values = new ContentValues(); + CountingOutputStream out = new CountingOutputStream(); + EOLConvertingOutputStream eolOut = new EOLConvertingOutputStream(out); + Rfc822Output.writeTo(mContext, + messageId, + eolOut, + false /* do not use smart reply */, + false /* do not send BCC */); + eolOut.flush(); + long len = out.getCount(); + try { + String tag = writeCommand(mWriter, "append \"" + + encodeFolderName(mailboxServerId) + + "\" (\\seen) {" + len + '}'); + String line = mReader.readLine(); + if (line.startsWith("+")) { + userLog("append response: " + line); + eolOut = new EOLConvertingOutputStream(mSocket.getOutputStream()); + Rfc822Output.writeTo(mContext, + messageId, + eolOut, + false /* do not use smart reply */, + false /* do not send BCC */); + eolOut.flush(); + mWriter.write("\r\n"); + mWriter.flush(); + if (readResponse(mConnection.reader, tag).equals(IMAP_OK)) { + int serverId = 0; + String lc = mImapSuccessLine.toLowerCase(); + int appendUid = lc.indexOf("appenduid"); + if (appendUid > 0) { + Parser p = new Parser(lc, appendUid + 11); + // UIDVALIDITY (we don't need it) + p.parseInteger(); + serverId = p.parseInteger(); + } + values.put(SyncColumns.SERVER_ID, serverId); + mResolver.update(ContentUris.withAppendedId(Message.CONTENT_URI, + messageId), values, null, null); + } else { + userLog("Append failed: " + mImapErrorLine); + } + } else { + userLog("Append failed: " + line); + } + } catch (Exception e) { + e.printStackTrace(); + } + } - // void foo() { - // Cursor c = ServerUploads.getCursorWhere(mDatabase, "account=" + mAccount.id); - // ArrayList uploaded = new ArrayList(); - // 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. roaming. - Sending response... + Sending response… No messages. @@ -1305,7 +1305,10 @@ as %s. Push IMAP Picky, picky, picky! - Select trash folder + + Select server trash folder + + Select server sent items folder Create folder diff --git a/res/xml/services.xml b/res/xml/services.xml index e52ea1cf4..f8f6dfd98 100644 --- a/res/xml/services.xml +++ b/res/xml/services.xml @@ -112,6 +112,7 @@ email:syncIntervals="@array/account_settings_check_frequency_values_push" email:defaultSyncInterval="push" + email:offerLookback="true" email:offerTls="true" email:usesSmtp="true" email:offerAttachmentPreload="true" diff --git a/src/com/android/email/mail/Store.java b/src/com/android/email/mail/Store.java index 15cf4deb4..eca314017 100644 --- a/src/com/android/email/mail/Store.java +++ b/src/com/android/email/mail/Store.java @@ -27,6 +27,7 @@ import com.android.email2.ui.MailActivityEmail; import com.android.emailcommon.Logging; import com.android.emailcommon.mail.Folder; import com.android.emailcommon.mail.MessagingException; +import com.android.emailcommon.mail.Transport; import com.android.emailcommon.provider.Account; import com.android.emailcommon.provider.EmailContent; import com.android.emailcommon.provider.HostAuth; diff --git a/src/com/android/email/mail/store/ImapConnection.java b/src/com/android/email/mail/store/ImapConnection.java index e83b9fc24..a6d4fe605 100644 --- a/src/com/android/email/mail/store/ImapConnection.java +++ b/src/com/android/email/mail/store/ImapConnection.java @@ -19,7 +19,6 @@ package com.android.email.mail.store; import android.text.TextUtils; import android.util.Log; -import com.android.email.mail.Transport; import com.android.email.mail.store.ImapStore.ImapException; import com.android.email.mail.store.imap.ImapConstants; import com.android.email.mail.store.imap.ImapList; @@ -33,6 +32,7 @@ import com.android.emailcommon.Logging; 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 java.io.IOException; import java.util.ArrayList; diff --git a/src/com/android/email/mail/store/ImapFolder.java b/src/com/android/email/mail/store/ImapFolder.java index ead78d447..e13366153 100644 --- a/src/com/android/email/mail/store/ImapFolder.java +++ b/src/com/android/email/mail/store/ImapFolder.java @@ -29,8 +29,6 @@ import com.android.email.mail.store.imap.ImapList; import com.android.email.mail.store.imap.ImapResponse; import com.android.email.mail.store.imap.ImapString; import com.android.email.mail.store.imap.ImapUtility; -import com.android.email.mail.transport.CountingOutputStream; -import com.android.email.mail.transport.EOLConvertingOutputStream; import com.android.email2.ui.MailActivityEmail; import com.android.emailcommon.Logging; import com.android.emailcommon.internet.BinaryTempFileBody; @@ -48,6 +46,8 @@ import com.android.emailcommon.mail.MessagingException; import com.android.emailcommon.mail.Part; import com.android.emailcommon.provider.Mailbox; import com.android.emailcommon.service.SearchParams; +import com.android.emailcommon.utility.CountingOutputStream; +import com.android.emailcommon.utility.EOLConvertingOutputStream; import com.android.emailcommon.utility.Utility; import com.google.common.annotations.VisibleForTesting; diff --git a/src/com/android/email/mail/store/ImapStore.java b/src/com/android/email/mail/store/ImapStore.java index 4125cd634..c404b2fc9 100644 --- a/src/com/android/email/mail/store/ImapStore.java +++ b/src/com/android/email/mail/store/ImapStore.java @@ -28,7 +28,6 @@ import com.android.email.LegacyConversions; import com.android.email.Preferences; import com.android.email.R; import com.android.email.mail.Store; -import com.android.email.mail.Transport; import com.android.email.mail.store.imap.ImapConstants; import com.android.email.mail.store.imap.ImapResponse; import com.android.email.mail.store.imap.ImapString; @@ -41,6 +40,7 @@ import com.android.emailcommon.mail.Flag; import com.android.emailcommon.mail.Folder; import com.android.emailcommon.mail.Message; import com.android.emailcommon.mail.MessagingException; +import com.android.emailcommon.mail.Transport; import com.android.emailcommon.provider.Account; import com.android.emailcommon.provider.HostAuth; import com.android.emailcommon.provider.Mailbox; diff --git a/src/com/android/email/mail/store/Pop3Store.java b/src/com/android/email/mail/store/Pop3Store.java index f78d19762..e813c1401 100644 --- a/src/com/android/email/mail/store/Pop3Store.java +++ b/src/com/android/email/mail/store/Pop3Store.java @@ -22,7 +22,6 @@ import android.util.Log; import com.android.email.R; import com.android.email.mail.Store; -import com.android.email.mail.Transport; import com.android.email.mail.transport.MailTransport; import com.android.email2.ui.MailActivityEmail; import com.android.emailcommon.Logging; @@ -31,6 +30,7 @@ import com.android.emailcommon.mail.AuthenticationFailedException; import com.android.emailcommon.mail.FetchProfile; import com.android.emailcommon.mail.Flag; import com.android.emailcommon.mail.Folder; +import com.android.emailcommon.mail.Transport; import com.android.emailcommon.mail.Folder.OpenMode; import com.android.emailcommon.mail.Message; import com.android.emailcommon.mail.MessagingException; diff --git a/src/com/android/email/mail/transport/MailTransport.java b/src/com/android/email/mail/transport/MailTransport.java index 7f59f46f7..f41799e33 100644 --- a/src/com/android/email/mail/transport/MailTransport.java +++ b/src/com/android/email/mail/transport/MailTransport.java @@ -16,11 +16,11 @@ package com.android.email.mail.transport; -import com.android.email.mail.Transport; import com.android.email2.ui.MailActivityEmail; 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; diff --git a/src/com/android/email/mail/transport/SmtpSender.java b/src/com/android/email/mail/transport/SmtpSender.java index 3ceb3307b..1d1fe3f97 100644 --- a/src/com/android/email/mail/transport/SmtpSender.java +++ b/src/com/android/email/mail/transport/SmtpSender.java @@ -21,7 +21,6 @@ import android.util.Base64; import android.util.Log; import com.android.email.mail.Sender; -import com.android.email.mail.Transport; import com.android.email2.ui.MailActivityEmail; import com.android.emailcommon.Logging; import com.android.emailcommon.internet.Rfc822Output; @@ -29,9 +28,11 @@ 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; @@ -45,6 +46,9 @@ import javax.net.ssl.SSLException; */ public class SmtpSender extends Sender { + 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; @@ -68,12 +72,12 @@ public class SmtpSender extends Sender { } // defaults, which can be changed by security modifiers int connectionSecurity = Transport.CONNECTION_SECURITY_NONE; - int defaultPort = 587; + 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 = 465; + defaultPort = DEFAULT_SMTP_SSL_PORT; } else if ((sendAuth.mFlags & HostAuth.FLAG_TLS) != 0) { connectionSecurity = Transport.CONNECTION_SECURITY_TLS; } diff --git a/src/com/android/email/provider/EmailProvider.java b/src/com/android/email/provider/EmailProvider.java index f12237c25..556504aea 100644 --- a/src/com/android/email/provider/EmailProvider.java +++ b/src/com/android/email/provider/EmailProvider.java @@ -187,6 +187,7 @@ public class EmailProvider extends ContentProvider { private static final int ACCOUNT_DEFAULT_ID = ACCOUNT_BASE + 5; private static final int ACCOUNT_CHECK = ACCOUNT_BASE + 6; private static final int ACCOUNT_PICK_TRASH_FOLDER = ACCOUNT_BASE + 7; + private static final int ACCOUNT_PICK_SENT_FOLDER = ACCOUNT_BASE + 8; private static final int MAILBOX_BASE = 0x1000; private static final int MAILBOX = MAILBOX_BASE; @@ -475,6 +476,7 @@ public class EmailProvider extends ContentProvider { matcher.addURI(EmailContent.AUTHORITY, "uidefaultrecentfolders/#", UI_DEFAULT_RECENT_FOLDERS); matcher.addURI(EmailContent.AUTHORITY, "pickTrashFolder/#", ACCOUNT_PICK_TRASH_FOLDER); + matcher.addURI(EmailContent.AUTHORITY, "pickSentFolder/#", ACCOUNT_PICK_SENT_FOLDER); } /** @@ -1656,6 +1658,8 @@ outer: switch (match) { case ACCOUNT_PICK_TRASH_FOLDER: return pickTrashFolder(uri); + case ACCOUNT_PICK_SENT_FOLDER: + return pickSentFolder(uri); case UI_FOLDER: return uiUpdateFolder(uri, values); case UI_RECENT_FOLDERS: @@ -3742,6 +3746,7 @@ outer: public static final String PICKER_UI_ACCOUNT = "picker_ui_account"; public static final String PICKER_MAILBOX_TYPE = "picker_mailbox_type"; public static final String PICKER_MESSAGE_ID = "picker_message_id"; + public static final String PICKER_HEADER_ID = "picker_header_id"; private int uiDeleteMessage(Uri uri) { final Context context = getContext(); @@ -3767,7 +3772,7 @@ outer: return uiUpdateMessage(uri, values); } - private int pickTrashFolder(Uri uri) { + private int pickFolder(Uri uri, int type, int headerId) { Context context = getContext(); Long acctId = Long.parseLong(uri.getLastPathSegment()); // For push imap, for example, we want the user to select the trash mailbox @@ -3779,7 +3784,8 @@ outer: new com.android.mail.providers.Account(ac); Intent intent = new Intent(context, FolderPickerActivity.class); intent.putExtra(PICKER_UI_ACCOUNT, uiAccount); - intent.putExtra(PICKER_MAILBOX_TYPE, Mailbox.TYPE_TRASH); + intent.putExtra(PICKER_MAILBOX_TYPE, type); + intent.putExtra(PICKER_HEADER_ID, headerId); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); return 1; @@ -3790,6 +3796,14 @@ outer: } } + private int pickTrashFolder(Uri uri) { + return pickFolder(uri, Mailbox.TYPE_TRASH, R.string.trash_folder_selection_title); + } + + private int pickSentFolder(Uri uri) { + return pickFolder(uri, Mailbox.TYPE_SENT, R.string.sent_folder_selection_title); + } + private Cursor uiUndo(String[] projection) { // First see if we have any operations saved // TODO: Make sure seq matches diff --git a/src/com/android/email/provider/FolderPickerActivity.java b/src/com/android/email/provider/FolderPickerActivity.java index 1c6b825ca..f00966ebe 100644 --- a/src/com/android/email/provider/FolderPickerActivity.java +++ b/src/com/android/email/provider/FolderPickerActivity.java @@ -38,7 +38,12 @@ public class FolderPickerActivity extends Activity implements FolderPickerCallba i.getParcelableExtra(EmailProvider.PICKER_UI_ACCOUNT); mAccountId = Long.parseLong(account.uri.getLastPathSegment()); mMailboxType = i.getIntExtra(EmailProvider.PICKER_MAILBOX_TYPE, -1); - new FolderSelectionDialog(this, account, this).show(); + int headerId = i.getIntExtra(EmailProvider.PICKER_HEADER_ID, 0); + if (headerId == 0) { + finish(); + return; + } + new FolderSelectionDialog(this, account, this, headerId).show(); } @Override diff --git a/src/com/android/email/provider/FolderSelectionDialog.java b/src/com/android/email/provider/FolderSelectionDialog.java index 74a182e7d..83fb65e3f 100644 --- a/src/com/android/email/provider/FolderSelectionDialog.java +++ b/src/com/android/email/provider/FolderSelectionDialog.java @@ -46,12 +46,12 @@ public class FolderSelectionDialog implements OnClickListener, OnMultiChoiceClic final private FolderPickerCallback mCallback; public FolderSelectionDialog(final Context context, Account account, - FolderPickerCallback callback) { + FolderPickerCallback callback, int headerId) { mCallback = callback; // Mapping of a folder's uri to its checked state mCheckedState = new HashMap(); AlertDialog.Builder builder = new AlertDialog.Builder(context); - builder.setTitle(R.string.trash_folder_selection_title); + builder.setTitle(headerId); builder.setPositiveButton(R.string.ok, this); builder.setNegativeButton(R.string.create_new_folder, this); final Cursor foldersCursor = context.getContentResolver().query(