1 /* 2 * Copyright (C) 2008 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.email.mail.store; 18 19 import android.content.Context; 20 import android.os.Build; 21 import android.os.Bundle; 22 import android.telephony.TelephonyManager; 23 import android.text.TextUtils; 24 import android.util.Base64; 25 26 import com.android.email.LegacyConversions; 27 import com.android.email.Preferences; 28 import com.android.email.mail.Store; 29 import com.android.email.mail.store.imap.ImapConstants; 30 import com.android.email.mail.store.imap.ImapResponse; 31 import com.android.email.mail.store.imap.ImapString; 32 import com.android.email.mail.transport.MailTransport; 33 import com.android.emailcommon.Logging; 34 import com.android.emailcommon.VendorPolicyLoader; 35 import com.android.emailcommon.internet.MimeMessage; 36 import com.android.emailcommon.mail.AuthenticationFailedException; 37 import com.android.emailcommon.mail.Flag; 38 import com.android.emailcommon.mail.Folder; 39 import com.android.emailcommon.mail.Message; 40 import com.android.emailcommon.mail.MessagingException; 41 import com.android.emailcommon.provider.Account; 42 import com.android.emailcommon.provider.HostAuth; 43 import com.android.emailcommon.provider.Mailbox; 44 import com.android.emailcommon.service.EmailServiceProxy; 45 import com.android.emailcommon.utility.Utility; 46 import com.android.mail.utils.LogUtils; 47 import com.beetstra.jutf7.CharsetProvider; 48 import com.google.common.annotations.VisibleForTesting; 49 50 import java.io.IOException; 51 import java.io.InputStream; 52 import java.nio.ByteBuffer; 53 import java.nio.charset.Charset; 54 import java.security.MessageDigest; 55 import java.security.NoSuchAlgorithmException; 56 import java.util.Collection; 57 import java.util.HashMap; 58 import java.util.List; 59 import java.util.Set; 60 import java.util.concurrent.ConcurrentLinkedQueue; 61 import java.util.regex.Pattern; 62 63 64 /** 65 * <pre> 66 * TODO Need to start keeping track of UIDVALIDITY 67 * TODO Need a default response handler for things like folder updates 68 * TODO In fetch(), if we need a ImapMessage and were given 69 * something else we can try to do a pre-fetch first. 70 * TODO Collect ALERT messages and show them to users. 71 * 72 * ftp://ftp.isi.edu/in-notes/rfc2683.txt When a client asks for 73 * certain information in a FETCH command, the server may return the requested 74 * information in any order, not necessarily in the order that it was requested. 75 * Further, the server may return the information in separate FETCH responses 76 * and may also return information that was not explicitly requested (to reflect 77 * to the client changes in the state of the subject message). 78 * </pre> 79 */ 80 public class ImapStore extends Store { 81 /** Charset used for converting folder names to and from UTF-7 as defined by RFC 3501. */ 82 private static final Charset MODIFIED_UTF_7_CHARSET = 83 new CharsetProvider().charsetForName("X-RFC-3501"); 84 85 @VisibleForTesting static String sImapId = null; 86 @VisibleForTesting String mPathPrefix; 87 @VisibleForTesting String mPathSeparator; 88 89 private final ConcurrentLinkedQueue<ImapConnection> mConnectionPool = 90 new ConcurrentLinkedQueue<ImapConnection>(); 91 92 /** 93 * Static named constructor. 94 */ newInstance(Account account, Context context)95 public static Store newInstance(Account account, Context context) throws MessagingException { 96 return new ImapStore(context, account); 97 } 98 99 /** 100 * Creates a new store for the given account. Always use 101 * {@link #newInstance(Account, Context)} to create an IMAP store. 102 */ ImapStore(Context context, Account account)103 private ImapStore(Context context, Account account) throws MessagingException { 104 mContext = context; 105 mAccount = account; 106 107 HostAuth recvAuth = account.getOrCreateHostAuthRecv(context); 108 if (recvAuth == null) { 109 throw new MessagingException("No HostAuth in ImapStore?"); 110 } 111 mTransport = new MailTransport(context, "IMAP", recvAuth); 112 113 String[] userInfo = recvAuth.getLogin(); 114 if (userInfo != null) { 115 mUsername = userInfo[0]; 116 mPassword = userInfo[1]; 117 } else { 118 mUsername = null; 119 mPassword = null; 120 } 121 mPathPrefix = recvAuth.mDomain; 122 } 123 124 @VisibleForTesting getConnectionPoolForTest()125 Collection<ImapConnection> getConnectionPoolForTest() { 126 return mConnectionPool; 127 } 128 129 /** 130 * For testing only. Injects a different root transport (it will be copied using 131 * newInstanceWithConfiguration() each time IMAP sets up a new channel). The transport 132 * should already be set up and ready to use. Do not use for real code. 133 * @param testTransport The Transport to inject and use for all future communication. 134 */ 135 @VisibleForTesting setTransportForTest(MailTransport testTransport)136 void setTransportForTest(MailTransport testTransport) { 137 mTransport = testTransport; 138 } 139 140 /** 141 * Return, or create and return, an string suitable for use in an IMAP ID message. 142 * This is constructed similarly to the way the browser sets up its user-agent strings. 143 * See RFC 2971 for more details. The output of this command will be a series of key-value 144 * pairs delimited by spaces (there is no point in returning a structured result because 145 * this will be sent as-is to the IMAP server). No tokens, parenthesis or "ID" are included, 146 * because some connections may append additional values. 147 * 148 * The following IMAP ID keys may be included: 149 * name Android package name of the program 150 * os "android" 151 * os-version "version; model; build-id" 152 * vendor Vendor of the client/server 153 * x-android-device-model Model (only revealed if release build) 154 * x-android-net-operator Mobile network operator (if known) 155 * AGUID A device+account UID 156 * 157 * In addition, a vendor policy .apk can append key/value pairs. 158 * 159 * @param userName the username of the account 160 * @param host the host (server) of the account 161 * @param capabilities a list of the capabilities from the server 162 * @return a String for use in an IMAP ID message. 163 */ getImapId(Context context, String userName, String host, String capabilities)164 public static String getImapId(Context context, String userName, String host, 165 String capabilities) { 166 // The first section is global to all IMAP connections, and generates the fixed 167 // values in any IMAP ID message 168 synchronized (ImapStore.class) { 169 if (sImapId == null) { 170 TelephonyManager tm = 171 (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); 172 String networkOperator = tm.getNetworkOperatorName(); 173 if (networkOperator == null) networkOperator = ""; 174 175 sImapId = makeCommonImapId(context.getPackageName(), Build.VERSION.RELEASE, 176 Build.VERSION.CODENAME, Build.MODEL, Build.ID, Build.MANUFACTURER, 177 networkOperator); 178 } 179 } 180 181 // This section is per Store, and adds in a dynamic elements like UID's. 182 // We don't cache the result of this work, because the caller does anyway. 183 StringBuilder id = new StringBuilder(sImapId); 184 185 // Optionally add any vendor-supplied id keys 186 String vendorId = VendorPolicyLoader.getInstance(context).getImapIdValues(userName, host, 187 capabilities); 188 if (vendorId != null) { 189 id.append(' '); 190 id.append(vendorId); 191 } 192 193 // Generate a UID that mixes a "stable" device UID with the email address 194 try { 195 String devUID = Preferences.getPreferences(context).getDeviceUID(); 196 MessageDigest messageDigest; 197 messageDigest = MessageDigest.getInstance("SHA-1"); 198 messageDigest.update(userName.getBytes()); 199 messageDigest.update(devUID.getBytes()); 200 byte[] uid = messageDigest.digest(); 201 String hexUid = Base64.encodeToString(uid, Base64.NO_WRAP); 202 id.append(" \"AGUID\" \""); 203 id.append(hexUid); 204 id.append('\"'); 205 } catch (NoSuchAlgorithmException e) { 206 LogUtils.d(Logging.LOG_TAG, "couldn't obtain SHA-1 hash for device UID"); 207 } 208 return id.toString(); 209 } 210 211 /** 212 * Helper function that actually builds the static part of the IMAP ID string. This is 213 * separated from getImapId for testability. There is no escaping or encoding in IMAP ID so 214 * any rogue chars must be filtered here. 215 * 216 * @param packageName context.getPackageName() 217 * @param version Build.VERSION.RELEASE 218 * @param codeName Build.VERSION.CODENAME 219 * @param model Build.MODEL 220 * @param id Build.ID 221 * @param vendor Build.MANUFACTURER 222 * @param networkOperator TelephonyManager.getNetworkOperatorName() 223 * @return the static (never changes) portion of the IMAP ID 224 */ 225 @VisibleForTesting makeCommonImapId(String packageName, String version, String codeName, String model, String id, String vendor, String networkOperator)226 static String makeCommonImapId(String packageName, String version, 227 String codeName, String model, String id, String vendor, String networkOperator) { 228 229 // Before building up IMAP ID string, pre-filter the input strings for "legal" chars 230 // This is using a fairly arbitrary char set intended to pass through most reasonable 231 // version, model, and vendor strings: a-z A-Z 0-9 - _ + = ; : . , / <space> 232 // The most important thing is *not* to pass parens, quotes, or CRLF, which would break 233 // the format of the IMAP ID list. 234 Pattern p = Pattern.compile("[^a-zA-Z0-9-_\\+=;:\\.,/ ]"); 235 packageName = p.matcher(packageName).replaceAll(""); 236 version = p.matcher(version).replaceAll(""); 237 codeName = p.matcher(codeName).replaceAll(""); 238 model = p.matcher(model).replaceAll(""); 239 id = p.matcher(id).replaceAll(""); 240 vendor = p.matcher(vendor).replaceAll(""); 241 networkOperator = p.matcher(networkOperator).replaceAll(""); 242 243 // "name" "com.android.email" 244 StringBuffer sb = new StringBuffer("\"name\" \""); 245 sb.append(packageName); 246 sb.append("\""); 247 248 // "os" "android" 249 sb.append(" \"os\" \"android\""); 250 251 // "os-version" "version; build-id" 252 sb.append(" \"os-version\" \""); 253 if (version.length() > 0) { 254 sb.append(version); 255 } else { 256 // default to "1.0" 257 sb.append("1.0"); 258 } 259 // add the build ID or build # 260 if (id.length() > 0) { 261 sb.append("; "); 262 sb.append(id); 263 } 264 sb.append("\""); 265 266 // "vendor" "the vendor" 267 if (vendor.length() > 0) { 268 sb.append(" \"vendor\" \""); 269 sb.append(vendor); 270 sb.append("\""); 271 } 272 273 // "x-android-device-model" the device model (on release builds only) 274 if ("REL".equals(codeName)) { 275 if (model.length() > 0) { 276 sb.append(" \"x-android-device-model\" \""); 277 sb.append(model); 278 sb.append("\""); 279 } 280 } 281 282 // "x-android-mobile-net-operator" "name of network operator" 283 if (networkOperator.length() > 0) { 284 sb.append(" \"x-android-mobile-net-operator\" \""); 285 sb.append(networkOperator); 286 sb.append("\""); 287 } 288 289 return sb.toString(); 290 } 291 292 293 @Override getFolder(String name)294 public Folder getFolder(String name) { 295 return new ImapFolder(this, name); 296 } 297 298 /** 299 * Creates a mailbox hierarchy out of the flat data provided by the server. 300 */ 301 @VisibleForTesting createHierarchy(HashMap<String, ImapFolder> mailboxes)302 static void createHierarchy(HashMap<String, ImapFolder> mailboxes) { 303 Set<String> pathnames = mailboxes.keySet(); 304 for (String path : pathnames) { 305 final ImapFolder folder = mailboxes.get(path); 306 final Mailbox mailbox = folder.mMailbox; 307 int delimiterIdx = mailbox.mServerId.lastIndexOf(mailbox.mDelimiter); 308 long parentKey = Mailbox.NO_MAILBOX; 309 String parentPath = null; 310 if (delimiterIdx != -1) { 311 parentPath = path.substring(0, delimiterIdx); 312 final ImapFolder parentFolder = mailboxes.get(parentPath); 313 final Mailbox parentMailbox = (parentFolder == null) ? null : parentFolder.mMailbox; 314 if (parentMailbox != null) { 315 parentKey = parentMailbox.mId; 316 parentMailbox.mFlags 317 |= (Mailbox.FLAG_HAS_CHILDREN | Mailbox.FLAG_CHILDREN_VISIBLE); 318 } 319 } 320 mailbox.mParentKey = parentKey; 321 mailbox.mParentServerId = parentPath; 322 } 323 } 324 325 /** 326 * Creates a {@link Folder} and associated {@link Mailbox}. If the folder does not already 327 * exist in the local database, a new row will immediately be created in the mailbox table. 328 * Otherwise, the existing row will be used. Any changes to existing rows, will not be stored 329 * to the database immediately. 330 * @param accountId The ID of the account the mailbox is to be associated with 331 * @param mailboxPath The path of the mailbox to add 332 * @param delimiter A path delimiter. May be {@code null} if there is no delimiter. 333 * @param selectable If {@code true}, the mailbox can be selected and used to store messages. 334 * @param mailbox If not null, mailbox is used instead of querying for the Mailbox. 335 */ addMailbox(Context context, long accountId, String mailboxPath, char delimiter, boolean selectable, Mailbox mailbox)336 private ImapFolder addMailbox(Context context, long accountId, String mailboxPath, 337 char delimiter, boolean selectable, Mailbox mailbox) { 338 ImapFolder folder = (ImapFolder) getFolder(mailboxPath); 339 if (mailbox == null) { 340 mailbox = Mailbox.getMailboxForPath(context, accountId, mailboxPath); 341 } 342 if (mailbox.isSaved()) { 343 // existing mailbox 344 // mailbox retrieved from database; save hash _before_ updating fields 345 folder.mHash = mailbox.getHashes(); 346 } 347 updateMailbox(mailbox, accountId, mailboxPath, delimiter, selectable, 348 LegacyConversions.inferMailboxTypeFromName(context, mailboxPath)); 349 if (folder.mHash == null) { 350 // new mailbox 351 // save hash after updating. allows tracking changes if the mailbox is saved 352 // outside of #saveMailboxList() 353 folder.mHash = mailbox.getHashes(); 354 // We must save this here to make sure we have a valid ID for later 355 mailbox.save(mContext); 356 } 357 folder.mMailbox = mailbox; 358 return folder; 359 } 360 361 /** 362 * Persists the folders in the given list. 363 */ saveMailboxList(Context context, HashMap<String, ImapFolder> folderMap)364 private static void saveMailboxList(Context context, HashMap<String, ImapFolder> folderMap) { 365 for (ImapFolder imapFolder : folderMap.values()) { 366 imapFolder.save(context); 367 } 368 } 369 370 @Override updateFolders()371 public Folder[] updateFolders() throws MessagingException { 372 // TODO: There is nothing that ever closes this connection. Trouble is, it's not exactly 373 // clear when we should close it, we'd like to keep it open until we're really done 374 // using it. 375 ImapConnection connection = getConnection(); 376 try { 377 HashMap<String, ImapFolder> mailboxes = new HashMap<String, ImapFolder>(); 378 // Establish a connection to the IMAP server; if necessary 379 // This ensures a valid prefix if the prefix is automatically set by the server 380 connection.executeSimpleCommand(ImapConstants.NOOP); 381 String imapCommand = ImapConstants.LIST + " \"\" \"*\""; 382 if (mPathPrefix != null) { 383 imapCommand = ImapConstants.LIST + " \"\" \"" + mPathPrefix + "*\""; 384 } 385 List<ImapResponse> responses = connection.executeSimpleCommand(imapCommand); 386 for (ImapResponse response : responses) { 387 // S: * LIST (\Noselect) "/" ~/Mail/foo 388 if (response.isDataResponse(0, ImapConstants.LIST)) { 389 // Get folder name. 390 ImapString encodedFolder = response.getStringOrEmpty(3); 391 if (encodedFolder.isEmpty()) continue; 392 393 String folderName = decodeFolderName(encodedFolder.getString(), mPathPrefix); 394 395 if (ImapConstants.INBOX.equalsIgnoreCase(folderName)) continue; 396 397 // Parse attributes. 398 boolean selectable = 399 !response.getListOrEmpty(1).contains(ImapConstants.FLAG_NO_SELECT); 400 String delimiter = response.getStringOrEmpty(2).getString(); 401 char delimiterChar = '\0'; 402 if (!TextUtils.isEmpty(delimiter)) { 403 delimiterChar = delimiter.charAt(0); 404 } 405 ImapFolder folder = addMailbox( 406 mContext, mAccount.mId, folderName, delimiterChar, selectable, null); 407 mailboxes.put(folderName, folder); 408 } 409 } 410 411 // In order to properly map INBOX -> Inbox, handle it as a special case. 412 final Mailbox inbox = 413 Mailbox.restoreMailboxOfType(mContext, mAccount.mId, Mailbox.TYPE_INBOX); 414 final ImapFolder newFolder = addMailbox( 415 mContext, mAccount.mId, inbox.mServerId, '\0', true /*selectable*/, inbox); 416 mailboxes.put(ImapConstants.INBOX, newFolder); 417 418 createHierarchy(mailboxes); 419 saveMailboxList(mContext, mailboxes); 420 return mailboxes.values().toArray(new Folder[] {}); 421 } catch (IOException ioe) { 422 connection.close(); 423 throw new MessagingException("Unable to get folder list.", ioe); 424 } catch (AuthenticationFailedException afe) { 425 // We do NOT want this connection pooled, or we will continue to send NOOP and SELECT 426 // commands to the server 427 connection.destroyResponses(); 428 connection = null; 429 throw afe; 430 } finally { 431 if (connection != null) { 432 poolConnection(connection); 433 } 434 } 435 } 436 437 @Override checkSettings()438 public Bundle checkSettings() throws MessagingException { 439 int result = MessagingException.NO_ERROR; 440 Bundle bundle = new Bundle(); 441 ImapConnection connection = new ImapConnection(this, mUsername, mPassword); 442 try { 443 connection.open(); 444 connection.close(); 445 } catch (IOException ioe) { 446 bundle.putString(EmailServiceProxy.VALIDATE_BUNDLE_ERROR_MESSAGE, ioe.getMessage()); 447 result = MessagingException.IOERROR; 448 } finally { 449 connection.destroyResponses(); 450 } 451 bundle.putInt(EmailServiceProxy.VALIDATE_BUNDLE_RESULT_CODE, result); 452 return bundle; 453 } 454 455 /** 456 * Returns whether or not the prefix has been set by the user. This can be determined by 457 * the fact that the prefix is set, but, the path separator is not set. 458 */ isUserPrefixSet()459 boolean isUserPrefixSet() { 460 return TextUtils.isEmpty(mPathSeparator) && !TextUtils.isEmpty(mPathPrefix); 461 } 462 463 /** Sets the path separator */ setPathSeparator(String pathSeparator)464 void setPathSeparator(String pathSeparator) { 465 mPathSeparator = pathSeparator; 466 } 467 468 /** Sets the prefix */ setPathPrefix(String pathPrefix)469 void setPathPrefix(String pathPrefix) { 470 mPathPrefix = pathPrefix; 471 } 472 473 /** Gets the context for this store */ getContext()474 Context getContext() { 475 return mContext; 476 } 477 478 /** Returns a clone of the transport associated with this store. */ cloneTransport()479 MailTransport cloneTransport() { 480 return mTransport.clone(); 481 } 482 483 /** 484 * Fixes the path prefix, if necessary. The path prefix must always end with the 485 * path separator. 486 */ ensurePrefixIsValid()487 void ensurePrefixIsValid() { 488 // Make sure the path prefix ends with the path separator 489 if (!TextUtils.isEmpty(mPathPrefix) && !TextUtils.isEmpty(mPathSeparator)) { 490 if (!mPathPrefix.endsWith(mPathSeparator)) { 491 mPathPrefix = mPathPrefix + mPathSeparator; 492 } 493 } 494 } 495 496 /** 497 * Gets a connection if one is available from the pool, or creates a new one if not. 498 */ getConnection()499 ImapConnection getConnection() { 500 ImapConnection connection = null; 501 while ((connection = mConnectionPool.poll()) != null) { 502 try { 503 connection.setStore(this, mUsername, mPassword); 504 connection.executeSimpleCommand(ImapConstants.NOOP); 505 break; 506 } catch (MessagingException e) { 507 // Fall through 508 } catch (IOException e) { 509 // Fall through 510 } 511 connection.close(); 512 connection = null; 513 } 514 if (connection == null) { 515 connection = new ImapConnection(this, mUsername, mPassword); 516 } 517 return connection; 518 } 519 520 /** 521 * Save a {@link ImapConnection} in the pool for reuse. Any responses associated with the 522 * connection are destroyed before adding the connection to the pool. 523 */ poolConnection(ImapConnection connection)524 void poolConnection(ImapConnection connection) { 525 if (connection != null) { 526 connection.destroyResponses(); 527 mConnectionPool.add(connection); 528 } 529 } 530 531 /** 532 * Prepends the folder name with the given prefix and UTF-7 encodes it. 533 */ encodeFolderName(String name, String prefix)534 static String encodeFolderName(String name, String prefix) { 535 // do NOT add the prefix to the special name "INBOX" 536 if (ImapConstants.INBOX.equalsIgnoreCase(name)) return name; 537 538 // Prepend prefix 539 if (prefix != null) { 540 name = prefix + name; 541 } 542 543 // TODO bypass the conversion if name doesn't have special char. 544 ByteBuffer bb = MODIFIED_UTF_7_CHARSET.encode(name); 545 byte[] b = new byte[bb.limit()]; 546 bb.get(b); 547 548 return Utility.fromAscii(b); 549 } 550 551 /** 552 * UTF-7 decodes the folder name and removes the given path prefix. 553 */ decodeFolderName(String name, String prefix)554 static String decodeFolderName(String name, String prefix) { 555 // TODO bypass the conversion if name doesn't have special char. 556 String folder; 557 folder = MODIFIED_UTF_7_CHARSET.decode(ByteBuffer.wrap(Utility.toAscii(name))).toString(); 558 if ((prefix != null) && folder.startsWith(prefix)) { 559 folder = folder.substring(prefix.length()); 560 } 561 return folder; 562 } 563 564 /** 565 * Returns UIDs of Messages joined with "," as the separator. 566 */ joinMessageUids(Message[] messages)567 static String joinMessageUids(Message[] messages) { 568 StringBuilder sb = new StringBuilder(); 569 boolean notFirst = false; 570 for (Message m : messages) { 571 if (notFirst) { 572 sb.append(','); 573 } 574 sb.append(m.getUid()); 575 notFirst = true; 576 } 577 return sb.toString(); 578 } 579 580 static class ImapMessage extends MimeMessage { ImapMessage(String uid, ImapFolder folder)581 ImapMessage(String uid, ImapFolder folder) { 582 mUid = uid; 583 mFolder = folder; 584 } 585 setSize(int size)586 public void setSize(int size) { 587 mSize = size; 588 } 589 590 @Override parse(InputStream in)591 public void parse(InputStream in) throws IOException, MessagingException { 592 super.parse(in); 593 } 594 setFlagInternal(Flag flag, boolean set)595 public void setFlagInternal(Flag flag, boolean set) throws MessagingException { 596 super.setFlag(flag, set); 597 } 598 599 @Override setFlag(Flag flag, boolean set)600 public void setFlag(Flag flag, boolean set) throws MessagingException { 601 super.setFlag(flag, set); 602 mFolder.setFlags(new Message[] { this }, new Flag[] { flag }, set); 603 } 604 } 605 606 static class ImapException extends MessagingException { 607 private static final long serialVersionUID = 1L; 608 609 String mAlertText; 610 ImapException(String message, String alertText, Throwable throwable)611 public ImapException(String message, String alertText, Throwable throwable) { 612 super(message, throwable); 613 mAlertText = alertText; 614 } 615 ImapException(String message, String alertText)616 public ImapException(String message, String alertText) { 617 super(message); 618 mAlertText = alertText; 619 } 620 getAlertText()621 public String getAlertText() { 622 return mAlertText; 623 } 624 setAlertText(String alertText)625 public void setAlertText(String alertText) { 626 mAlertText = alertText; 627 } 628 } 629 } 630