package com.android.email.mail.store; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketAddress; import java.net.URI; import java.net.URISyntaxException; import java.security.GeneralSecurityException; import java.security.SecureRandom; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.SSLException; import android.util.Config; import android.util.Log; import com.android.email.Email; import com.android.email.Utility; import com.android.email.mail.AuthenticationFailedException; import com.android.email.mail.FetchProfile; import com.android.email.mail.Flag; import com.android.email.mail.Folder; import com.android.email.mail.Message; import com.android.email.mail.MessageRetrievalListener; import com.android.email.mail.MessagingException; import com.android.email.mail.Store; import com.android.email.mail.CertificateValidationException; import com.android.email.mail.Folder.OpenMode; import com.android.email.mail.internet.MimeMessage; public class Pop3Store extends Store { public static final int CONNECTION_SECURITY_NONE = 0; public static final int CONNECTION_SECURITY_TLS_OPTIONAL = 1; public static final int CONNECTION_SECURITY_TLS_REQUIRED = 2; public static final int CONNECTION_SECURITY_SSL_REQUIRED = 3; public static final int CONNECTION_SECURITY_SSL_OPTIONAL = 4; private static final Flag[] PERMANENT_FLAGS = { Flag.DELETED }; private String mHost; private int mPort; private String mUsername; private String mPassword; private int mConnectionSecurity; private HashMap mFolders = new HashMap(); private Pop3Capabilities mCapabilities; // /** // * Detected latency, used for usage scaling. // * Usage scaling occurs when it is neccesary to get information about // * messages that could result in large data loads. This value allows // * the code that loads this data to decide between using large downloads // * (high latency) or multiple round trips (low latency) to accomplish // * the same thing. // * Default is Integer.MAX_VALUE implying massive latency so that the large // * download method is used by default until latency data is collected. // */ // private int mLatencyMs = Integer.MAX_VALUE; // // /** // * Detected throughput, used for usage scaling. // * Usage scaling occurs when it is neccesary to get information about // * messages that could result in large data loads. This value allows // * the code that loads this data to decide between using large downloads // * (high latency) or multiple round trips (low latency) to accomplish // * the same thing. // * Default is Integer.MAX_VALUE implying massive bandwidth so that the // * large download method is used by default until latency data is // * collected. // */ // private int mThroughputKbS = Integer.MAX_VALUE; /** * pop3://user:password@server:port CONNECTION_SECURITY_NONE * pop3+tls://user:password@server:port CONNECTION_SECURITY_TLS_OPTIONAL * pop3+tls+://user:password@server:port CONNECTION_SECURITY_TLS_REQUIRED * pop3+ssl+://user:password@server:port CONNECTION_SECURITY_SSL_REQUIRED * pop3+ssl://user:password@server:port CONNECTION_SECURITY_SSL_OPTIONAL * * @param _uri */ public Pop3Store(String _uri) throws MessagingException { URI uri; try { uri = new URI(_uri); } catch (URISyntaxException use) { throw new MessagingException("Invalid Pop3Store URI", use); } String scheme = uri.getScheme(); if (scheme.equals("pop3")) { mConnectionSecurity = CONNECTION_SECURITY_NONE; mPort = 110; } else if (scheme.equals("pop3+tls")) { mConnectionSecurity = CONNECTION_SECURITY_TLS_OPTIONAL; mPort = 110; } else if (scheme.equals("pop3+tls+")) { mConnectionSecurity = CONNECTION_SECURITY_TLS_REQUIRED; mPort = 110; } else if (scheme.equals("pop3+ssl+")) { mConnectionSecurity = CONNECTION_SECURITY_SSL_REQUIRED; mPort = 995; } else if (scheme.equals("pop3+ssl")) { mConnectionSecurity = CONNECTION_SECURITY_SSL_OPTIONAL; mPort = 995; } else { throw new MessagingException("Unsupported protocol"); } mHost = uri.getHost(); if (uri.getPort() != -1) { mPort = uri.getPort(); } if (uri.getUserInfo() != null) { String[] userInfoParts = uri.getUserInfo().split(":", 2); mUsername = userInfoParts[0]; if (userInfoParts.length > 1) { mPassword = userInfoParts[1]; } } } @Override public Folder getFolder(String name) throws MessagingException { Folder folder = mFolders.get(name); if (folder == null) { folder = new Pop3Folder(name); mFolders.put(folder.getName(), folder); } return folder; } @Override public Folder[] getPersonalNamespaces() throws MessagingException { return new Folder[] { getFolder("INBOX"), }; } @Override public void checkSettings() throws MessagingException { Pop3Folder folder = new Pop3Folder("INBOX"); folder.open(OpenMode.READ_WRITE); if (!mCapabilities.uidl) { /* * Run an additional test to see if UIDL is supported on the server. If it's not we * can't service this account. */ /* * If the server doesn't support UIDL it will return a - response, which causes * executeSimpleCommand to throw a MessagingException, exiting this method. */ folder.executeSimpleCommand("UIDL"); } folder.close(false); } class Pop3Folder extends Folder { private Socket mSocket; private InputStream mIn; private OutputStream mOut; private HashMap mUidToMsgMap = new HashMap(); private HashMap mMsgNumToMsgMap = new HashMap(); private HashMap mUidToMsgNumMap = new HashMap(); private String mName; private int mMessageCount; public Pop3Folder(String name) { this.mName = name; if (mName.equalsIgnoreCase("INBOX")) { mName = "INBOX"; } } @Override public synchronized void open(OpenMode mode) throws MessagingException { if (isOpen()) { return; } if (!mName.equalsIgnoreCase("INBOX")) { throw new MessagingException("Folder does not exist"); } try { SocketAddress socketAddress = new InetSocketAddress(mHost, mPort); if (mConnectionSecurity == CONNECTION_SECURITY_SSL_REQUIRED || mConnectionSecurity == CONNECTION_SECURITY_SSL_OPTIONAL) { SSLContext sslContext = SSLContext.getInstance("TLS"); final boolean secure = mConnectionSecurity == CONNECTION_SECURITY_SSL_REQUIRED; sslContext.init(null, new TrustManager[] { TrustManagerFactory.get(mHost, secure) }, new SecureRandom()); mSocket = sslContext.getSocketFactory().createSocket(); mSocket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT); mIn = new BufferedInputStream(mSocket.getInputStream(), 1024); mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512); } else { mSocket = new Socket(); mSocket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT); mIn = new BufferedInputStream(mSocket.getInputStream(), 1024); mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512); } mSocket.setSoTimeout(Store.SOCKET_READ_TIMEOUT); if (!isOpen()) { throw new MessagingException("Unable to connect socket"); } // Eat the banner executeSimpleCommand(null); mCapabilities = getCapabilities(); if (mConnectionSecurity == CONNECTION_SECURITY_TLS_OPTIONAL || mConnectionSecurity == CONNECTION_SECURITY_TLS_REQUIRED) { if (mCapabilities.stls) { writeLine("STLS"); SSLContext sslContext = SSLContext.getInstance("TLS"); boolean secure = mConnectionSecurity == CONNECTION_SECURITY_TLS_REQUIRED; sslContext.init(null, new TrustManager[] { TrustManagerFactory.get(mHost, secure) }, new SecureRandom()); mSocket = sslContext.getSocketFactory().createSocket(mSocket, mHost, mPort, true); mSocket.setSoTimeout(Store.SOCKET_READ_TIMEOUT); mIn = new BufferedInputStream(mSocket.getInputStream(), 1024); mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512); if (!isOpen()) { throw new MessagingException("Unable to connect socket"); } } else if (mConnectionSecurity == CONNECTION_SECURITY_TLS_REQUIRED) { throw new MessagingException("TLS not supported but required"); } } try { executeSimpleCommand("USER " + mUsername); executeSimpleCommand("PASS " + mPassword); } catch (MessagingException me) { throw new AuthenticationFailedException(null, me); } } catch (SSLException e) { throw new CertificateValidationException(e.getMessage(), e); } catch (GeneralSecurityException gse) { throw new MessagingException( "Unable to open connection to POP server due to security error.", gse); } catch (IOException ioe) { throw new MessagingException("Unable to open connection to POP server.", ioe); } String response = executeSimpleCommand("STAT"); String[] parts = response.split(" "); mMessageCount = Integer.parseInt(parts[1]); mUidToMsgMap.clear(); mMsgNumToMsgMap.clear(); mUidToMsgNumMap.clear(); } public boolean isOpen() { return (mIn != null && mOut != null && mSocket != null && mSocket.isConnected() && !mSocket.isClosed()); } @Override public OpenMode getMode() throws MessagingException { return OpenMode.READ_WRITE; } @Override public void close(boolean expunge) { try { executeSimpleCommand("QUIT"); } catch (Exception e) { /* * QUIT may fail if the connection is already closed. We don't care. It's just * being friendly. */ } closeIO(); } private void closeIO() { 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 String getName() { return mName; } @Override public boolean create(FolderType type) throws MessagingException { return false; } @Override public boolean exists() throws MessagingException { return mName.equalsIgnoreCase("INBOX"); } @Override public int getMessageCount() { return mMessageCount; } @Override public int getUnreadMessageCount() throws MessagingException { return -1; } @Override public Message getMessage(String uid) throws MessagingException { Pop3Message message = mUidToMsgMap.get(uid); if (message == null) { message = new Pop3Message(uid, this); } return message; } @Override public Message[] getMessages(int start, int end, MessageRetrievalListener listener) throws MessagingException { if (start < 1 || end < 1 || end < start) { throw new MessagingException(String.format("Invalid message set %d %d", start, end)); } try { indexMsgNums(start, end); } catch (IOException ioe) { throw new MessagingException("getMessages", ioe); } ArrayList messages = new ArrayList(); int i = 0; for (int msgNum = start; msgNum <= end; msgNum++) { Pop3Message message = mMsgNumToMsgMap.get(msgNum); if (listener != null) { listener.messageStarted(message.getUid(), i++, (end - start) + 1); } messages.add(message); if (listener != null) { listener.messageFinished(message, i++, (end - start) + 1); } } return messages.toArray(new Message[messages.size()]); } /** * Ensures that the given message set (from start to end inclusive) * has been queried so that uids are available in the local cache. * @param start * @param end * @throws MessagingException * @throws IOException */ private void indexMsgNums(int start, int end) throws MessagingException, IOException { int unindexedMessageCount = 0; for (int msgNum = start; msgNum <= end; msgNum++) { if (mMsgNumToMsgMap.get(msgNum) == null) { unindexedMessageCount++; } } if (unindexedMessageCount == 0) { return; } if (unindexedMessageCount < 50 && mMessageCount > 5000) { /* * In extreme cases we'll do a UIDL command per message instead of a bulk * download. */ for (int msgNum = start; msgNum <= end; msgNum++) { Pop3Message message = mMsgNumToMsgMap.get(msgNum); if (message == null) { String response = executeSimpleCommand("UIDL " + msgNum); int uidIndex = response.lastIndexOf(' '); String msgUid = response.substring(uidIndex + 1); message = new Pop3Message(msgUid, this); indexMessage(msgNum, message); } } } else { String response = executeSimpleCommand("UIDL"); while ((response = readLine()) != null) { if (response.equals(".")) { break; } String[] uidParts = response.split(" "); Integer msgNum = Integer.valueOf(uidParts[0]); String msgUid = uidParts[1]; if (msgNum >= start && msgNum <= end) { Pop3Message message = mMsgNumToMsgMap.get(msgNum); if (message == null) { message = new Pop3Message(msgUid, this); indexMessage(msgNum, message); } } } } } private void indexUids(ArrayList uids) throws MessagingException, IOException { HashSet unindexedUids = new HashSet(); for (String uid : uids) { if (mUidToMsgMap.get(uid) == null) { if (Config.LOGD) { Log.d(Email.LOG_TAG, "Need to index UID " + uid); } unindexedUids.add(uid); } } if (unindexedUids.size() == 0) { return; } /* * If we are missing uids in the cache the only sure way to * get them is to do a full UIDL list. A possible optimization * would be trying UIDL for the latest X messages and praying. */ String response = executeSimpleCommand("UIDL"); while ((response = readLine()) != null) { if (response.equals(".")) { break; } String[] uidParts = response.split(" "); Integer msgNum = Integer.valueOf(uidParts[0]); String msgUid = uidParts[1]; if (unindexedUids.contains(msgUid)) { if (Config.LOGD) { Log.d(Email.LOG_TAG, "Got msgNum " + msgNum + " for UID " + msgUid); } Pop3Message message = mUidToMsgMap.get(msgUid); if (message == null) { message = new Pop3Message(msgUid, this); } indexMessage(msgNum, message); } } } private void indexMessage(int msgNum, Pop3Message message) { if (Config.LOGD){ Log.d(Email.LOG_TAG, "Adding index for UID " + message.getUid() + " to msgNum " + msgNum); } mMsgNumToMsgMap.put(msgNum, message); mUidToMsgMap.put(message.getUid(), message); mUidToMsgNumMap.put(message.getUid(), msgNum); } @Override public Message[] getMessages(MessageRetrievalListener listener) throws MessagingException { throw new UnsupportedOperationException("Pop3: No getMessages"); } @Override public Message[] getMessages(String[] uids, MessageRetrievalListener listener) throws MessagingException { throw new UnsupportedOperationException("Pop3: No getMessages by uids"); } /** * Fetch the items contained in the FetchProfile into the given set of * Messages in as efficient a manner as possible. * @param messages * @param fp * @throws MessagingException */ public void fetch(Message[] messages, FetchProfile fp, MessageRetrievalListener listener) throws MessagingException { if (messages == null || messages.length == 0) { return; } ArrayList uids = new ArrayList(); for (Message message : messages) { uids.add(message.getUid()); } try { indexUids(uids); } catch (IOException ioe) { throw new MessagingException("fetch", ioe); } try { if (fp.contains(FetchProfile.Item.ENVELOPE)) { /* * We pass the listener only if there are other things to do in the * FetchProfile. Since fetchEnvelop works in bulk and eveything else * works one at a time if we let fetchEnvelope send events the * event would get sent twice. */ fetchEnvelope(messages, fp.size() == 1 ? listener : null); } } catch (IOException ioe) { throw new MessagingException("fetch", ioe); } for (int i = 0, count = messages.length; i < count; i++) { Message message = messages[i]; if (!(message instanceof Pop3Message)) { throw new MessagingException("Pop3Store.fetch called with non-Pop3 Message"); } Pop3Message pop3Message = (Pop3Message)message; try { if (listener != null && !fp.contains(FetchProfile.Item.ENVELOPE)) { listener.messageStarted(pop3Message.getUid(), i, count); } if (fp.contains(FetchProfile.Item.BODY)) { fetchBody(pop3Message, -1); } else if (fp.contains(FetchProfile.Item.BODY_SANE)) { /* * To convert the suggested download size we take the size * divided by the maximum line size (76). */ fetchBody(pop3Message, FETCH_BODY_SANE_SUGGESTED_SIZE / 76); } else if (fp.contains(FetchProfile.Item.STRUCTURE)) { /* * If the user is requesting STRUCTURE we are required to set the body * to null since we do not support the function. */ pop3Message.setBody(null); } if (listener != null && !(fp.contains(FetchProfile.Item.ENVELOPE) && fp.size() == 1)) { listener.messageFinished(message, i, count); } } catch (IOException ioe) { throw new MessagingException("Unable to fetch message", ioe); } } } private void fetchEnvelope(Message[] messages, MessageRetrievalListener listener) throws IOException, MessagingException { int unsizedMessages = 0; for (Message message : messages) { if (message.getSize() == -1) { unsizedMessages++; } } if (unsizedMessages == 0) { return; } if (unsizedMessages < 50 && mMessageCount > 5000) { /* * In extreme cases we'll do a command per message instead of a bulk request * to hopefully save some time and bandwidth. */ for (int i = 0, count = messages.length; i < count; i++) { Message message = messages[i]; if (!(message instanceof Pop3Message)) { throw new MessagingException("Pop3Store.fetch called with non-Pop3 Message"); } Pop3Message pop3Message = (Pop3Message)message; if (listener != null) { listener.messageStarted(pop3Message.getUid(), i, count); } String response = executeSimpleCommand(String.format("LIST %d", mUidToMsgNumMap.get(pop3Message.getUid()))); String[] listParts = response.split(" "); int msgNum = Integer.parseInt(listParts[1]); int msgSize = Integer.parseInt(listParts[2]); pop3Message.setSize(msgSize); if (listener != null) { listener.messageFinished(pop3Message, i, count); } } } else { HashSet msgUidIndex = new HashSet(); for (Message message : messages) { msgUidIndex.add(message.getUid()); } int i = 0, count = messages.length; String response = executeSimpleCommand("LIST"); while ((response = readLine()) != null) { if (response.equals(".")) { break; } String[] listParts = response.split(" "); int msgNum = Integer.parseInt(listParts[0]); int msgSize = Integer.parseInt(listParts[1]); Pop3Message pop3Message = mMsgNumToMsgMap.get(msgNum); if (pop3Message != null && msgUidIndex.contains(pop3Message.getUid())) { if (listener != null) { listener.messageStarted(pop3Message.getUid(), i, count); } pop3Message.setSize(msgSize); if (listener != null) { listener.messageFinished(pop3Message, i, count); } i++; } } } } /** * Fetches the body of the given message, limiting the stored data * to the specified number of lines. If lines is -1 the entire message * is fetched. This is implemented with RETR for lines = -1 or TOP * for any other value. If the server does not support TOP it is * emulated with RETR and extra lines are thrown away. * @param message * @param lines */ private void fetchBody(Pop3Message message, int lines) throws IOException, MessagingException { String response = null; if (lines == -1 || !mCapabilities.top) { response = executeSimpleCommand(String.format("RETR %d", mUidToMsgNumMap.get(message.getUid()))); } else { response = executeSimpleCommand(String.format("TOP %d %d", mUidToMsgNumMap.get(message.getUid()), lines)); } if (response != null) { try { message.parse(new Pop3ResponseInputStream(mIn)); if (lines == -1 || !mCapabilities.top) { message.setFlag(Flag.X_DOWNLOADED_FULL, true); } } catch (MessagingException me) { /* * If we're only downloading headers it's possible * we'll get a broken MIME message which we're not * real worried about. If we've downloaded the body * and can't parse it we need to let the user know. */ if (lines == -1) { throw me; } } } } @Override public Flag[] getPermanentFlags() throws MessagingException { return PERMANENT_FLAGS; } public void appendMessages(Message[] messages) throws MessagingException { } public void delete(boolean recurse) throws MessagingException { } @Override public String getUidFromMessageId(Message message) throws MessagingException { return null; } public Message[] expunge() throws MessagingException { return null; } @Override public void setFlags(Flag[] flags, boolean value) throws MessagingException { Message[] messages = getMessages(null); setFlags(messages, flags, value); } public void setFlags(Message[] messages, Flag[] flags, boolean value) throws MessagingException { if (!value || !Utility.arrayContains(flags, Flag.DELETED)) { /* * The only flagging we support is setting the Deleted flag. */ return; } ArrayList uids = new ArrayList(); try { for (Message message : messages) { uids.add(message.getUid()); } indexUids(uids); } catch (IOException ioe) { throw new MessagingException("Could not get message number for uid " + uids, ioe); } for (Message message : messages) { Integer msgNum = mUidToMsgNumMap.get(message.getUid()); if (msgNum == null) { MessagingException me = new MessagingException("Could not delete message " + message.getUid() + " because no msgNum found; permanent error"); me.setPermanentFailure(true); throw me; } executeSimpleCommand(String.format("DELE %s", msgNum)); } } // private boolean isRoundTripModeSuggested() { // long roundTripMethodMs = // (uncachedMessageCount * 2 * mLatencyMs); // long bulkMethodMs = // (mMessageCount * 58) / (mThroughputKbS * 1024 / 8) * 1000; // } private String readLine() throws IOException { StringBuffer sb = new StringBuffer(); int d = mIn.read(); if (d == -1) { throw new IOException("End of stream reached while trying to read line."); } do { if (((char)d) == '\r') { continue; } else if (((char)d) == '\n') { break; } else { sb.append((char)d); } } while ((d = mIn.read()) != -1); String ret = sb.toString(); if (Config.LOGD) { if (Email.DEBUG) { Log.d(Email.LOG_TAG, "<<< " + ret); } } return ret; } private void writeLine(String s) throws IOException { if (Config.LOGD) { if (Email.DEBUG) { Log.d(Email.LOG_TAG, ">>> " + s); } } mOut.write(s.getBytes()); mOut.write('\r'); mOut.write('\n'); mOut.flush(); } private Pop3Capabilities getCapabilities() throws IOException, MessagingException { Pop3Capabilities capabilities = new Pop3Capabilities(); try { String response = executeSimpleCommand("CAPA"); while ((response = readLine()) != null) { if (response.equals(".")) { break; } if (response.equalsIgnoreCase("STLS")){ capabilities.stls = true; } else if (response.equalsIgnoreCase("UIDL")) { capabilities.uidl = true; } else if (response.equalsIgnoreCase("PIPELINING")) { capabilities.pipelining = true; } else if (response.equalsIgnoreCase("USER")) { capabilities.user = true; } else if (response.equalsIgnoreCase("TOP")) { capabilities.top = true; } } } catch (MessagingException me) { /* * The server may not support the CAPA command, so we just eat this Exception * and allow the empty capabilities object to be returned. */ } return capabilities; } private String executeSimpleCommand(String command) throws MessagingException { try { open(OpenMode.READ_WRITE); if (Config.LOGV) { Log.v(Email.LOG_TAG, "POP3: command '" + command + "'"); } if (command != null) { writeLine(command); } String response = readLine(); if (Config.LOGV) { Log.v(Email.LOG_TAG, "POP3: response '" + command + "'"); } if (response.length() > 1 && response.charAt(0) == '-') { throw new MessagingException(response); } return response; } catch (MessagingException me) { throw me; } catch (Exception e) { closeIO(); throw new MessagingException("Unable to execute POP3 command", e); } } @Override public boolean supportsFetchingFlags() { return false; }//isFlagSupported @Override public boolean equals(Object o) { if (o instanceof Pop3Folder) { return ((Pop3Folder) o).mName.equals(mName); } return super.equals(o); } }//Pop3Folder class Pop3Message extends MimeMessage { public Pop3Message(String uid, Pop3Folder folder) throws MessagingException { mUid = uid; mFolder = folder; mSize = -1; mFlags.add(Flag.X_NO_SEEN_INFO); } public void setSize(int size) { mSize = size; } protected void parse(InputStream in) throws IOException, MessagingException { super.parse(in); } @Override public void setFlag(Flag flag, boolean set) throws MessagingException { super.setFlag(flag, set); mFolder.setFlags(new Message[] { this }, new Flag[] { flag }, set); } @Override public void delete(String trashFolderName) throws MessagingException { // try // { // Poor POP3 users, we can't copy the message to the Trash folder, but they still want a delete setFlag(Flag.DELETED, true); // } // catch (MessagingException me) // { // Log.w(Email.LOG_TAG, "Could not delete non-existant message", me); // } } } class Pop3Capabilities { public boolean stls; public boolean top; public boolean user; public boolean uidl; public boolean pipelining; public String toString() { return String.format("STLS %b, TOP %b, USER %b, UIDL %b, PIPELINING %b", stls, top, user, uidl, pipelining); } } class Pop3ResponseInputStream extends InputStream { InputStream mIn; boolean mStartOfLine = true; boolean mFinished; public Pop3ResponseInputStream(InputStream in) { mIn = in; } @Override public int read() throws IOException { if (mFinished) { return -1; } int d = mIn.read(); if (mStartOfLine && d == '.') { d = mIn.read(); if (d == '\r') { mFinished = true; mIn.read(); return -1; } } mStartOfLine = (d == '\n'); return d; } } }