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 */ 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 */ 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 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 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 */ 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 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 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 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 if (delimiterIdx != -1) { 310 String parentPath = path.substring(0, delimiterIdx); 311 final ImapFolder parentFolder = mailboxes.get(parentPath); 312 final Mailbox parentMailbox = (parentFolder == null) ? null : parentFolder.mMailbox; 313 if (parentMailbox != null) { 314 parentKey = parentMailbox.mId; 315 parentMailbox.mFlags 316 |= (Mailbox.FLAG_HAS_CHILDREN | Mailbox.FLAG_CHILDREN_VISIBLE); 317 } 318 } 319 mailbox.mParentKey = parentKey; 320 } 321 } 322 323 /** 324 * Creates a {@link Folder} and associated {@link Mailbox}. If the folder does not already 325 * exist in the local database, a new row will immediately be created in the mailbox table. 326 * Otherwise, the existing row will be used. Any changes to existing rows, will not be stored 327 * to the database immediately. 328 * @param accountId The ID of the account the mailbox is to be associated with 329 * @param mailboxPath The path of the mailbox to add 330 * @param delimiter A path delimiter. May be {@code null} if there is no delimiter. 331 * @param selectable If {@code true}, the mailbox can be selected and used to store messages. 332 * @param mailbox If not null, mailbox is used instead of querying for the Mailbox. 333 */ 334 private ImapFolder addMailbox(Context context, long accountId, String mailboxPath, 335 char delimiter, boolean selectable, Mailbox mailbox) { 336 ImapFolder folder = (ImapFolder) getFolder(mailboxPath); 337 if (mailbox == null) { 338 mailbox = Mailbox.getMailboxForPath(context, accountId, mailboxPath); 339 } 340 if (mailbox.isSaved()) { 341 // existing mailbox 342 // mailbox retrieved from database; save hash _before_ updating fields 343 folder.mHash = mailbox.getHashes(); 344 } 345 updateMailbox(mailbox, accountId, mailboxPath, delimiter, selectable, 346 LegacyConversions.inferMailboxTypeFromName(context, mailboxPath)); 347 if (folder.mHash == null) { 348 // new mailbox 349 // save hash after updating. allows tracking changes if the mailbox is saved 350 // outside of #saveMailboxList() 351 folder.mHash = mailbox.getHashes(); 352 // We must save this here to make sure we have a valid ID for later 353 mailbox.save(mContext); 354 } 355 folder.mMailbox = mailbox; 356 return folder; 357 } 358 359 /** 360 * Persists the folders in the given list. 361 */ 362 private static void saveMailboxList(Context context, HashMap<String, ImapFolder> folderMap) { 363 for (ImapFolder imapFolder : folderMap.values()) { 364 imapFolder.save(context); 365 } 366 } 367 368 @Override 369 public Folder[] updateFolders() throws MessagingException { 370 // TODO: There is nothing that ever closes this connection. Trouble is, it's not exactly 371 // clear when we should close it, we'd like to keep it open until we're really done 372 // using it. 373 ImapConnection connection = getConnection(); 374 try { 375 HashMap<String, ImapFolder> mailboxes = new HashMap<String, ImapFolder>(); 376 // Establish a connection to the IMAP server; if necessary 377 // This ensures a valid prefix if the prefix is automatically set by the server 378 connection.executeSimpleCommand(ImapConstants.NOOP); 379 String imapCommand = ImapConstants.LIST + " \"\" \"*\""; 380 if (mPathPrefix != null) { 381 imapCommand = ImapConstants.LIST + " \"\" \"" + mPathPrefix + "*\""; 382 } 383 List<ImapResponse> responses = connection.executeSimpleCommand(imapCommand); 384 for (ImapResponse response : responses) { 385 // S: * LIST (\Noselect) "/" ~/Mail/foo 386 if (response.isDataResponse(0, ImapConstants.LIST)) { 387 // Get folder name. 388 ImapString encodedFolder = response.getStringOrEmpty(3); 389 if (encodedFolder.isEmpty()) continue; 390 391 String folderName = decodeFolderName(encodedFolder.getString(), mPathPrefix); 392 393 if (ImapConstants.INBOX.equalsIgnoreCase(folderName)) continue; 394 395 // Parse attributes. 396 boolean selectable = 397 !response.getListOrEmpty(1).contains(ImapConstants.FLAG_NO_SELECT); 398 String delimiter = response.getStringOrEmpty(2).getString(); 399 char delimiterChar = '\0'; 400 if (!TextUtils.isEmpty(delimiter)) { 401 delimiterChar = delimiter.charAt(0); 402 } 403 ImapFolder folder = addMailbox( 404 mContext, mAccount.mId, folderName, delimiterChar, selectable, null); 405 mailboxes.put(folderName, folder); 406 } 407 } 408 409 // In order to properly map INBOX -> Inbox, handle it as a special case. 410 final Mailbox inbox = 411 Mailbox.restoreMailboxOfType(mContext, mAccount.mId, Mailbox.TYPE_INBOX); 412 final ImapFolder newFolder = addMailbox( 413 mContext, mAccount.mId, inbox.mServerId, '\0', true /*selectable*/, inbox); 414 mailboxes.put(ImapConstants.INBOX, newFolder); 415 416 createHierarchy(mailboxes); 417 saveMailboxList(mContext, mailboxes); 418 return mailboxes.values().toArray(new Folder[] {}); 419 } catch (IOException ioe) { 420 connection.close(); 421 throw new MessagingException("Unable to get folder list.", ioe); 422 } catch (AuthenticationFailedException afe) { 423 // We do NOT want this connection pooled, or we will continue to send NOOP and SELECT 424 // commands to the server 425 connection.destroyResponses(); 426 connection = null; 427 throw afe; 428 } finally { 429 if (connection != null) { 430 poolConnection(connection); 431 } 432 } 433 } 434 435 @Override 436 public Bundle checkSettings() throws MessagingException { 437 int result = MessagingException.NO_ERROR; 438 Bundle bundle = new Bundle(); 439 ImapConnection connection = new ImapConnection(this, mUsername, mPassword); 440 try { 441 connection.open(); 442 connection.close(); 443 } catch (IOException ioe) { 444 bundle.putString(EmailServiceProxy.VALIDATE_BUNDLE_ERROR_MESSAGE, ioe.getMessage()); 445 result = MessagingException.IOERROR; 446 } finally { 447 connection.destroyResponses(); 448 } 449 bundle.putInt(EmailServiceProxy.VALIDATE_BUNDLE_RESULT_CODE, result); 450 return bundle; 451 } 452 453 /** 454 * Returns whether or not the prefix has been set by the user. This can be determined by 455 * the fact that the prefix is set, but, the path separator is not set. 456 */ 457 boolean isUserPrefixSet() { 458 return TextUtils.isEmpty(mPathSeparator) && !TextUtils.isEmpty(mPathPrefix); 459 } 460 461 /** Sets the path separator */ 462 void setPathSeparator(String pathSeparator) { 463 mPathSeparator = pathSeparator; 464 } 465 466 /** Sets the prefix */ 467 void setPathPrefix(String pathPrefix) { 468 mPathPrefix = pathPrefix; 469 } 470 471 /** Gets the context for this store */ 472 Context getContext() { 473 return mContext; 474 } 475 476 /** Returns a clone of the transport associated with this store. */ 477 MailTransport cloneTransport() { 478 return mTransport.clone(); 479 } 480 481 /** 482 * Fixes the path prefix, if necessary. The path prefix must always end with the 483 * path separator. 484 */ 485 void ensurePrefixIsValid() { 486 // Make sure the path prefix ends with the path separator 487 if (!TextUtils.isEmpty(mPathPrefix) && !TextUtils.isEmpty(mPathSeparator)) { 488 if (!mPathPrefix.endsWith(mPathSeparator)) { 489 mPathPrefix = mPathPrefix + mPathSeparator; 490 } 491 } 492 } 493 494 /** 495 * Gets a connection if one is available from the pool, or creates a new one if not. 496 */ 497 ImapConnection getConnection() { 498 ImapConnection connection = null; 499 while ((connection = mConnectionPool.poll()) != null) { 500 try { 501 connection.setStore(this, mUsername, mPassword); 502 connection.executeSimpleCommand(ImapConstants.NOOP); 503 break; 504 } catch (MessagingException e) { 505 // Fall through 506 } catch (IOException e) { 507 // Fall through 508 } 509 connection.close(); 510 connection = null; 511 } 512 if (connection == null) { 513 connection = new ImapConnection(this, mUsername, mPassword); 514 } 515 return connection; 516 } 517 518 /** 519 * Save a {@link ImapConnection} in the pool for reuse. Any responses associated with the 520 * connection are destroyed before adding the connection to the pool. 521 */ 522 void poolConnection(ImapConnection connection) { 523 if (connection != null) { 524 connection.destroyResponses(); 525 mConnectionPool.add(connection); 526 } 527 } 528 529 /** 530 * Prepends the folder name with the given prefix and UTF-7 encodes it. 531 */ 532 static String encodeFolderName(String name, String prefix) { 533 // do NOT add the prefix to the special name "INBOX" 534 if (ImapConstants.INBOX.equalsIgnoreCase(name)) return name; 535 536 // Prepend prefix 537 if (prefix != null) { 538 name = prefix + name; 539 } 540 541 // TODO bypass the conversion if name doesn't have special char. 542 ByteBuffer bb = MODIFIED_UTF_7_CHARSET.encode(name); 543 byte[] b = new byte[bb.limit()]; 544 bb.get(b); 545 546 return Utility.fromAscii(b); 547 } 548 549 /** 550 * UTF-7 decodes the folder name and removes the given path prefix. 551 */ 552 static String decodeFolderName(String name, String prefix) { 553 // TODO bypass the conversion if name doesn't have special char. 554 String folder; 555 folder = MODIFIED_UTF_7_CHARSET.decode(ByteBuffer.wrap(Utility.toAscii(name))).toString(); 556 if ((prefix != null) && folder.startsWith(prefix)) { 557 folder = folder.substring(prefix.length()); 558 } 559 return folder; 560 } 561 562 /** 563 * Returns UIDs of Messages joined with "," as the separator. 564 */ 565 static String joinMessageUids(Message[] messages) { 566 StringBuilder sb = new StringBuilder(); 567 boolean notFirst = false; 568 for (Message m : messages) { 569 if (notFirst) { 570 sb.append(','); 571 } 572 sb.append(m.getUid()); 573 notFirst = true; 574 } 575 return sb.toString(); 576 } 577 578 static class ImapMessage extends MimeMessage { 579 ImapMessage(String uid, ImapFolder folder) { 580 mUid = uid; 581 mFolder = folder; 582 } 583 584 public void setSize(int size) { 585 mSize = size; 586 } 587 588 @Override 589 public void parse(InputStream in) throws IOException, MessagingException { 590 super.parse(in); 591 } 592 593 public void setFlagInternal(Flag flag, boolean set) throws MessagingException { 594 super.setFlag(flag, set); 595 } 596 597 @Override 598 public void setFlag(Flag flag, boolean set) throws MessagingException { 599 super.setFlag(flag, set); 600 mFolder.setFlags(new Message[] { this }, new Flag[] { flag }, set); 601 } 602 } 603 604 static class ImapException extends MessagingException { 605 private static final long serialVersionUID = 1L; 606 607 String mAlertText; 608 609 public ImapException(String message, String alertText, Throwable throwable) { 610 super(message, throwable); 611 mAlertText = alertText; 612 } 613 614 public ImapException(String message, String alertText) { 615 super(message); 616 mAlertText = alertText; 617 } 618 619 public String getAlertText() { 620 return mAlertText; 621 } 622 623 public void setAlertText(String alertText) { 624 mAlertText = alertText; 625 } 626 } 627 } 628