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 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 */ 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 */ 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 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 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 */ 459 boolean isUserPrefixSet() { 460 return TextUtils.isEmpty(mPathSeparator) && !TextUtils.isEmpty(mPathPrefix); 461 } 462 463 /** Sets the path separator */ 464 void setPathSeparator(String pathSeparator) { 465 mPathSeparator = pathSeparator; 466 } 467 468 /** Sets the prefix */ 469 void setPathPrefix(String pathPrefix) { 470 mPathPrefix = pathPrefix; 471 } 472 473 /** Gets the context for this store */ 474 Context getContext() { 475 return mContext; 476 } 477 478 /** Returns a clone of the transport associated with this store. */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 { 581 ImapMessage(String uid, ImapFolder folder) { 582 mUid = uid; 583 mFolder = folder; 584 } 585 586 public void setSize(int size) { 587 mSize = size; 588 } 589 590 @Override 591 public void parse(InputStream in) throws IOException, MessagingException { 592 super.parse(in); 593 } 594 595 public void setFlagInternal(Flag flag, boolean set) throws MessagingException { 596 super.setFlag(flag, set); 597 } 598 599 @Override 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 611 public ImapException(String message, String alertText, Throwable throwable) { 612 super(message, throwable); 613 mAlertText = alertText; 614 } 615 616 public ImapException(String message, String alertText) { 617 super(message); 618 mAlertText = alertText; 619 } 620 621 public String getAlertText() { 622 return mAlertText; 623 } 624 625 public void setAlertText(String alertText) { 626 mAlertText = alertText; 627 } 628 } 629 } 630