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