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