1 /* 2 * DavMail POP/IMAP/SMTP/CalDav/LDAP Exchange Gateway 3 * Copyright (C) 2009 Mickael Guessant 4 * 5 * This program is free software; you can redistribute it and/or 6 * modify it under the terms of the GNU General Public License 7 * as published by the Free Software Foundation; either version 2 8 * of the License, or (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License 16 * along with this program; if not, write to the Free Software 17 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 18 */ 19 package davmail.exchange; 20 21 import davmail.BundleMessage; 22 import davmail.Settings; 23 import davmail.exception.DavMailException; 24 import davmail.exception.HttpNotFoundException; 25 import davmail.http.URIUtil; 26 import davmail.ui.NotificationDialog; 27 import davmail.util.StringUtil; 28 import org.apache.log4j.Logger; 29 30 import javax.mail.MessagingException; 31 import javax.mail.internet.InternetAddress; 32 import javax.mail.internet.InternetHeaders; 33 import javax.mail.internet.MimeMessage; 34 import javax.mail.internet.MimeMultipart; 35 import javax.mail.internet.MimePart; 36 import javax.mail.util.SharedByteArrayInputStream; 37 import java.io.ByteArrayOutputStream; 38 import java.io.File; 39 import java.io.FileOutputStream; 40 import java.io.IOException; 41 import java.io.InputStream; 42 import java.io.OutputStreamWriter; 43 import java.io.StringReader; 44 import java.net.NoRouteToHostException; 45 import java.net.UnknownHostException; 46 import java.nio.charset.StandardCharsets; 47 import java.text.ParseException; 48 import java.text.SimpleDateFormat; 49 import java.util.*; 50 51 /** 52 * Exchange session through Outlook Web Access (DAV) 53 */ 54 public abstract class ExchangeSession { 55 56 protected static final Logger LOGGER = Logger.getLogger("davmail.exchange.ExchangeSession"); 57 58 /** 59 * Reference GMT timezone to format dates 60 */ 61 public static final SimpleTimeZone GMT_TIMEZONE = new SimpleTimeZone(0, "GMT"); 62 63 protected static final int FREE_BUSY_INTERVAL = 15; 64 65 protected static final String PUBLIC_ROOT = "/public/"; 66 protected static final String CALENDAR = "calendar"; 67 protected static final String TASKS = "tasks"; 68 /** 69 * Contacts folder logical name 70 */ 71 public static final String CONTACTS = "contacts"; 72 protected static final String ADDRESSBOOK = "addressbook"; 73 protected static final String INBOX = "INBOX"; 74 protected static final String LOWER_CASE_INBOX = "inbox"; 75 protected static final String MIXED_CASE_INBOX = "Inbox"; 76 protected static final String SENT = "Sent"; 77 protected static final String SENDMSG = "##DavMailSubmissionURI##"; 78 protected static final String DRAFTS = "Drafts"; 79 protected static final String TRASH = "Trash"; 80 protected static final String JUNK = "Junk"; 81 protected static final String UNSENT = "Unsent Messages"; 82 83 protected static final List<String> SPECIAL = Arrays.asList(SENT, DRAFTS, TRASH, JUNK); 84 85 static { 86 // Adjust Mime decoder settings 87 System.setProperty("mail.mime.ignoreunknownencoding", "true"); 88 System.setProperty("mail.mime.decodetext.strict", "false"); 89 } 90 91 protected String publicFolderUrl; 92 93 /** 94 * Base user mailboxes path (used to select folder) 95 */ 96 protected String mailPath; 97 protected String rootPath; 98 protected String email; 99 protected String alias; 100 /** 101 * Lower case Caldav path to current user mailbox. 102 * /users/<i>email</i> 103 */ 104 protected String currentMailboxPath; 105 106 protected String userName; 107 108 protected String serverVersion; 109 110 protected static final String YYYY_MM_DD_HH_MM_SS = "yyyy/MM/dd HH:mm:ss"; 111 private static final String YYYYMMDD_T_HHMMSS_Z = "yyyyMMdd'T'HHmmss'Z'"; 112 protected static final String YYYY_MM_DD_T_HHMMSS_Z = "yyyy-MM-dd'T'HH:mm:ss'Z'"; 113 private static final String YYYY_MM_DD = "yyyy-MM-dd"; 114 private static final String YYYY_MM_DD_T_HHMMSS_SSS_Z = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"; 115 ExchangeSession()116 public ExchangeSession() { 117 // empty constructor 118 } 119 120 /** 121 * Close session. 122 * Shutdown http client connection manager 123 */ close()124 public abstract void close(); 125 126 /** 127 * Format date to exchange search format. 128 * 129 * @param date date object 130 * @return formatted search date 131 */ formatSearchDate(Date date)132 public abstract String formatSearchDate(Date date); 133 134 /** 135 * Return standard zulu date formatter. 136 * 137 * @return zulu date formatter 138 */ getZuluDateFormat()139 public static SimpleDateFormat getZuluDateFormat() { 140 SimpleDateFormat dateFormat = new SimpleDateFormat(YYYYMMDD_T_HHMMSS_Z, Locale.ENGLISH); 141 dateFormat.setTimeZone(GMT_TIMEZONE); 142 return dateFormat; 143 } 144 getVcardBdayFormat()145 protected static SimpleDateFormat getVcardBdayFormat() { 146 SimpleDateFormat dateFormat = new SimpleDateFormat(YYYY_MM_DD, Locale.ENGLISH); 147 dateFormat.setTimeZone(GMT_TIMEZONE); 148 return dateFormat; 149 } 150 getExchangeDateFormat(String value)151 protected static SimpleDateFormat getExchangeDateFormat(String value) { 152 SimpleDateFormat dateFormat; 153 if (value.length() == 8) { 154 dateFormat = new SimpleDateFormat("yyyyMMdd", Locale.ENGLISH); 155 dateFormat.setTimeZone(GMT_TIMEZONE); 156 } else if (value.length() == 15) { 157 dateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.ENGLISH); 158 dateFormat.setTimeZone(GMT_TIMEZONE); 159 } else if (value.length() == 16) { 160 dateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.ENGLISH); 161 dateFormat.setTimeZone(GMT_TIMEZONE); 162 } else { 163 dateFormat = ExchangeSession.getExchangeZuluDateFormat(); 164 } 165 return dateFormat; 166 } 167 getExchangeZuluDateFormat()168 protected static SimpleDateFormat getExchangeZuluDateFormat() { 169 SimpleDateFormat dateFormat = new SimpleDateFormat(YYYY_MM_DD_T_HHMMSS_Z, Locale.ENGLISH); 170 dateFormat.setTimeZone(GMT_TIMEZONE); 171 return dateFormat; 172 } 173 getExchangeZuluDateFormatMillisecond()174 protected static SimpleDateFormat getExchangeZuluDateFormatMillisecond() { 175 SimpleDateFormat dateFormat = new SimpleDateFormat(YYYY_MM_DD_T_HHMMSS_SSS_Z, Locale.ENGLISH); 176 dateFormat.setTimeZone(GMT_TIMEZONE); 177 return dateFormat; 178 } 179 parseDate(String dateString)180 protected static Date parseDate(String dateString) throws ParseException { 181 SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd"); 182 dateFormat.setTimeZone(GMT_TIMEZONE); 183 return dateFormat.parse(dateString); 184 } 185 186 187 /** 188 * Test if the session expired. 189 * 190 * @return true this session expired 191 * @throws NoRouteToHostException on error 192 * @throws UnknownHostException on error 193 */ isExpired()194 public boolean isExpired() throws NoRouteToHostException, UnknownHostException { 195 boolean isExpired = false; 196 try { 197 getFolder(""); 198 } catch (UnknownHostException | NoRouteToHostException exc) { 199 throw exc; 200 } catch (IOException e) { 201 isExpired = true; 202 } 203 204 return isExpired; 205 } 206 buildSessionInfo(java.net.URI uri)207 protected abstract void buildSessionInfo(java.net.URI uri) throws IOException; 208 209 /** 210 * Create message in specified folder. 211 * Will overwrite an existing message with same subject in the same folder 212 * 213 * @param folderPath Exchange folder path 214 * @param messageName message name 215 * @param properties message properties (flags) 216 * @param mimeMessage MIME message 217 * @throws IOException when unable to create message 218 */ createMessage(String folderPath, String messageName, HashMap<String, String> properties, MimeMessage mimeMessage)219 public abstract void createMessage(String folderPath, String messageName, HashMap<String, String> properties, MimeMessage mimeMessage) throws IOException; 220 221 /** 222 * Update given properties on message. 223 * 224 * @param message Exchange message 225 * @param properties Webdav properties map 226 * @throws IOException on error 227 */ updateMessage(Message message, Map<String, String> properties)228 public abstract void updateMessage(Message message, Map<String, String> properties) throws IOException; 229 230 231 /** 232 * Delete Exchange message. 233 * 234 * @param message Exchange message 235 * @throws IOException on error 236 */ deleteMessage(Message message)237 public abstract void deleteMessage(Message message) throws IOException; 238 239 /** 240 * Get raw MIME message content 241 * 242 * @param message Exchange message 243 * @return message body 244 * @throws IOException on error 245 */ getContent(Message message)246 protected abstract byte[] getContent(Message message) throws IOException; 247 248 protected static final Set<String> POP_MESSAGE_ATTRIBUTES = new HashSet<>(); 249 250 static { 251 POP_MESSAGE_ATTRIBUTES.add("uid"); 252 POP_MESSAGE_ATTRIBUTES.add("imapUid"); 253 POP_MESSAGE_ATTRIBUTES.add("messageSize"); 254 } 255 256 /** 257 * Return folder message list with id and size only (for POP3 listener). 258 * 259 * @param folderName Exchange folder name 260 * @return folder message list 261 * @throws IOException on error 262 */ getAllMessageUidAndSize(String folderName)263 public MessageList getAllMessageUidAndSize(String folderName) throws IOException { 264 return searchMessages(folderName, POP_MESSAGE_ATTRIBUTES, null); 265 } 266 267 protected static final Set<String> IMAP_MESSAGE_ATTRIBUTES = new HashSet<>(); 268 269 static { 270 IMAP_MESSAGE_ATTRIBUTES.add("permanenturl"); 271 IMAP_MESSAGE_ATTRIBUTES.add("urlcompname"); 272 IMAP_MESSAGE_ATTRIBUTES.add("uid"); 273 IMAP_MESSAGE_ATTRIBUTES.add("messageSize"); 274 IMAP_MESSAGE_ATTRIBUTES.add("imapUid"); 275 IMAP_MESSAGE_ATTRIBUTES.add("junk"); 276 IMAP_MESSAGE_ATTRIBUTES.add("flagStatus"); 277 IMAP_MESSAGE_ATTRIBUTES.add("messageFlags"); 278 IMAP_MESSAGE_ATTRIBUTES.add("lastVerbExecuted"); 279 IMAP_MESSAGE_ATTRIBUTES.add("read"); 280 IMAP_MESSAGE_ATTRIBUTES.add("deleted"); 281 IMAP_MESSAGE_ATTRIBUTES.add("date"); 282 IMAP_MESSAGE_ATTRIBUTES.add("lastmodified"); 283 // OSX IMAP requests content-class 284 IMAP_MESSAGE_ATTRIBUTES.add("contentclass"); 285 IMAP_MESSAGE_ATTRIBUTES.add("keywords"); 286 } 287 288 protected static final Set<String> UID_MESSAGE_ATTRIBUTES = new HashSet<>(); 289 290 static { 291 UID_MESSAGE_ATTRIBUTES.add("uid"); 292 } 293 294 /** 295 * Get all folder messages. 296 * 297 * @param folderPath Exchange folder name 298 * @return message list 299 * @throws IOException on error 300 */ searchMessages(String folderPath)301 public MessageList searchMessages(String folderPath) throws IOException { 302 return searchMessages(folderPath, IMAP_MESSAGE_ATTRIBUTES, null); 303 } 304 305 /** 306 * Search folder for messages matching conditions, with attributes needed by IMAP listener. 307 * 308 * @param folderName Exchange folder name 309 * @param condition search filter 310 * @return message list 311 * @throws IOException on error 312 */ searchMessages(String folderName, Condition condition)313 public MessageList searchMessages(String folderName, Condition condition) throws IOException { 314 return searchMessages(folderName, IMAP_MESSAGE_ATTRIBUTES, condition); 315 } 316 317 /** 318 * Search folder for messages matching conditions, with given attributes. 319 * 320 * @param folderName Exchange folder name 321 * @param attributes requested Webdav attributes 322 * @param condition search filter 323 * @return message list 324 * @throws IOException on error 325 */ searchMessages(String folderName, Set<String> attributes, Condition condition)326 public abstract MessageList searchMessages(String folderName, Set<String> attributes, Condition condition) throws IOException; 327 328 /** 329 * Get server version (Exchange2003, Exchange2007 or Exchange2010) 330 * 331 * @return server version 332 */ getServerVersion()333 public String getServerVersion() { 334 return serverVersion; 335 } 336 337 public enum Operator { 338 Or, And, Not, IsEqualTo, 339 IsGreaterThan, IsGreaterThanOrEqualTo, 340 IsLessThan, IsLessThanOrEqualTo, 341 IsNull, IsTrue, IsFalse, 342 Like, StartsWith, Contains 343 } 344 345 /** 346 * Exchange search filter. 347 */ 348 public interface Condition { 349 /** 350 * Append condition to buffer. 351 * 352 * @param buffer search filter buffer 353 */ appendTo(StringBuilder buffer)354 void appendTo(StringBuilder buffer); 355 356 /** 357 * True if condition is empty. 358 * 359 * @return true if condition is empty 360 */ isEmpty()361 boolean isEmpty(); 362 363 /** 364 * Test if the contact matches current condition. 365 * 366 * @param contact Exchange Contact 367 * @return true if contact matches condition 368 */ isMatch(ExchangeSession.Contact contact)369 boolean isMatch(ExchangeSession.Contact contact); 370 } 371 372 /** 373 * Attribute condition. 374 */ 375 public abstract static class AttributeCondition implements Condition { 376 protected final String attributeName; 377 protected final Operator operator; 378 protected final String value; 379 AttributeCondition(String attributeName, Operator operator, String value)380 protected AttributeCondition(String attributeName, Operator operator, String value) { 381 this.attributeName = attributeName; 382 this.operator = operator; 383 this.value = value; 384 } 385 isEmpty()386 public boolean isEmpty() { 387 return false; 388 } 389 390 /** 391 * Get attribute name. 392 * 393 * @return attribute name 394 */ getAttributeName()395 public String getAttributeName() { 396 return attributeName; 397 } 398 399 /** 400 * Condition value. 401 * 402 * @return value 403 */ getValue()404 public String getValue() { 405 return value; 406 } 407 408 } 409 410 /** 411 * Multiple condition. 412 */ 413 public abstract static class MultiCondition implements Condition { 414 protected final Operator operator; 415 protected final List<Condition> conditions; 416 MultiCondition(Operator operator, Condition... conditions)417 protected MultiCondition(Operator operator, Condition... conditions) { 418 this.operator = operator; 419 this.conditions = new ArrayList<>(); 420 for (Condition condition : conditions) { 421 if (condition != null) { 422 this.conditions.add(condition); 423 } 424 } 425 } 426 427 /** 428 * Conditions list. 429 * 430 * @return conditions 431 */ getConditions()432 public List<Condition> getConditions() { 433 return conditions; 434 } 435 436 /** 437 * Condition operator. 438 * 439 * @return operator 440 */ getOperator()441 public Operator getOperator() { 442 return operator; 443 } 444 445 /** 446 * Add a new condition. 447 * 448 * @param condition single condition 449 */ add(Condition condition)450 public void add(Condition condition) { 451 if (condition != null) { 452 conditions.add(condition); 453 } 454 } 455 isEmpty()456 public boolean isEmpty() { 457 boolean isEmpty = true; 458 for (Condition condition : conditions) { 459 if (!condition.isEmpty()) { 460 isEmpty = false; 461 break; 462 } 463 } 464 return isEmpty; 465 } 466 isMatch(ExchangeSession.Contact contact)467 public boolean isMatch(ExchangeSession.Contact contact) { 468 if (operator == Operator.And) { 469 for (Condition condition : conditions) { 470 if (!condition.isMatch(contact)) { 471 return false; 472 } 473 } 474 return true; 475 } else if (operator == Operator.Or) { 476 for (Condition condition : conditions) { 477 if (condition.isMatch(contact)) { 478 return true; 479 } 480 } 481 return false; 482 } else { 483 return false; 484 } 485 } 486 487 } 488 489 /** 490 * Not condition. 491 */ 492 public abstract static class NotCondition implements Condition { 493 protected final Condition condition; 494 NotCondition(Condition condition)495 protected NotCondition(Condition condition) { 496 this.condition = condition; 497 } 498 isEmpty()499 public boolean isEmpty() { 500 return condition.isEmpty(); 501 } 502 isMatch(ExchangeSession.Contact contact)503 public boolean isMatch(ExchangeSession.Contact contact) { 504 return !condition.isMatch(contact); 505 } 506 } 507 508 /** 509 * Single search filter condition. 510 */ 511 public abstract static class MonoCondition implements Condition { 512 protected final String attributeName; 513 protected final Operator operator; 514 MonoCondition(String attributeName, Operator operator)515 protected MonoCondition(String attributeName, Operator operator) { 516 this.attributeName = attributeName; 517 this.operator = operator; 518 } 519 isEmpty()520 public boolean isEmpty() { 521 return false; 522 } 523 isMatch(ExchangeSession.Contact contact)524 public boolean isMatch(ExchangeSession.Contact contact) { 525 String actualValue = contact.get(attributeName); 526 return (operator == Operator.IsNull && actualValue == null) || 527 (operator == Operator.IsFalse && "false".equals(actualValue)) || 528 (operator == Operator.IsTrue && "true".equals(actualValue)); 529 } 530 } 531 532 /** 533 * And search filter. 534 * 535 * @param condition search conditions 536 * @return condition 537 */ and(Condition... condition)538 public abstract MultiCondition and(Condition... condition); 539 540 /** 541 * Or search filter. 542 * 543 * @param condition search conditions 544 * @return condition 545 */ or(Condition... condition)546 public abstract MultiCondition or(Condition... condition); 547 548 /** 549 * Not search filter. 550 * 551 * @param condition search condition 552 * @return condition 553 */ not(Condition condition)554 public abstract Condition not(Condition condition); 555 556 /** 557 * Equals condition. 558 * 559 * @param attributeName logical Exchange attribute name 560 * @param value attribute value 561 * @return condition 562 */ isEqualTo(String attributeName, String value)563 public abstract Condition isEqualTo(String attributeName, String value); 564 565 /** 566 * Equals condition. 567 * 568 * @param attributeName logical Exchange attribute name 569 * @param value attribute value 570 * @return condition 571 */ isEqualTo(String attributeName, int value)572 public abstract Condition isEqualTo(String attributeName, int value); 573 574 /** 575 * MIME header equals condition. 576 * 577 * @param headerName MIME header name 578 * @param value attribute value 579 * @return condition 580 */ headerIsEqualTo(String headerName, String value)581 public abstract Condition headerIsEqualTo(String headerName, String value); 582 583 /** 584 * Greater than or equals condition. 585 * 586 * @param attributeName logical Exchange attribute name 587 * @param value attribute value 588 * @return condition 589 */ gte(String attributeName, String value)590 public abstract Condition gte(String attributeName, String value); 591 592 /** 593 * Greater than condition. 594 * 595 * @param attributeName logical Exchange attribute name 596 * @param value attribute value 597 * @return condition 598 */ gt(String attributeName, String value)599 public abstract Condition gt(String attributeName, String value); 600 601 /** 602 * Lower than condition. 603 * 604 * @param attributeName logical Exchange attribute name 605 * @param value attribute value 606 * @return condition 607 */ lt(String attributeName, String value)608 public abstract Condition lt(String attributeName, String value); 609 610 /** 611 * Lower than or equals condition. 612 * 613 * @param attributeName logical Exchange attribute name 614 * @param value attribute value 615 * @return condition 616 */ 617 @SuppressWarnings({"UnusedDeclaration"}) lte(String attributeName, String value)618 public abstract Condition lte(String attributeName, String value); 619 620 /** 621 * Contains condition. 622 * 623 * @param attributeName logical Exchange attribute name 624 * @param value attribute value 625 * @return condition 626 */ contains(String attributeName, String value)627 public abstract Condition contains(String attributeName, String value); 628 629 /** 630 * Starts with condition. 631 * 632 * @param attributeName logical Exchange attribute name 633 * @param value attribute value 634 * @return condition 635 */ startsWith(String attributeName, String value)636 public abstract Condition startsWith(String attributeName, String value); 637 638 /** 639 * Is null condition. 640 * 641 * @param attributeName logical Exchange attribute name 642 * @return condition 643 */ isNull(String attributeName)644 public abstract Condition isNull(String attributeName); 645 646 /** 647 * Exists condition. 648 * 649 * @param attributeName logical Exchange attribute name 650 * @return condition 651 */ exists(String attributeName)652 public abstract Condition exists(String attributeName); 653 654 /** 655 * Is true condition. 656 * 657 * @param attributeName logical Exchange attribute name 658 * @return condition 659 */ isTrue(String attributeName)660 public abstract Condition isTrue(String attributeName); 661 662 /** 663 * Is false condition. 664 * 665 * @param attributeName logical Exchange attribute name 666 * @return condition 667 */ isFalse(String attributeName)668 public abstract Condition isFalse(String attributeName); 669 670 /** 671 * Search mail and generic folders under given folder. 672 * Exclude calendar and contacts folders 673 * 674 * @param folderName Exchange folder name 675 * @param recursive deep search if true 676 * @return list of folders 677 * @throws IOException on error 678 */ getSubFolders(String folderName, boolean recursive, boolean wildcard)679 public List<Folder> getSubFolders(String folderName, boolean recursive, boolean wildcard) throws IOException { 680 MultiCondition folderCondition = and(); 681 if (!Settings.getBooleanProperty("davmail.imapIncludeSpecialFolders", false)) { 682 folderCondition.add(or(isEqualTo("folderclass", "IPF.Note"), 683 isEqualTo("folderclass", "IPF.Note.Microsoft.Conversation"), 684 isNull("folderclass"))); 685 } 686 if (wildcard) { 687 folderCondition.add(startsWith("displayname", folderName)); 688 folderName = ""; 689 } 690 List<Folder> results = getSubFolders(folderName, folderCondition, 691 recursive); 692 // need to include base folder in recursive search, except on root 693 if (recursive && folderName.length() > 0) { 694 results.add(getFolder(folderName)); 695 } 696 697 return results; 698 } 699 700 /** 701 * Search calendar folders under given folder. 702 * 703 * @param folderName Exchange folder name 704 * @param recursive deep search if true 705 * @return list of folders 706 * @throws IOException on error 707 */ getSubCalendarFolders(String folderName, boolean recursive)708 public List<Folder> getSubCalendarFolders(String folderName, boolean recursive) throws IOException { 709 return getSubFolders(folderName, isEqualTo("folderclass", "IPF.Appointment"), recursive); 710 } 711 712 /** 713 * Search folders under given folder matching filter. 714 * 715 * @param folderName Exchange folder name 716 * @param condition search filter 717 * @param recursive deep search if true 718 * @return list of folders 719 * @throws IOException on error 720 */ getSubFolders(String folderName, Condition condition, boolean recursive)721 public abstract List<Folder> getSubFolders(String folderName, Condition condition, boolean recursive) throws IOException; 722 723 /** 724 * Delete oldest messages in trash. 725 * keepDelay is the number of days to keep messages in trash before delete 726 * 727 * @throws IOException when unable to purge messages 728 */ purgeOldestTrashAndSentMessages()729 public void purgeOldestTrashAndSentMessages() throws IOException { 730 int keepDelay = Settings.getIntProperty("davmail.keepDelay"); 731 if (keepDelay != 0) { 732 purgeOldestFolderMessages(TRASH, keepDelay); 733 } 734 // this is a new feature, default is : do nothing 735 int sentKeepDelay = Settings.getIntProperty("davmail.sentKeepDelay"); 736 if (sentKeepDelay != 0) { 737 purgeOldestFolderMessages(SENT, sentKeepDelay); 738 } 739 } 740 purgeOldestFolderMessages(String folderPath, int keepDelay)741 protected void purgeOldestFolderMessages(String folderPath, int keepDelay) throws IOException { 742 Calendar cal = Calendar.getInstance(); 743 cal.add(Calendar.DAY_OF_MONTH, -keepDelay); 744 LOGGER.debug("Delete messages in " + folderPath + " not modified since " + cal.getTime()); 745 746 MessageList messages = searchMessages(folderPath, UID_MESSAGE_ATTRIBUTES, 747 lt("lastmodified", formatSearchDate(cal.getTime()))); 748 749 for (Message message : messages) { 750 message.delete(); 751 } 752 } 753 convertResentHeader(MimeMessage mimeMessage, String headerName)754 protected void convertResentHeader(MimeMessage mimeMessage, String headerName) throws MessagingException { 755 String[] resentHeader = mimeMessage.getHeader("Resent-" + headerName); 756 if (resentHeader != null) { 757 mimeMessage.removeHeader("Resent-" + headerName); 758 mimeMessage.removeHeader(headerName); 759 for (String value : resentHeader) { 760 mimeMessage.addHeader(headerName, value); 761 } 762 } 763 } 764 765 protected String lastSentMessageId; 766 767 /** 768 * Send message in reader to recipients. 769 * Detect visible recipients in message body to determine bcc recipients 770 * 771 * @param rcptToRecipients recipients list 772 * @param mimeMessage mime message 773 * @throws IOException on error 774 * @throws MessagingException on error 775 */ sendMessage(List<String> rcptToRecipients, MimeMessage mimeMessage)776 public void sendMessage(List<String> rcptToRecipients, MimeMessage mimeMessage) throws IOException, MessagingException { 777 // detect duplicate send command 778 String messageId = mimeMessage.getMessageID(); 779 if (lastSentMessageId != null && lastSentMessageId.equals(messageId)) { 780 LOGGER.debug("Dropping message id " + messageId + ": already sent"); 781 return; 782 } 783 lastSentMessageId = messageId; 784 785 convertResentHeader(mimeMessage, "From"); 786 convertResentHeader(mimeMessage, "To"); 787 convertResentHeader(mimeMessage, "Cc"); 788 convertResentHeader(mimeMessage, "Bcc"); 789 convertResentHeader(mimeMessage, "Message-Id"); 790 791 // do not allow send as another user on Exchange 2003 792 if ("Exchange2003".equals(serverVersion) || Settings.getBooleanProperty("davmail.smtpStripFrom", false)) { 793 mimeMessage.removeHeader("From"); 794 } 795 796 // remove visible recipients from list 797 Set<String> visibleRecipients = new HashSet<>(); 798 List<InternetAddress> recipients = getAllRecipients(mimeMessage); 799 for (InternetAddress address : recipients) { 800 visibleRecipients.add((address.getAddress().toLowerCase())); 801 } 802 for (String recipient : rcptToRecipients) { 803 if (!visibleRecipients.contains(recipient.toLowerCase())) { 804 mimeMessage.addRecipient(javax.mail.Message.RecipientType.BCC, new InternetAddress(recipient)); 805 } 806 } 807 sendMessage(mimeMessage); 808 809 } 810 811 static final String[] RECIPIENT_HEADERS = {"to", "cc", "bcc"}; 812 getAllRecipients(MimeMessage mimeMessage)813 protected List<InternetAddress> getAllRecipients(MimeMessage mimeMessage) throws MessagingException { 814 List<InternetAddress> recipientList = new ArrayList<>(); 815 for (String recipientHeader : RECIPIENT_HEADERS) { 816 final String recipientHeaderValue = mimeMessage.getHeader(recipientHeader, ","); 817 if (recipientHeaderValue != null) { 818 // parse headers in non strict mode 819 recipientList.addAll(Arrays.asList(InternetAddress.parseHeader(recipientHeaderValue, false))); 820 } 821 822 } 823 return recipientList; 824 } 825 826 /** 827 * Send Mime message. 828 * 829 * @param mimeMessage MIME message 830 * @throws IOException on error 831 * @throws MessagingException on error 832 */ sendMessage(MimeMessage mimeMessage)833 public abstract void sendMessage(MimeMessage mimeMessage) throws IOException, MessagingException; 834 835 /** 836 * Get folder object. 837 * Folder name can be logical names INBOX, Drafts, Trash or calendar, 838 * or a path relative to user base folder or absolute path. 839 * 840 * @param folderPath folder path 841 * @return Folder object 842 * @throws IOException on error 843 */ getFolder(String folderPath)844 public ExchangeSession.Folder getFolder(String folderPath) throws IOException { 845 Folder folder = internalGetFolder(folderPath); 846 if (isMainCalendar(folderPath)) { 847 Folder taskFolder = internalGetFolder(TASKS); 848 folder.ctag += taskFolder.ctag; 849 } 850 return folder; 851 } 852 internalGetFolder(String folderName)853 protected abstract Folder internalGetFolder(String folderName) throws IOException; 854 855 /** 856 * Check folder ctag and reload messages as needed. 857 * 858 * @param currentFolder current folder 859 * @return true if folder changed 860 * @throws IOException on error 861 */ refreshFolder(Folder currentFolder)862 public boolean refreshFolder(Folder currentFolder) throws IOException { 863 Folder newFolder = getFolder(currentFolder.folderPath); 864 if (currentFolder.ctag == null || !currentFolder.ctag.equals(newFolder.ctag) 865 // ctag stamp is limited to second, check message count 866 || !(currentFolder.count == newFolder.count) 867 ) { 868 if (LOGGER.isDebugEnabled()) { 869 LOGGER.debug("Contenttag or count changed on " + currentFolder.folderPath + 870 " ctag: " + currentFolder.ctag + " => " + newFolder.ctag + 871 " count: " + currentFolder.count + " => " + newFolder.count 872 + ", reloading messages"); 873 } 874 currentFolder.hasChildren = newFolder.hasChildren; 875 currentFolder.noInferiors = newFolder.noInferiors; 876 currentFolder.unreadCount = newFolder.unreadCount; 877 currentFolder.ctag = newFolder.ctag; 878 currentFolder.etag = newFolder.etag; 879 if (newFolder.uidNext > currentFolder.uidNext) { 880 currentFolder.uidNext = newFolder.uidNext; 881 } 882 currentFolder.loadMessages(); 883 return true; 884 } else { 885 return false; 886 } 887 } 888 889 /** 890 * Create Exchange message folder. 891 * 892 * @param folderName logical folder name 893 * @throws IOException on error 894 */ createMessageFolder(String folderName)895 public void createMessageFolder(String folderName) throws IOException { 896 createFolder(folderName, "IPF.Note", null); 897 } 898 899 /** 900 * Create Exchange calendar folder. 901 * 902 * @param folderName logical folder name 903 * @param properties folder properties 904 * @return status 905 * @throws IOException on error 906 */ createCalendarFolder(String folderName, Map<String, String> properties)907 public int createCalendarFolder(String folderName, Map<String, String> properties) throws IOException { 908 return createFolder(folderName, "IPF.Appointment", properties); 909 } 910 911 /** 912 * Create Exchange contact folder. 913 * 914 * @param folderName logical folder name 915 * @param properties folder properties 916 * @throws IOException on error 917 */ createContactFolder(String folderName, Map<String, String> properties)918 public void createContactFolder(String folderName, Map<String, String> properties) throws IOException { 919 createFolder(folderName, "IPF.Contact", properties); 920 } 921 922 /** 923 * Create Exchange folder with given folder class. 924 * 925 * @param folderName logical folder name 926 * @param folderClass folder class 927 * @param properties folder properties 928 * @return status 929 * @throws IOException on error 930 */ createFolder(String folderName, String folderClass, Map<String, String> properties)931 public abstract int createFolder(String folderName, String folderClass, Map<String, String> properties) throws IOException; 932 933 /** 934 * Update Exchange folder properties. 935 * 936 * @param folderName logical folder name 937 * @param properties folder properties 938 * @return status 939 * @throws IOException on error 940 */ updateFolder(String folderName, Map<String, String> properties)941 public abstract int updateFolder(String folderName, Map<String, String> properties) throws IOException; 942 943 /** 944 * Delete Exchange folder. 945 * 946 * @param folderName logical folder name 947 * @throws IOException on error 948 */ deleteFolder(String folderName)949 public abstract void deleteFolder(String folderName) throws IOException; 950 951 /** 952 * Copy message to target folder 953 * 954 * @param message Exchange message 955 * @param targetFolder target folder 956 * @throws IOException on error 957 */ copyMessage(Message message, String targetFolder)958 public abstract void copyMessage(Message message, String targetFolder) throws IOException; 959 copyMessages(List<Message> messages, String targetFolder)960 public void copyMessages(List<Message> messages, String targetFolder) throws IOException { 961 for (Message message : messages) { 962 copyMessage(message, targetFolder); 963 } 964 } 965 966 967 /** 968 * Move message to target folder 969 * 970 * @param message Exchange message 971 * @param targetFolder target folder 972 * @throws IOException on error 973 */ moveMessage(Message message, String targetFolder)974 public abstract void moveMessage(Message message, String targetFolder) throws IOException; 975 moveMessages(List<Message> messages, String targetFolder)976 public void moveMessages(List<Message> messages, String targetFolder) throws IOException { 977 for (Message message : messages) { 978 moveMessage(message, targetFolder); 979 } 980 } 981 982 /** 983 * Move folder to target name. 984 * 985 * @param folderName current folder name/path 986 * @param targetName target folder name/path 987 * @throws IOException on error 988 */ moveFolder(String folderName, String targetName)989 public abstract void moveFolder(String folderName, String targetName) throws IOException; 990 991 /** 992 * Move item from source path to target path. 993 * 994 * @param sourcePath item source path 995 * @param targetPath item target path 996 * @throws IOException on error 997 */ moveItem(String sourcePath, String targetPath)998 public abstract void moveItem(String sourcePath, String targetPath) throws IOException; 999 moveToTrash(Message message)1000 protected abstract void moveToTrash(Message message) throws IOException; 1001 1002 /** 1003 * Convert keyword value to IMAP flag. 1004 * 1005 * @param value keyword value 1006 * @return IMAP flag 1007 */ convertKeywordToFlag(String value)1008 public String convertKeywordToFlag(String value) { 1009 // first test for keyword in settings 1010 Properties flagSettings = Settings.getSubProperties("davmail.imapFlags"); 1011 Enumeration<?> flagSettingsEnum = flagSettings.propertyNames(); 1012 while (flagSettingsEnum.hasMoreElements()) { 1013 String key = (String) flagSettingsEnum.nextElement(); 1014 if (value.equalsIgnoreCase(flagSettings.getProperty(key))) { 1015 return key; 1016 } 1017 } 1018 1019 ResourceBundle flagBundle = ResourceBundle.getBundle("imapflags"); 1020 Enumeration<String> flagBundleEnum = flagBundle.getKeys(); 1021 while (flagBundleEnum.hasMoreElements()) { 1022 String key = flagBundleEnum.nextElement(); 1023 if (value.equalsIgnoreCase(flagBundle.getString(key))) { 1024 return key; 1025 } 1026 } 1027 1028 // fall back to raw value 1029 return value; 1030 } 1031 1032 /** 1033 * Convert IMAP flag to keyword value. 1034 * 1035 * @param value IMAP flag 1036 * @return keyword value 1037 */ convertFlagToKeyword(String value)1038 public String convertFlagToKeyword(String value) { 1039 // first test for flag in settings 1040 Properties flagSettings = Settings.getSubProperties("davmail.imapFlags"); 1041 // case insensitive lookup 1042 for (String key : flagSettings.stringPropertyNames()) { 1043 if (key.equalsIgnoreCase(value)) { 1044 return flagSettings.getProperty(key); 1045 } 1046 } 1047 1048 // fall back to predefined flags 1049 ResourceBundle flagBundle = ResourceBundle.getBundle("imapflags"); 1050 for (String key : flagBundle.keySet()) { 1051 if (key.equalsIgnoreCase(value)) { 1052 return flagBundle.getString(key); 1053 } 1054 } 1055 1056 // fall back to raw value 1057 return value; 1058 } 1059 1060 /** 1061 * Convert IMAP flags to keyword value. 1062 * 1063 * @param flags IMAP flags 1064 * @return keyword value 1065 */ convertFlagsToKeywords(HashSet<String> flags)1066 public String convertFlagsToKeywords(HashSet<String> flags) { 1067 HashSet<String> keywordSet = new HashSet<>(); 1068 for (String flag : flags) { 1069 keywordSet.add(decodeKeyword(convertFlagToKeyword(flag))); 1070 } 1071 return StringUtil.join(keywordSet, ","); 1072 } 1073 decodeKeyword(String keyword)1074 protected String decodeKeyword(String keyword) { 1075 String result = keyword; 1076 if (keyword.contains("_x0028_") || keyword.contains("_x0029_")) { 1077 result = result.replaceAll("_x0028_", "(") 1078 .replaceAll("_x0029_", ")"); 1079 } 1080 return result; 1081 } 1082 encodeKeyword(String keyword)1083 protected String encodeKeyword(String keyword) { 1084 String result = keyword; 1085 if (keyword.indexOf('(') >= 0|| keyword.indexOf(')') >= 0) { 1086 result = result.replaceAll("\\(", "_x0028_") 1087 .replaceAll("\\)", "_x0029_" ); 1088 } 1089 return result; 1090 } 1091 1092 /** 1093 * Exchange folder with IMAP properties 1094 */ 1095 public class Folder { 1096 /** 1097 * Logical (IMAP) folder path. 1098 */ 1099 public String folderPath; 1100 1101 /** 1102 * Display Name. 1103 */ 1104 public String displayName; 1105 /** 1106 * Folder class (PR_CONTAINER_CLASS). 1107 */ 1108 public String folderClass; 1109 /** 1110 * Folder message count. 1111 */ 1112 public int count; 1113 /** 1114 * Folder unread message count. 1115 */ 1116 public int unreadCount; 1117 /** 1118 * true if folder has subfolders (DAV:hassubs). 1119 */ 1120 public boolean hasChildren; 1121 /** 1122 * true if folder has no subfolders (DAV:nosubs). 1123 */ 1124 public boolean noInferiors; 1125 /** 1126 * Folder content tag (to detect folder content changes). 1127 */ 1128 public String ctag; 1129 /** 1130 * Folder etag (to detect folder object changes). 1131 */ 1132 public String etag; 1133 /** 1134 * Next IMAP uid 1135 */ 1136 public long uidNext; 1137 /** 1138 * recent count 1139 */ 1140 public int recent; 1141 1142 /** 1143 * Folder message list, empty before loadMessages call. 1144 */ 1145 public ExchangeSession.MessageList messages; 1146 /** 1147 * Permanent uid (PR_SEARCH_KEY) to IMAP UID map. 1148 */ 1149 private final HashMap<String, Long> permanentUrlToImapUidMap = new HashMap<>(); 1150 1151 /** 1152 * Get IMAP folder flags. 1153 * 1154 * @return folder flags in IMAP format 1155 */ getFlags()1156 public String getFlags() { 1157 String specialFlag = ""; 1158 if (isSpecial()) { 1159 specialFlag = "\\" + folderPath + " "; 1160 } 1161 if (noInferiors) { 1162 return specialFlag + "\\NoInferiors"; 1163 } else if (hasChildren) { 1164 return specialFlag + "\\HasChildren"; 1165 } else { 1166 return specialFlag + "\\HasNoChildren"; 1167 } 1168 } 1169 1170 /** 1171 * Special folder flag (Sent, Drafts, Trash, Junk). 1172 * @return true if folder is special 1173 */ isSpecial()1174 public boolean isSpecial() { 1175 return SPECIAL.contains(folderPath); 1176 } 1177 1178 /** 1179 * Load folder messages. 1180 * 1181 * @throws IOException on error 1182 */ loadMessages()1183 public void loadMessages() throws IOException { 1184 messages = ExchangeSession.this.searchMessages(folderPath, null); 1185 fixUids(messages); 1186 recent = 0; 1187 for (Message message : messages) { 1188 if (message.recent) { 1189 recent++; 1190 } 1191 } 1192 long computedUidNext = 1; 1193 if (!messages.isEmpty()) { 1194 computedUidNext = messages.get(messages.size() - 1).getImapUid() + 1; 1195 } 1196 if (computedUidNext > uidNext) { 1197 uidNext = computedUidNext; 1198 } 1199 } 1200 1201 /** 1202 * Search messages in folder matching query. 1203 * 1204 * @param condition search query 1205 * @return message list 1206 * @throws IOException on error 1207 */ searchMessages(Condition condition)1208 public MessageList searchMessages(Condition condition) throws IOException { 1209 MessageList localMessages = ExchangeSession.this.searchMessages(folderPath, condition); 1210 fixUids(localMessages); 1211 return localMessages; 1212 } 1213 1214 /** 1215 * Restore previous uids changed by a PROPPATCH (flag change). 1216 * 1217 * @param messages message list 1218 */ fixUids(MessageList messages)1219 protected void fixUids(MessageList messages) { 1220 boolean sortNeeded = false; 1221 for (Message message : messages) { 1222 if (permanentUrlToImapUidMap.containsKey(message.getPermanentId())) { 1223 long previousUid = permanentUrlToImapUidMap.get(message.getPermanentId()); 1224 if (message.getImapUid() != previousUid) { 1225 LOGGER.debug("Restoring IMAP uid " + message.getImapUid() + " -> " + previousUid + " for message " + message.getPermanentId()); 1226 message.setImapUid(previousUid); 1227 sortNeeded = true; 1228 } 1229 } else { 1230 // add message to uid map 1231 permanentUrlToImapUidMap.put(message.getPermanentId(), message.getImapUid()); 1232 } 1233 } 1234 if (sortNeeded) { 1235 Collections.sort(messages); 1236 } 1237 } 1238 1239 /** 1240 * Folder message count. 1241 * 1242 * @return message count 1243 */ count()1244 public int count() { 1245 if (messages == null) { 1246 return count; 1247 } else { 1248 return messages.size(); 1249 } 1250 } 1251 1252 /** 1253 * Compute IMAP uidnext. 1254 * 1255 * @return max(messageuids)+1 1256 */ getUidNext()1257 public long getUidNext() { 1258 return uidNext; 1259 } 1260 1261 /** 1262 * Get message at index. 1263 * 1264 * @param index message index 1265 * @return message 1266 */ get(int index)1267 public Message get(int index) { 1268 return messages.get(index); 1269 } 1270 1271 /** 1272 * Get current folder messages imap uids and flags 1273 * 1274 * @return imap uid list 1275 */ getImapFlagMap()1276 public TreeMap<Long, String> getImapFlagMap() { 1277 TreeMap<Long, String> imapFlagMap = new TreeMap<>(); 1278 for (ExchangeSession.Message message : messages) { 1279 imapFlagMap.put(message.getImapUid(), message.getImapFlags()); 1280 } 1281 return imapFlagMap; 1282 } 1283 1284 /** 1285 * Calendar folder flag. 1286 * 1287 * @return true if this is a calendar folder 1288 */ isCalendar()1289 public boolean isCalendar() { 1290 return "IPF.Appointment".equals(folderClass); 1291 } 1292 1293 /** 1294 * Contact folder flag. 1295 * 1296 * @return true if this is a calendar folder 1297 */ isContact()1298 public boolean isContact() { 1299 return "IPF.Contact".equals(folderClass); 1300 } 1301 1302 /** 1303 * Task folder flag. 1304 * 1305 * @return true if this is a task folder 1306 */ isTask()1307 public boolean isTask() { 1308 return "IPF.Task".equals(folderClass); 1309 } 1310 1311 /** 1312 * drop cached message 1313 */ clearCache()1314 public void clearCache() { 1315 messages.cachedMimeContent = null; 1316 messages.cachedMimeMessage = null; 1317 messages.cachedMessageImapUid = 0; 1318 } 1319 } 1320 1321 /** 1322 * Exchange message. 1323 */ 1324 public abstract class Message implements Comparable<Message> { 1325 /** 1326 * enclosing message list 1327 */ 1328 public MessageList messageList; 1329 /** 1330 * Message url. 1331 */ 1332 public String messageUrl; 1333 /** 1334 * Message permanent url (does not change on message move). 1335 */ 1336 public String permanentUrl; 1337 /** 1338 * Message uid. 1339 */ 1340 public String uid; 1341 /** 1342 * Message content class. 1343 */ 1344 public String contentClass; 1345 /** 1346 * Message keywords (categories). 1347 */ 1348 public String keywords; 1349 /** 1350 * Message IMAP uid, unique in folder (x0e230003). 1351 */ 1352 public long imapUid; 1353 /** 1354 * MAPI message size. 1355 */ 1356 public int size; 1357 /** 1358 * Message date (urn:schemas:mailheader:date). 1359 */ 1360 public String date; 1361 1362 /** 1363 * Message flag: read. 1364 */ 1365 public boolean read; 1366 /** 1367 * Message flag: deleted. 1368 */ 1369 public boolean deleted; 1370 /** 1371 * Message flag: junk. 1372 */ 1373 public boolean junk; 1374 /** 1375 * Message flag: flagged. 1376 */ 1377 public boolean flagged; 1378 /** 1379 * Message flag: recent. 1380 */ 1381 public boolean recent; 1382 /** 1383 * Message flag: draft. 1384 */ 1385 public boolean draft; 1386 /** 1387 * Message flag: answered. 1388 */ 1389 public boolean answered; 1390 /** 1391 * Message flag: fowarded. 1392 */ 1393 public boolean forwarded; 1394 1395 /** 1396 * Unparsed message content. 1397 */ 1398 protected byte[] mimeContent; 1399 1400 /** 1401 * Message content parsed in a MIME message. 1402 */ 1403 protected MimeMessage mimeMessage; 1404 1405 /** 1406 * Get permanent message id. 1407 * permanentUrl over WebDav or IitemId over EWS 1408 * 1409 * @return permanent id 1410 */ getPermanentId()1411 public abstract String getPermanentId(); 1412 1413 /** 1414 * IMAP uid , unique in folder (x0e230003) 1415 * 1416 * @return IMAP uid 1417 */ getImapUid()1418 public long getImapUid() { 1419 return imapUid; 1420 } 1421 1422 /** 1423 * Set IMAP uid. 1424 * 1425 * @param imapUid new uid 1426 */ setImapUid(long imapUid)1427 public void setImapUid(long imapUid) { 1428 this.imapUid = imapUid; 1429 } 1430 1431 /** 1432 * Exchange uid. 1433 * 1434 * @return uid 1435 */ getUid()1436 public String getUid() { 1437 return uid; 1438 } 1439 1440 /** 1441 * Return message flags in IMAP format. 1442 * 1443 * @return IMAP flags 1444 */ getImapFlags()1445 public String getImapFlags() { 1446 StringBuilder buffer = new StringBuilder(); 1447 if (read) { 1448 buffer.append("\\Seen "); 1449 } 1450 if (deleted) { 1451 buffer.append("\\Deleted "); 1452 } 1453 if (recent) { 1454 buffer.append("\\Recent "); 1455 } 1456 if (flagged) { 1457 buffer.append("\\Flagged "); 1458 } 1459 if (junk) { 1460 buffer.append("Junk "); 1461 } 1462 if (draft) { 1463 buffer.append("\\Draft "); 1464 } 1465 if (answered) { 1466 buffer.append("\\Answered "); 1467 } 1468 if (forwarded) { 1469 buffer.append("$Forwarded "); 1470 } 1471 if (keywords != null) { 1472 for (String keyword : keywords.split(",")) { 1473 buffer.append(encodeKeyword(convertKeywordToFlag(keyword))).append(" "); 1474 } 1475 } 1476 return buffer.toString().trim(); 1477 } 1478 1479 /** 1480 * Load message content in a Mime message 1481 * 1482 * @throws IOException on error 1483 * @throws MessagingException on error 1484 */ loadMimeMessage()1485 public void loadMimeMessage() throws IOException, MessagingException { 1486 if (mimeMessage == null) { 1487 // try to get message content from cache 1488 if (this.imapUid == messageList.cachedMessageImapUid 1489 // make sure we never return null even with broken 0 uid message 1490 && messageList.cachedMimeContent != null 1491 && messageList.cachedMimeMessage != null) { 1492 mimeContent = messageList.cachedMimeContent; 1493 mimeMessage = messageList.cachedMimeMessage; 1494 LOGGER.debug("Got message content for " + imapUid + " from cache"); 1495 } else { 1496 // load and parse message 1497 mimeContent = getContent(this); 1498 mimeMessage = new MimeMessage(null, new SharedByteArrayInputStream(mimeContent)); 1499 // workaround for Exchange 2003 ActiveSync bug 1500 if (mimeMessage.getHeader("MAIL FROM") != null) { 1501 // find start of actual message 1502 byte[] mimeContentCopy = new byte[((SharedByteArrayInputStream) mimeMessage.getRawInputStream()).available()]; 1503 int offset = mimeContent.length - mimeContentCopy.length; 1504 // remove unwanted header 1505 System.arraycopy(mimeContent, offset, mimeContentCopy, 0, mimeContentCopy.length); 1506 mimeContent = mimeContentCopy; 1507 mimeMessage = new MimeMessage(null, new SharedByteArrayInputStream(mimeContent)); 1508 } 1509 LOGGER.debug("Downloaded full message content for IMAP UID " + imapUid + " (" + mimeContent.length + " bytes)"); 1510 } 1511 } 1512 } 1513 1514 /** 1515 * Get message content as a Mime message. 1516 * 1517 * @return mime message 1518 * @throws IOException on error 1519 * @throws MessagingException on error 1520 */ getMimeMessage()1521 public MimeMessage getMimeMessage() throws IOException, MessagingException { 1522 loadMimeMessage(); 1523 return mimeMessage; 1524 } 1525 getMatchingHeaderLinesFromHeaders(String[] headerNames)1526 public Enumeration<?> getMatchingHeaderLinesFromHeaders(String[] headerNames) throws MessagingException { 1527 Enumeration<?> result = null; 1528 if (mimeMessage == null) { 1529 // message not loaded, try to get headers only 1530 InputStream headers = getMimeHeaders(); 1531 if (headers != null) { 1532 InternetHeaders internetHeaders = new InternetHeaders(headers); 1533 if (internetHeaders.getHeader("Subject") == null) { 1534 // invalid header content 1535 return null; 1536 } 1537 if (headerNames == null) { 1538 result = internetHeaders.getAllHeaderLines(); 1539 } else { 1540 result = internetHeaders.getMatchingHeaderLines(headerNames); 1541 } 1542 } 1543 } 1544 return result; 1545 } 1546 getMatchingHeaderLines(String[] headerNames)1547 public Enumeration<?> getMatchingHeaderLines(String[] headerNames) throws MessagingException, IOException { 1548 Enumeration<?> result = getMatchingHeaderLinesFromHeaders(headerNames); 1549 if (result == null) { 1550 if (headerNames == null) { 1551 result = getMimeMessage().getAllHeaderLines(); 1552 } else { 1553 result = getMimeMessage().getMatchingHeaderLines(headerNames); 1554 } 1555 1556 } 1557 return result; 1558 } 1559 getMimeHeaders()1560 protected abstract InputStream getMimeHeaders(); 1561 1562 /** 1563 * Get message body size. 1564 * 1565 * @return mime message size 1566 * @throws IOException on error 1567 * @throws MessagingException on error 1568 */ getMimeMessageSize()1569 public int getMimeMessageSize() throws IOException, MessagingException { 1570 loadMimeMessage(); 1571 return mimeContent.length; 1572 } 1573 1574 /** 1575 * Get message body input stream. 1576 * 1577 * @return mime message InputStream 1578 * @throws IOException on error 1579 * @throws MessagingException on error 1580 */ getRawInputStream()1581 public InputStream getRawInputStream() throws IOException, MessagingException { 1582 loadMimeMessage(); 1583 return new SharedByteArrayInputStream(mimeContent); 1584 } 1585 1586 1587 /** 1588 * Drop mime message to avoid keeping message content in memory, 1589 * keep a single message in MessageList cache to handle chunked fetch. 1590 */ dropMimeMessage()1591 public void dropMimeMessage() { 1592 // update single message cache 1593 if (mimeMessage != null) { 1594 messageList.cachedMessageImapUid = imapUid; 1595 messageList.cachedMimeContent = mimeContent; 1596 messageList.cachedMimeMessage = mimeMessage; 1597 } 1598 // drop curent message body to save memory 1599 mimeMessage = null; 1600 mimeContent = null; 1601 } 1602 isLoaded()1603 public boolean isLoaded() { 1604 // check and retrieve cached content 1605 if (imapUid == messageList.cachedMessageImapUid) { 1606 mimeContent = messageList.cachedMimeContent; 1607 mimeMessage = messageList.cachedMimeMessage; 1608 } 1609 return mimeMessage != null; 1610 } 1611 1612 /** 1613 * Delete message. 1614 * 1615 * @throws IOException on error 1616 */ delete()1617 public void delete() throws IOException { 1618 deleteMessage(this); 1619 } 1620 1621 /** 1622 * Move message to trash, mark message read. 1623 * 1624 * @throws IOException on error 1625 */ moveToTrash()1626 public void moveToTrash() throws IOException { 1627 markRead(); 1628 1629 ExchangeSession.this.moveToTrash(this); 1630 } 1631 1632 /** 1633 * Mark message as read. 1634 * 1635 * @throws IOException on error 1636 */ markRead()1637 public void markRead() throws IOException { 1638 HashMap<String, String> properties = new HashMap<>(); 1639 properties.put("read", "1"); 1640 updateMessage(this, properties); 1641 } 1642 1643 /** 1644 * Comparator to sort messages by IMAP uid 1645 * 1646 * @param message other message 1647 * @return imapUid comparison result 1648 */ compareTo(Message message)1649 public int compareTo(Message message) { 1650 long compareValue = (imapUid - message.imapUid); 1651 if (compareValue > 0) { 1652 return 1; 1653 } else if (compareValue < 0) { 1654 return -1; 1655 } else { 1656 return 0; 1657 } 1658 } 1659 1660 /** 1661 * Override equals, compare IMAP uids 1662 * 1663 * @param message other message 1664 * @return true if IMAP uids are equal 1665 */ 1666 @Override equals(Object message)1667 public boolean equals(Object message) { 1668 return message instanceof Message && imapUid == ((Message) message).imapUid; 1669 } 1670 1671 /** 1672 * Override hashCode, return imapUid hashcode. 1673 * 1674 * @return imapUid hashcode 1675 */ 1676 @Override hashCode()1677 public int hashCode() { 1678 return (int) (imapUid ^ (imapUid >>> 32)); 1679 } 1680 removeFlag(String flag)1681 public String removeFlag(String flag) { 1682 if (keywords != null) { 1683 final String exchangeFlag = convertFlagToKeyword(flag); 1684 Set<String> keywordSet = new HashSet<>(); 1685 String[] keywordArray = keywords.split(","); 1686 for (String value : keywordArray) { 1687 if (!value.equalsIgnoreCase(exchangeFlag)) { 1688 keywordSet.add(value); 1689 } 1690 } 1691 keywords = StringUtil.join(keywordSet, ","); 1692 } 1693 return keywords; 1694 } 1695 addFlag(String flag)1696 public String addFlag(String flag) { 1697 final String exchangeFlag = convertFlagToKeyword(flag); 1698 HashSet<String> keywordSet = new HashSet<>(); 1699 boolean hasFlag = false; 1700 if (keywords != null) { 1701 String[] keywordArray = keywords.split(","); 1702 for (String value : keywordArray) { 1703 keywordSet.add(value); 1704 if (value.equalsIgnoreCase(exchangeFlag)) { 1705 hasFlag = true; 1706 } 1707 } 1708 } 1709 if (!hasFlag) { 1710 keywordSet.add(exchangeFlag); 1711 } 1712 keywords = StringUtil.join(keywordSet, ","); 1713 return keywords; 1714 } 1715 setFlags(HashSet<String> flags)1716 public String setFlags(HashSet<String> flags) { 1717 keywords = convertFlagsToKeywords(flags); 1718 return keywords; 1719 } 1720 1721 } 1722 1723 /** 1724 * Message list, includes a single messsage cache 1725 */ 1726 public static class MessageList extends ArrayList<Message> { 1727 /** 1728 * Cached message content parsed in a MIME message. 1729 */ 1730 protected transient MimeMessage cachedMimeMessage; 1731 /** 1732 * Cached message uid. 1733 */ 1734 protected transient long cachedMessageImapUid; 1735 /** 1736 * Cached unparsed message 1737 */ 1738 protected transient byte[] cachedMimeContent; 1739 1740 } 1741 1742 /** 1743 * Generic folder item. 1744 */ 1745 public abstract static class Item extends HashMap<String, String> { 1746 protected String folderPath; 1747 protected String itemName; 1748 protected String permanentUrl; 1749 /** 1750 * Display name. 1751 */ 1752 public String displayName; 1753 /** 1754 * item etag 1755 */ 1756 public String etag; 1757 protected String noneMatch; 1758 1759 /** 1760 * Build item instance. 1761 * 1762 * @param folderPath folder path 1763 * @param itemName item name class 1764 * @param etag item etag 1765 * @param noneMatch none match flag 1766 */ Item(String folderPath, String itemName, String etag, String noneMatch)1767 public Item(String folderPath, String itemName, String etag, String noneMatch) { 1768 this.folderPath = folderPath; 1769 this.itemName = itemName; 1770 this.etag = etag; 1771 this.noneMatch = noneMatch; 1772 } 1773 1774 /** 1775 * Default constructor. 1776 */ Item()1777 protected Item() { 1778 } 1779 1780 /** 1781 * Return item content type 1782 * 1783 * @return content type 1784 */ getContentType()1785 public abstract String getContentType(); 1786 1787 /** 1788 * Retrieve item body from Exchange 1789 * 1790 * @return item body 1791 * @throws IOException on error 1792 */ getBody()1793 public abstract String getBody() throws IOException; 1794 1795 /** 1796 * Get event name (file name part in URL). 1797 * 1798 * @return event name 1799 */ getName()1800 public String getName() { 1801 return itemName; 1802 } 1803 1804 /** 1805 * Get event etag (last change tag). 1806 * 1807 * @return event etag 1808 */ getEtag()1809 public String getEtag() { 1810 return etag; 1811 } 1812 1813 /** 1814 * Set item href. 1815 * 1816 * @param href item href 1817 */ setHref(String href)1818 public void setHref(String href) { 1819 int index = href.lastIndexOf('/'); 1820 if (index >= 0) { 1821 folderPath = href.substring(0, index); 1822 itemName = href.substring(index + 1); 1823 } else { 1824 throw new IllegalArgumentException(href); 1825 } 1826 } 1827 1828 /** 1829 * Return item href. 1830 * 1831 * @return item href 1832 */ getHref()1833 public String getHref() { 1834 return folderPath + '/' + itemName; 1835 } 1836 setItemName(String itemName)1837 public void setItemName(String itemName) { 1838 this.itemName = itemName; 1839 } 1840 } 1841 1842 /** 1843 * Contact object 1844 */ 1845 public abstract class Contact extends Item { 1846 1847 protected ArrayList<String> distributionListMembers = null; 1848 protected String vCardVersion; 1849 Contact(String folderPath, String itemName, Map<String, String> properties, String etag, String noneMatch)1850 public Contact(String folderPath, String itemName, Map<String, String> properties, String etag, String noneMatch) { 1851 super(folderPath, itemName.endsWith(".vcf") ? itemName.substring(0, itemName.length() - 3) + "EML" : itemName, etag, noneMatch); 1852 this.putAll(properties); 1853 } 1854 Contact()1855 protected Contact() { 1856 } 1857 setVCardVersion(String vCardVersion)1858 public void setVCardVersion(String vCardVersion) { 1859 this.vCardVersion = vCardVersion; 1860 } 1861 createOrUpdate()1862 public abstract ItemResult createOrUpdate() throws IOException; 1863 1864 /** 1865 * Convert EML extension to vcf. 1866 * 1867 * @return item name 1868 */ 1869 @Override getName()1870 public String getName() { 1871 String name = super.getName(); 1872 if (name.endsWith(".EML")) { 1873 name = name.substring(0, name.length() - 3) + "vcf"; 1874 } 1875 return name; 1876 } 1877 1878 /** 1879 * Set contact name 1880 * 1881 * @param name contact name 1882 */ setName(String name)1883 public void setName(String name) { 1884 this.itemName = name; 1885 } 1886 1887 /** 1888 * Compute vcard uid from name. 1889 * 1890 * @return uid 1891 */ getUid()1892 public String getUid() { 1893 String uid = getName(); 1894 int dotIndex = uid.lastIndexOf('.'); 1895 if (dotIndex > 0) { 1896 uid = uid.substring(0, dotIndex); 1897 } 1898 return URIUtil.encodePath(uid); 1899 } 1900 1901 @Override getContentType()1902 public String getContentType() { 1903 return "text/vcard"; 1904 } 1905 addMember(String member)1906 public void addMember(String member) { 1907 if (distributionListMembers == null) { 1908 distributionListMembers = new ArrayList<>(); 1909 } 1910 distributionListMembers.add(member); 1911 } 1912 1913 1914 @Override getBody()1915 public String getBody() { 1916 // build RFC 2426 VCard from contact information 1917 VCardWriter writer = new VCardWriter(); 1918 writer.startCard(vCardVersion); 1919 writer.appendProperty("UID", getUid()); 1920 // common name 1921 String cn = get("cn"); 1922 if (cn == null) { 1923 cn = get("displayname"); 1924 } 1925 String sn = get("sn"); 1926 if (sn == null) { 1927 sn = cn; 1928 } 1929 writer.appendProperty("FN", cn); 1930 // RFC 2426: Family Name, Given Name, Additional Names, Honorific Prefixes, and Honorific Suffixes 1931 writer.appendProperty("N", sn, get("givenName"), get("middlename"), get("personaltitle"), get("namesuffix")); 1932 1933 if (distributionListMembers != null) { 1934 writer.appendProperty("KIND", "group"); 1935 for (String member : distributionListMembers) { 1936 writer.appendProperty("MEMBER", member); 1937 } 1938 } 1939 1940 writer.appendProperty("TEL;TYPE=cell", get("mobile")); 1941 writer.appendProperty("TEL;TYPE=work", get("telephoneNumber")); 1942 writer.appendProperty("TEL;TYPE=home", get("homePhone")); 1943 writer.appendProperty("TEL;TYPE=fax", get("facsimiletelephonenumber")); 1944 writer.appendProperty("TEL;TYPE=pager", get("pager")); 1945 writer.appendProperty("TEL;TYPE=car", get("othermobile")); 1946 writer.appendProperty("TEL;TYPE=home,fax", get("homefax")); 1947 writer.appendProperty("TEL;TYPE=isdn", get("internationalisdnnumber")); 1948 writer.appendProperty("TEL;TYPE=msg", get("otherTelephone")); 1949 1950 // The structured type value corresponds, in sequence, to the post office box; the extended address; 1951 // the street address; the locality (e.g., city); the region (e.g., state or province); 1952 // the postal code; the country name 1953 writer.appendProperty("ADR;TYPE=home", 1954 get("homepostofficebox"), null, get("homeStreet"), get("homeCity"), get("homeState"), get("homePostalCode"), get("homeCountry")); 1955 writer.appendProperty("ADR;TYPE=work", 1956 get("postofficebox"), get("roomnumber"), get("street"), get("l"), get("st"), get("postalcode"), get("co")); 1957 writer.appendProperty("ADR;TYPE=other", 1958 get("otherpostofficebox"), null, get("otherstreet"), get("othercity"), get("otherstate"), get("otherpostalcode"), get("othercountry")); 1959 1960 writer.appendProperty("EMAIL;TYPE=work", get("smtpemail1")); 1961 writer.appendProperty("EMAIL;TYPE=home", get("smtpemail2")); 1962 writer.appendProperty("EMAIL;TYPE=other", get("smtpemail3")); 1963 1964 writer.appendProperty("ORG", get("o"), get("department")); 1965 writer.appendProperty("URL;TYPE=work", get("businesshomepage")); 1966 writer.appendProperty("URL;TYPE=home", get("personalHomePage")); 1967 writer.appendProperty("TITLE", get("title")); 1968 writer.appendProperty("NOTE", get("description")); 1969 1970 writer.appendProperty("CUSTOM1", get("extensionattribute1")); 1971 writer.appendProperty("CUSTOM2", get("extensionattribute2")); 1972 writer.appendProperty("CUSTOM3", get("extensionattribute3")); 1973 writer.appendProperty("CUSTOM4", get("extensionattribute4")); 1974 1975 writer.appendProperty("ROLE", get("profession")); 1976 writer.appendProperty("NICKNAME", get("nickname")); 1977 writer.appendProperty("X-AIM", get("im")); 1978 1979 writer.appendProperty("BDAY", convertZuluDateToBday(get("bday"))); 1980 writer.appendProperty("ANNIVERSARY", convertZuluDateToBday(get("anniversary"))); 1981 1982 String gender = get("gender"); 1983 if ("1".equals(gender)) { 1984 writer.appendProperty("SEX", "2"); 1985 } else if ("2".equals(gender)) { 1986 writer.appendProperty("SEX", "1"); 1987 } 1988 1989 writer.appendProperty("CATEGORIES", get("keywords")); 1990 1991 writer.appendProperty("FBURL", get("fburl")); 1992 1993 if ("1".equals(get("private"))) { 1994 writer.appendProperty("CLASS", "PRIVATE"); 1995 } 1996 1997 writer.appendProperty("X-ASSISTANT", get("secretarycn")); 1998 writer.appendProperty("X-MANAGER", get("manager")); 1999 writer.appendProperty("X-SPOUSE", get("spousecn")); 2000 2001 writer.appendProperty("REV", get("lastmodified")); 2002 2003 ContactPhoto contactPhoto = null; 2004 2005 if (Settings.getBooleanProperty("davmail.carddavReadPhoto", true)) { 2006 if (("true".equals(get("haspicture")))) { 2007 try { 2008 contactPhoto = getContactPhoto(this); 2009 } catch (IOException e) { 2010 LOGGER.warn("Unable to get photo from contact " + this.get("cn")); 2011 } 2012 } 2013 2014 if (contactPhoto == null) { 2015 contactPhoto = getADPhoto(get("smtpemail1")); 2016 } 2017 } 2018 2019 if (contactPhoto != null) { 2020 writer.writeLine("PHOTO;TYPE=" + contactPhoto.contentType + ";ENCODING=BASE64:"); 2021 writer.writeLine(contactPhoto.content, true); 2022 } 2023 2024 writer.endCard(); 2025 return writer.toString(); 2026 } 2027 } 2028 2029 /** 2030 * Calendar event object. 2031 */ 2032 public abstract class Event extends Item { 2033 protected String contentClass; 2034 protected String subject; 2035 protected VCalendar vCalendar; 2036 Event(String folderPath, String itemName, String contentClass, String itemBody, String etag, String noneMatch)2037 public Event(String folderPath, String itemName, String contentClass, String itemBody, String etag, String noneMatch) throws IOException { 2038 super(folderPath, itemName, etag, noneMatch); 2039 this.contentClass = contentClass; 2040 fixICS(itemBody.getBytes(StandardCharsets.UTF_8), false); 2041 // fix task item name 2042 if (vCalendar.isTodo() && this.itemName.endsWith(".ics")) { 2043 this.itemName = itemName.substring(0, itemName.length() - 3) + "EML"; 2044 } 2045 } 2046 Event()2047 protected Event() { 2048 } 2049 2050 @Override getContentType()2051 public String getContentType() { 2052 return "text/calendar;charset=UTF-8"; 2053 } 2054 2055 @Override getBody()2056 public String getBody() throws IOException { 2057 if (vCalendar == null) { 2058 fixICS(getEventContent(), true); 2059 } 2060 return vCalendar.toString(); 2061 } 2062 buildHttpNotFoundException(Exception e)2063 protected HttpNotFoundException buildHttpNotFoundException(Exception e) { 2064 String message = "Unable to get event " + getName() + " subject: " + subject + " at " + permanentUrl + ": " + e.getMessage(); 2065 LOGGER.warn(message); 2066 return new HttpNotFoundException(message); 2067 } 2068 2069 /** 2070 * Retrieve item body from Exchange 2071 * 2072 * @return item content 2073 * @throws IOException on error 2074 */ getEventContent()2075 public abstract byte[] getEventContent() throws IOException; 2076 2077 protected static final String TEXT_CALENDAR = "text/calendar"; 2078 protected static final String APPLICATION_ICS = "application/ics"; 2079 isCalendarContentType(String contentType)2080 protected boolean isCalendarContentType(String contentType) { 2081 return TEXT_CALENDAR.regionMatches(true, 0, contentType, 0, TEXT_CALENDAR.length()) || 2082 APPLICATION_ICS.regionMatches(true, 0, contentType, 0, APPLICATION_ICS.length()); 2083 } 2084 getCalendarMimePart(MimeMultipart multiPart)2085 protected MimePart getCalendarMimePart(MimeMultipart multiPart) throws IOException, MessagingException { 2086 MimePart bodyPart = null; 2087 for (int i = 0; i < multiPart.getCount(); i++) { 2088 String contentType = multiPart.getBodyPart(i).getContentType(); 2089 if (isCalendarContentType(contentType)) { 2090 bodyPart = (MimePart) multiPart.getBodyPart(i); 2091 break; 2092 } else if (contentType.startsWith("multipart")) { 2093 Object content = multiPart.getBodyPart(i).getContent(); 2094 if (content instanceof MimeMultipart) { 2095 bodyPart = getCalendarMimePart((MimeMultipart) content); 2096 } 2097 } 2098 } 2099 2100 return bodyPart; 2101 } 2102 2103 /** 2104 * Load ICS content from MIME message input stream 2105 * 2106 * @param mimeInputStream mime message input stream 2107 * @return mime message ics attachment body 2108 * @throws IOException on error 2109 * @throws MessagingException on error 2110 */ getICS(InputStream mimeInputStream)2111 protected byte[] getICS(InputStream mimeInputStream) throws IOException, MessagingException { 2112 byte[] result; 2113 MimeMessage mimeMessage = new MimeMessage(null, mimeInputStream); 2114 String[] contentClassHeader = mimeMessage.getHeader("Content-class"); 2115 // task item, return null 2116 if (contentClassHeader != null && contentClassHeader.length > 0 && "urn:content-classes:task".equals(contentClassHeader[0])) { 2117 return null; 2118 } 2119 Object mimeBody = mimeMessage.getContent(); 2120 MimePart bodyPart = null; 2121 if (mimeBody instanceof MimeMultipart) { 2122 bodyPart = getCalendarMimePart((MimeMultipart) mimeBody); 2123 } else if (isCalendarContentType(mimeMessage.getContentType())) { 2124 // no multipart, single body 2125 bodyPart = mimeMessage; 2126 } 2127 2128 if (bodyPart != null) { 2129 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 2130 bodyPart.getDataHandler().writeTo(baos); 2131 baos.close(); 2132 result = baos.toByteArray(); 2133 } else { 2134 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 2135 mimeMessage.writeTo(baos); 2136 baos.close(); 2137 throw new DavMailException("EXCEPTION_INVALID_MESSAGE_CONTENT", new String(baos.toByteArray(), StandardCharsets.UTF_8)); 2138 } 2139 return result; 2140 } 2141 fixICS(byte[] icsContent, boolean fromServer)2142 protected void fixICS(byte[] icsContent, boolean fromServer) throws IOException { 2143 if (LOGGER.isDebugEnabled() && fromServer) { 2144 dumpIndex++; 2145 String icsBody = new String(icsContent, StandardCharsets.UTF_8); 2146 dumpICS(icsBody, true, false); 2147 LOGGER.debug("Vcalendar body received from server:\n" + icsBody); 2148 } 2149 vCalendar = new VCalendar(icsContent, getEmail(), getVTimezone()); 2150 vCalendar.fixVCalendar(fromServer); 2151 if (LOGGER.isDebugEnabled() && !fromServer) { 2152 String resultString = vCalendar.toString(); 2153 LOGGER.debug("Fixed Vcalendar body to server:\n" + resultString); 2154 dumpICS(resultString, false, true); 2155 } 2156 } 2157 dumpICS(String icsBody, boolean fromServer, boolean after)2158 protected void dumpICS(String icsBody, boolean fromServer, boolean after) { 2159 String logFileDirectory = Settings.getLogFileDirectory(); 2160 2161 // additional setting to activate ICS dump (not available in GUI) 2162 int dumpMax = Settings.getIntProperty("davmail.dumpICS"); 2163 if (dumpMax > 0) { 2164 if (dumpIndex > dumpMax) { 2165 // Delete the oldest dump file 2166 final int oldest = dumpIndex - dumpMax; 2167 try { 2168 File[] oldestFiles = (new File(logFileDirectory)).listFiles((dir, name) -> { 2169 if (name.endsWith(".ics")) { 2170 int dashIndex = name.indexOf('-'); 2171 if (dashIndex > 0) { 2172 try { 2173 int fileIndex = Integer.parseInt(name.substring(0, dashIndex)); 2174 return fileIndex < oldest; 2175 } catch (NumberFormatException nfe) { 2176 // ignore 2177 } 2178 } 2179 } 2180 return false; 2181 }); 2182 if (oldestFiles != null) { 2183 for (File file : oldestFiles) { 2184 if (!file.delete()) { 2185 LOGGER.warn("Unable to delete " + file.getAbsolutePath()); 2186 } 2187 } 2188 } 2189 } catch (Exception ex) { 2190 LOGGER.warn("Error deleting ics dump: " + ex.getMessage()); 2191 } 2192 } 2193 2194 StringBuilder filePath = new StringBuilder(); 2195 filePath.append(logFileDirectory).append('/') 2196 .append(dumpIndex) 2197 .append(after ? "-to" : "-from") 2198 .append((after ^ fromServer) ? "-server" : "-client") 2199 .append(".ics"); 2200 if ((icsBody != null) && (icsBody.length() > 0)) { 2201 OutputStreamWriter writer = null; 2202 try { 2203 writer = new OutputStreamWriter(new FileOutputStream(filePath.toString()), StandardCharsets.UTF_8); 2204 writer.write(icsBody); 2205 } catch (IOException e) { 2206 LOGGER.error(e); 2207 } finally { 2208 if (writer != null) { 2209 try { 2210 writer.close(); 2211 } catch (IOException e) { 2212 LOGGER.error(e); 2213 } 2214 } 2215 } 2216 2217 2218 } 2219 } 2220 2221 } 2222 2223 /** 2224 * Build Mime body for event or event message. 2225 * 2226 * @return mimeContent as byte array or null 2227 * @throws IOException on error 2228 */ createMimeContent()2229 public byte[] createMimeContent() throws IOException { 2230 String boundary = UUID.randomUUID().toString(); 2231 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 2232 MimeOutputStreamWriter writer = new MimeOutputStreamWriter(baos); 2233 2234 writer.writeHeader("Content-Transfer-Encoding", "7bit"); 2235 writer.writeHeader("Content-class", contentClass); 2236 // append date 2237 writer.writeHeader("Date", new Date()); 2238 2239 // Make sure invites have a proper subject line 2240 String vEventSubject = vCalendar.getFirstVeventPropertyValue("SUMMARY"); 2241 if (vEventSubject == null) { 2242 vEventSubject = BundleMessage.format("MEETING_REQUEST"); 2243 } 2244 2245 // Write a part of the message that contains the 2246 // ICS description so that invites contain the description text 2247 String description = vCalendar.getFirstVeventPropertyValue("DESCRIPTION"); 2248 2249 // handle notifications 2250 if ("urn:content-classes:calendarmessage".equals(contentClass)) { 2251 // need to parse attendees and organizer to build recipients 2252 VCalendar.Recipients recipients = vCalendar.getRecipients(true); 2253 String to; 2254 String cc; 2255 String notificationSubject; 2256 if (email.equalsIgnoreCase(recipients.organizer)) { 2257 // current user is organizer => notify all 2258 to = recipients.attendees; 2259 cc = recipients.optionalAttendees; 2260 notificationSubject = subject; 2261 } else { 2262 String status = vCalendar.getAttendeeStatus(); 2263 // notify only organizer 2264 to = recipients.organizer; 2265 cc = null; 2266 notificationSubject = (status != null) ? (BundleMessage.format(status) + vEventSubject) : subject; 2267 description = ""; 2268 } 2269 2270 // Allow end user notification edit 2271 if (Settings.getBooleanProperty("davmail.caldavEditNotifications")) { 2272 // create notification edit dialog 2273 NotificationDialog notificationDialog = new NotificationDialog(to, 2274 cc, notificationSubject, description); 2275 if (!notificationDialog.getSendNotification()) { 2276 LOGGER.debug("Notification canceled by user"); 2277 return null; 2278 } 2279 // get description from dialog 2280 to = notificationDialog.getTo(); 2281 cc = notificationDialog.getCc(); 2282 notificationSubject = notificationDialog.getSubject(); 2283 description = notificationDialog.getBody(); 2284 } 2285 2286 // do not send notification if no recipients found 2287 if ((to == null || to.length() == 0) && (cc == null || cc.length() == 0)) { 2288 return null; 2289 } 2290 2291 writer.writeHeader("To", to); 2292 writer.writeHeader("Cc", cc); 2293 writer.writeHeader("Subject", notificationSubject); 2294 2295 2296 if (LOGGER.isDebugEnabled()) { 2297 StringBuilder logBuffer = new StringBuilder("Sending notification "); 2298 if (to != null) { 2299 logBuffer.append("to: ").append(to); 2300 } 2301 if (cc != null) { 2302 logBuffer.append("cc: ").append(cc); 2303 } 2304 LOGGER.debug(logBuffer.toString()); 2305 } 2306 } else { 2307 // need to parse attendees and organizer to build recipients 2308 VCalendar.Recipients recipients = vCalendar.getRecipients(false); 2309 // storing appointment, full recipients header 2310 if (recipients.attendees != null) { 2311 writer.writeHeader("To", recipients.attendees); 2312 } else { 2313 // use current user as attendee 2314 writer.writeHeader("To", email); 2315 } 2316 writer.writeHeader("Cc", recipients.optionalAttendees); 2317 2318 if (recipients.organizer != null) { 2319 writer.writeHeader("From", recipients.organizer); 2320 } else { 2321 writer.writeHeader("From", email); 2322 } 2323 } 2324 if (vCalendar.getMethod() == null) { 2325 vCalendar.setPropertyValue("METHOD", "REQUEST"); 2326 } 2327 writer.writeHeader("MIME-Version", "1.0"); 2328 writer.writeHeader("Content-Type", "multipart/alternative;\r\n" + 2329 "\tboundary=\"----=_NextPart_" + boundary + '\"'); 2330 writer.writeLn(); 2331 writer.writeLn("This is a multi-part message in MIME format."); 2332 writer.writeLn(); 2333 writer.writeLn("------=_NextPart_" + boundary); 2334 2335 if (description != null && description.length() > 0) { 2336 writer.writeHeader("Content-Type", "text/plain;\r\n" + 2337 "\tcharset=\"utf-8\""); 2338 writer.writeHeader("content-transfer-encoding", "8bit"); 2339 writer.writeLn(); 2340 writer.flush(); 2341 baos.write(description.getBytes(StandardCharsets.UTF_8)); 2342 writer.writeLn(); 2343 writer.writeLn("------=_NextPart_" + boundary); 2344 } 2345 writer.writeHeader("Content-class", contentClass); 2346 writer.writeHeader("Content-Type", "text/calendar;\r\n" + 2347 "\tmethod=" + vCalendar.getMethod() + ";\r\n" + 2348 "\tcharset=\"utf-8\"" 2349 ); 2350 writer.writeHeader("Content-Transfer-Encoding", "8bit"); 2351 writer.writeLn(); 2352 writer.flush(); 2353 baos.write(vCalendar.toString().getBytes(StandardCharsets.UTF_8)); 2354 writer.writeLn(); 2355 writer.writeLn("------=_NextPart_" + boundary + "--"); 2356 writer.close(); 2357 return baos.toByteArray(); 2358 } 2359 2360 /** 2361 * Create or update item 2362 * 2363 * @return action result 2364 * @throws IOException on error 2365 */ createOrUpdate()2366 public abstract ItemResult createOrUpdate() throws IOException; 2367 2368 } 2369 getItemProperties()2370 protected abstract Set<String> getItemProperties(); 2371 2372 /** 2373 * Search contacts in provided folder. 2374 * 2375 * @param folderPath Exchange folder path 2376 * @param includeDistList include distribution lists 2377 * @return list of contacts 2378 * @throws IOException on error 2379 */ getAllContacts(String folderPath, boolean includeDistList)2380 public List<ExchangeSession.Contact> getAllContacts(String folderPath, boolean includeDistList) throws IOException { 2381 return searchContacts(folderPath, ExchangeSession.CONTACT_ATTRIBUTES, isEqualTo("outlookmessageclass", "IPM.Contact"), 0); 2382 } 2383 2384 2385 /** 2386 * Search contacts in provided folder matching the search query. 2387 * 2388 * @param folderPath Exchange folder path 2389 * @param attributes requested attributes 2390 * @param condition Exchange search query 2391 * @param maxCount maximum item count 2392 * @return list of contacts 2393 * @throws IOException on error 2394 */ searchContacts(String folderPath, Set<String> attributes, Condition condition, int maxCount)2395 public abstract List<Contact> searchContacts(String folderPath, Set<String> attributes, Condition condition, int maxCount) throws IOException; 2396 2397 /** 2398 * Search calendar messages in provided folder. 2399 * 2400 * @param folderPath Exchange folder path 2401 * @return list of calendar messages as Event objects 2402 * @throws IOException on error 2403 */ getEventMessages(String folderPath)2404 public abstract List<Event> getEventMessages(String folderPath) throws IOException; 2405 2406 /** 2407 * Search calendar events in provided folder. 2408 * 2409 * @param folderPath Exchange folder path 2410 * @return list of calendar events 2411 * @throws IOException on error 2412 */ getAllEvents(String folderPath)2413 public List<Event> getAllEvents(String folderPath) throws IOException { 2414 List<Event> results = searchEvents(folderPath, getCalendarItemCondition(getPastDelayCondition("dtstart"))); 2415 2416 if (!Settings.getBooleanProperty("davmail.caldavDisableTasks", false) && isMainCalendar(folderPath)) { 2417 // retrieve tasks from main tasks folder 2418 results.addAll(searchTasksOnly(TASKS)); 2419 } 2420 2421 return results; 2422 } 2423 getCalendarItemCondition(Condition dateCondition)2424 protected abstract Condition getCalendarItemCondition(Condition dateCondition); 2425 getPastDelayCondition(String attribute)2426 protected Condition getPastDelayCondition(String attribute) { 2427 int caldavPastDelay = Settings.getIntProperty("davmail.caldavPastDelay"); 2428 Condition dateCondition = null; 2429 if (caldavPastDelay != 0) { 2430 Calendar cal = Calendar.getInstance(); 2431 cal.add(Calendar.DAY_OF_MONTH, -caldavPastDelay); 2432 dateCondition = gt(attribute, formatSearchDate(cal.getTime())); 2433 } 2434 return dateCondition; 2435 } 2436 getRangeCondition(String timeRangeStart, String timeRangeEnd)2437 protected Condition getRangeCondition(String timeRangeStart, String timeRangeEnd) throws IOException { 2438 try { 2439 SimpleDateFormat parser = getZuluDateFormat(); 2440 ExchangeSession.MultiCondition andCondition = and(); 2441 if (timeRangeStart != null) { 2442 andCondition.add(gt("dtend", formatSearchDate(parser.parse(timeRangeStart)))); 2443 } 2444 if (timeRangeEnd != null) { 2445 andCondition.add(lt("dtstart", formatSearchDate(parser.parse(timeRangeEnd)))); 2446 } 2447 return andCondition; 2448 } catch (ParseException e) { 2449 throw new IOException(e + " " + e.getMessage()); 2450 } 2451 } 2452 2453 /** 2454 * Search events between start and end. 2455 * 2456 * @param folderPath Exchange folder path 2457 * @param timeRangeStart date range start in zulu format 2458 * @param timeRangeEnd date range start in zulu format 2459 * @return list of calendar events 2460 * @throws IOException on error 2461 */ searchEvents(String folderPath, String timeRangeStart, String timeRangeEnd)2462 public List<Event> searchEvents(String folderPath, String timeRangeStart, String timeRangeEnd) throws IOException { 2463 Condition dateCondition = getRangeCondition(timeRangeStart, timeRangeEnd); 2464 Condition condition = getCalendarItemCondition(dateCondition); 2465 2466 return searchEvents(folderPath, condition); 2467 } 2468 2469 /** 2470 * Search events between start and end, exclude tasks. 2471 * 2472 * @param folderPath Exchange folder path 2473 * @param timeRangeStart date range start in zulu format 2474 * @param timeRangeEnd date range start in zulu format 2475 * @return list of calendar events 2476 * @throws IOException on error 2477 */ searchEventsOnly(String folderPath, String timeRangeStart, String timeRangeEnd)2478 public List<Event> searchEventsOnly(String folderPath, String timeRangeStart, String timeRangeEnd) throws IOException { 2479 Condition dateCondition = getRangeCondition(timeRangeStart, timeRangeEnd); 2480 return searchEvents(folderPath, getCalendarItemCondition(dateCondition)); 2481 } 2482 2483 /** 2484 * Search tasks only (VTODO). 2485 * 2486 * @param folderPath Exchange folder path 2487 * @return list of tasks 2488 * @throws IOException on error 2489 */ searchTasksOnly(String folderPath)2490 public List<Event> searchTasksOnly(String folderPath) throws IOException { 2491 return searchEvents(folderPath, and(isEqualTo("outlookmessageclass", "IPM.Task"), 2492 or(isNull("datecompleted"), getPastDelayCondition("datecompleted")))); 2493 } 2494 2495 /** 2496 * Search calendar events in provided folder. 2497 * 2498 * @param folderPath Exchange folder path 2499 * @param filter search filter 2500 * @return list of calendar events 2501 * @throws IOException on error 2502 */ searchEvents(String folderPath, Condition filter)2503 public List<Event> searchEvents(String folderPath, Condition filter) throws IOException { 2504 2505 Condition privateCondition = null; 2506 if (isSharedFolder(folderPath) && Settings.getBooleanProperty("davmail.excludePrivateEvents", true)) { 2507 LOGGER.debug("Shared or public calendar: exclude private events"); 2508 privateCondition = isEqualTo("sensitivity", 0); 2509 } 2510 2511 return searchEvents(folderPath, getItemProperties(), 2512 and(filter, privateCondition)); 2513 } 2514 2515 /** 2516 * Search calendar events or messages in provided folder matching the search query. 2517 * 2518 * @param folderPath Exchange folder path 2519 * @param attributes requested attributes 2520 * @param condition Exchange search query 2521 * @return list of calendar messages as Event objects 2522 * @throws IOException on error 2523 */ searchEvents(String folderPath, Set<String> attributes, Condition condition)2524 public abstract List<Event> searchEvents(String folderPath, Set<String> attributes, Condition condition) throws IOException; 2525 2526 /** 2527 * convert vcf extension to EML. 2528 * 2529 * @param itemName item name 2530 * @return EML item name 2531 */ convertItemNameToEML(String itemName)2532 protected String convertItemNameToEML(String itemName) { 2533 if (itemName.endsWith(".vcf")) { 2534 return itemName.substring(0, itemName.length() - 3) + "EML"; 2535 } else { 2536 return itemName; 2537 } 2538 } 2539 2540 /** 2541 * Get item named eventName in folder 2542 * 2543 * @param folderPath Exchange folder path 2544 * @param itemName event name 2545 * @return event object 2546 * @throws IOException on error 2547 */ getItem(String folderPath, String itemName)2548 public abstract Item getItem(String folderPath, String itemName) throws IOException; 2549 2550 /** 2551 * Contact picture 2552 */ 2553 public static class ContactPhoto { 2554 /** 2555 * Contact picture content type (always image/jpeg on read) 2556 */ 2557 public String contentType; 2558 /** 2559 * Base64 encoded picture content 2560 */ 2561 public String content; 2562 } 2563 2564 /** 2565 * Retrieve contact photo attached to contact 2566 * 2567 * @param contact address book contact 2568 * @return contact photo 2569 * @throws IOException on error 2570 */ getContactPhoto(Contact contact)2571 public abstract ContactPhoto getContactPhoto(Contact contact) throws IOException; 2572 2573 /** 2574 * Retrieve contact photo from AD 2575 * 2576 * @param email address book contact 2577 * @return contact photo 2578 */ getADPhoto(String email)2579 public ContactPhoto getADPhoto(String email) { 2580 return null; 2581 } 2582 2583 /** 2584 * Delete event named itemName in folder 2585 * 2586 * @param folderPath Exchange folder path 2587 * @param itemName item name 2588 * @throws IOException on error 2589 */ deleteItem(String folderPath, String itemName)2590 public abstract void deleteItem(String folderPath, String itemName) throws IOException; 2591 2592 /** 2593 * Mark event processed named eventName in folder 2594 * 2595 * @param folderPath Exchange folder path 2596 * @param itemName item name 2597 * @throws IOException on error 2598 */ processItem(String folderPath, String itemName)2599 public abstract void processItem(String folderPath, String itemName) throws IOException; 2600 2601 2602 private static int dumpIndex; 2603 2604 /** 2605 * Replace iCal4 (Snow Leopard) principal paths with mailto expression 2606 * 2607 * @param value attendee value or ics line 2608 * @return fixed value 2609 */ replaceIcal4Principal(String value)2610 protected String replaceIcal4Principal(String value) { 2611 if (value != null && value.contains("/principals/__uuids__/")) { 2612 return value.replaceAll("/principals/__uuids__/([^/]*)__AT__([^/]*)/", "mailto:$1@$2"); 2613 } else { 2614 return value; 2615 } 2616 } 2617 2618 /** 2619 * Event result object to hold HTTP status and event etag from an event creation/update. 2620 */ 2621 public static class ItemResult { 2622 /** 2623 * HTTP status 2624 */ 2625 public int status; 2626 /** 2627 * Event etag from response HTTP header 2628 */ 2629 public String etag; 2630 /** 2631 * Created item name 2632 */ 2633 public String itemName; 2634 } 2635 2636 /** 2637 * Build and send the MIME message for the provided ICS event. 2638 * 2639 * @param icsBody event in iCalendar format 2640 * @return HTTP status 2641 * @throws IOException on error 2642 */ sendEvent(String icsBody)2643 public abstract int sendEvent(String icsBody) throws IOException; 2644 2645 /** 2646 * Create or update item (event or contact) on the Exchange server 2647 * 2648 * @param folderPath Exchange folder path 2649 * @param itemName event name 2650 * @param itemBody event body in iCalendar format 2651 * @param etag previous event etag to detect concurrent updates 2652 * @param noneMatch if-none-match header value 2653 * @return HTTP response event result (status and etag) 2654 * @throws IOException on error 2655 */ createOrUpdateItem(String folderPath, String itemName, String itemBody, String etag, String noneMatch)2656 public ItemResult createOrUpdateItem(String folderPath, String itemName, String itemBody, String etag, String noneMatch) throws IOException { 2657 if (itemBody.startsWith("BEGIN:VCALENDAR")) { 2658 return internalCreateOrUpdateEvent(folderPath, itemName, "urn:content-classes:appointment", itemBody, etag, noneMatch); 2659 } else if (itemBody.startsWith("BEGIN:VCARD")) { 2660 return createOrUpdateContact(folderPath, itemName, itemBody, etag, noneMatch); 2661 } else { 2662 throw new IOException(BundleMessage.format("EXCEPTION_INVALID_MESSAGE_CONTENT", itemBody)); 2663 } 2664 } 2665 2666 static final String[] VCARD_N_PROPERTIES = {"sn", "givenName", "middlename", "personaltitle", "namesuffix"}; 2667 static final String[] VCARD_ADR_HOME_PROPERTIES = {"homepostofficebox", null, "homeStreet", "homeCity", "homeState", "homePostalCode", "homeCountry"}; 2668 static final String[] VCARD_ADR_WORK_PROPERTIES = {"postofficebox", "roomnumber", "street", "l", "st", "postalcode", "co"}; 2669 static final String[] VCARD_ADR_OTHER_PROPERTIES = {"otherpostofficebox", null, "otherstreet", "othercity", "otherstate", "otherpostalcode", "othercountry"}; 2670 static final String[] VCARD_ORG_PROPERTIES = {"o", "department"}; 2671 convertContactProperties(Map<String, String> properties, String[] contactProperties, List<String> values)2672 protected void convertContactProperties(Map<String, String> properties, String[] contactProperties, List<String> values) { 2673 for (int i = 0; i < values.size() && i < contactProperties.length; i++) { 2674 if (contactProperties[i] != null) { 2675 properties.put(contactProperties[i], values.get(i)); 2676 } 2677 } 2678 } 2679 createOrUpdateContact(String folderPath, String itemName, String itemBody, String etag, String noneMatch)2680 protected ItemResult createOrUpdateContact(String folderPath, String itemName, String itemBody, String etag, String noneMatch) throws IOException { 2681 // parse VCARD body to build contact property map 2682 Map<String, String> properties = new HashMap<>(); 2683 2684 VObject vcard = new VObject(new ICSBufferedReader(new StringReader(itemBody))); 2685 if ("group".equalsIgnoreCase(vcard.getPropertyValue("KIND"))) { 2686 properties.put("outlookmessageclass", "IPM.DistList"); 2687 properties.put("displayname", vcard.getPropertyValue("FN")); 2688 } else { 2689 properties.put("outlookmessageclass", "IPM.Contact"); 2690 2691 for (VProperty property : vcard.getProperties()) { 2692 if ("FN".equals(property.getKey())) { 2693 properties.put("cn", property.getValue()); 2694 properties.put("subject", property.getValue()); 2695 properties.put("fileas", property.getValue()); 2696 2697 } else if ("N".equals(property.getKey())) { 2698 convertContactProperties(properties, VCARD_N_PROPERTIES, property.getValues()); 2699 } else if ("NICKNAME".equals(property.getKey())) { 2700 properties.put("nickname", property.getValue()); 2701 } else if ("TEL".equals(property.getKey())) { 2702 if (property.hasParam("TYPE", "cell") || property.hasParam("X-GROUP", "cell")) { 2703 properties.put("mobile", property.getValue()); 2704 } else if (property.hasParam("TYPE", "work") || property.hasParam("X-GROUP", "work")) { 2705 properties.put("telephoneNumber", property.getValue()); 2706 } else if (property.hasParam("TYPE", "home") || property.hasParam("X-GROUP", "home")) { 2707 properties.put("homePhone", property.getValue()); 2708 } else if (property.hasParam("TYPE", "fax")) { 2709 if (property.hasParam("TYPE", "home")) { 2710 properties.put("homefax", property.getValue()); 2711 } else { 2712 properties.put("facsimiletelephonenumber", property.getValue()); 2713 } 2714 } else if (property.hasParam("TYPE", "pager")) { 2715 properties.put("pager", property.getValue()); 2716 } else if (property.hasParam("TYPE", "car")) { 2717 properties.put("othermobile", property.getValue()); 2718 } else { 2719 properties.put("otherTelephone", property.getValue()); 2720 } 2721 } else if ("ADR".equals(property.getKey())) { 2722 // address 2723 if (property.hasParam("TYPE", "home")) { 2724 convertContactProperties(properties, VCARD_ADR_HOME_PROPERTIES, property.getValues()); 2725 } else if (property.hasParam("TYPE", "work")) { 2726 convertContactProperties(properties, VCARD_ADR_WORK_PROPERTIES, property.getValues()); 2727 // any other type goes to other address 2728 } else { 2729 convertContactProperties(properties, VCARD_ADR_OTHER_PROPERTIES, property.getValues()); 2730 } 2731 } else if ("EMAIL".equals(property.getKey())) { 2732 if (property.hasParam("TYPE", "home")) { 2733 properties.put("email2", property.getValue()); 2734 properties.put("smtpemail2", property.getValue()); 2735 } else if (property.hasParam("TYPE", "other")) { 2736 properties.put("email3", property.getValue()); 2737 properties.put("smtpemail3", property.getValue()); 2738 } else { 2739 properties.put("email1", property.getValue()); 2740 properties.put("smtpemail1", property.getValue()); 2741 } 2742 } else if ("ORG".equals(property.getKey())) { 2743 convertContactProperties(properties, VCARD_ORG_PROPERTIES, property.getValues()); 2744 } else if ("URL".equals(property.getKey())) { 2745 if (property.hasParam("TYPE", "work")) { 2746 properties.put("businesshomepage", property.getValue()); 2747 } else if (property.hasParam("TYPE", "home")) { 2748 properties.put("personalHomePage", property.getValue()); 2749 } else { 2750 // default: set personal home page 2751 properties.put("personalHomePage", property.getValue()); 2752 } 2753 } else if ("TITLE".equals(property.getKey())) { 2754 properties.put("title", property.getValue()); 2755 } else if ("NOTE".equals(property.getKey())) { 2756 properties.put("description", property.getValue()); 2757 } else if ("CUSTOM1".equals(property.getKey())) { 2758 properties.put("extensionattribute1", property.getValue()); 2759 } else if ("CUSTOM2".equals(property.getKey())) { 2760 properties.put("extensionattribute2", property.getValue()); 2761 } else if ("CUSTOM3".equals(property.getKey())) { 2762 properties.put("extensionattribute3", property.getValue()); 2763 } else if ("CUSTOM4".equals(property.getKey())) { 2764 properties.put("extensionattribute4", property.getValue()); 2765 } else if ("ROLE".equals(property.getKey())) { 2766 properties.put("profession", property.getValue()); 2767 } else if ("X-AIM".equals(property.getKey())) { 2768 properties.put("im", property.getValue()); 2769 } else if ("BDAY".equals(property.getKey())) { 2770 properties.put("bday", convertBDayToZulu(property.getValue())); 2771 } else if ("ANNIVERSARY".equals(property.getKey()) || "X-ANNIVERSARY".equals(property.getKey())) { 2772 properties.put("anniversary", convertBDayToZulu(property.getValue())); 2773 } else if ("CATEGORIES".equals(property.getKey())) { 2774 properties.put("keywords", property.getValue()); 2775 } else if ("CLASS".equals(property.getKey())) { 2776 if ("PUBLIC".equals(property.getValue())) { 2777 properties.put("sensitivity", "0"); 2778 properties.put("private", "false"); 2779 } else { 2780 properties.put("sensitivity", "2"); 2781 properties.put("private", "true"); 2782 } 2783 } else if ("SEX".equals(property.getKey())) { 2784 String propertyValue = property.getValue(); 2785 if ("1".equals(propertyValue)) { 2786 properties.put("gender", "2"); 2787 } else if ("2".equals(propertyValue)) { 2788 properties.put("gender", "1"); 2789 } 2790 } else if ("FBURL".equals(property.getKey())) { 2791 properties.put("fburl", property.getValue()); 2792 } else if ("X-ASSISTANT".equals(property.getKey())) { 2793 properties.put("secretarycn", property.getValue()); 2794 } else if ("X-MANAGER".equals(property.getKey())) { 2795 properties.put("manager", property.getValue()); 2796 } else if ("X-SPOUSE".equals(property.getKey())) { 2797 properties.put("spousecn", property.getValue()); 2798 } else if ("PHOTO".equals(property.getKey())) { 2799 properties.put("photo", property.getValue()); 2800 properties.put("haspicture", "true"); 2801 } 2802 } 2803 LOGGER.debug("Create or update contact " + itemName + ": " + properties); 2804 // reset missing properties to null 2805 for (String key : CONTACT_ATTRIBUTES) { 2806 if (!"imapUid".equals(key) && !"etag".equals(key) && !"urlcompname".equals(key) 2807 && !"lastmodified".equals(key) && !"sensitivity".equals(key) && 2808 !properties.containsKey(key)) { 2809 properties.put(key, null); 2810 } 2811 } 2812 } 2813 2814 Contact contact = buildContact(folderPath, itemName, properties, etag, noneMatch); 2815 for (VProperty property : vcard.getProperties()) { 2816 if ("MEMBER".equals(property.getKey())) { 2817 String member = property.getValue(); 2818 if (member.startsWith("urn:uuid:")) { 2819 Item item = getItem(folderPath, member.substring(9) + ".EML"); 2820 if (item != null) { 2821 if (item.get("smtpemail1") != null) { 2822 member = "mailto:" + item.get("smtpemail1"); 2823 } else if (item.get("smtpemail2") != null) { 2824 member = "mailto:" + item.get("smtpemail2"); 2825 } else if (item.get("smtpemail3") != null) { 2826 member = "mailto:" + item.get("smtpemail3"); 2827 } 2828 } 2829 } 2830 contact.addMember(member); 2831 } 2832 } 2833 return contact.createOrUpdate(); 2834 } 2835 convertZuluDateToBday(String value)2836 protected String convertZuluDateToBday(String value) { 2837 String result = null; 2838 if (value != null && value.length() > 0) { 2839 try { 2840 SimpleDateFormat parser = ExchangeSession.getZuluDateFormat(); 2841 Calendar cal = Calendar.getInstance(); 2842 cal.setTime(parser.parse(value)); 2843 cal.add(Calendar.HOUR_OF_DAY, 12); 2844 result = ExchangeSession.getVcardBdayFormat().format(cal.getTime()); 2845 } catch (ParseException e) { 2846 LOGGER.warn("Invalid date: " + value); 2847 } 2848 } 2849 return result; 2850 } 2851 convertBDayToZulu(String value)2852 protected String convertBDayToZulu(String value) { 2853 String result = null; 2854 if (value != null && value.length() > 0) { 2855 try { 2856 SimpleDateFormat parser; 2857 if (value.length() == 10) { 2858 parser = ExchangeSession.getVcardBdayFormat(); 2859 } else if (value.length() == 15) { 2860 parser = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.ENGLISH); 2861 parser.setTimeZone(GMT_TIMEZONE); 2862 } else { 2863 parser = ExchangeSession.getExchangeZuluDateFormat(); 2864 } 2865 result = ExchangeSession.getExchangeZuluDateFormatMillisecond().format(parser.parse(value)); 2866 } catch (ParseException e) { 2867 LOGGER.warn("Invalid date: " + value); 2868 } 2869 } 2870 2871 return result; 2872 } 2873 2874 buildContact(String folderPath, String itemName, Map<String, String> properties, String etag, String noneMatch)2875 protected abstract Contact buildContact(String folderPath, String itemName, Map<String, String> properties, String etag, String noneMatch) throws IOException; 2876 internalCreateOrUpdateEvent(String folderPath, String itemName, String contentClass, String icsBody, String etag, String noneMatch)2877 protected abstract ItemResult internalCreateOrUpdateEvent(String folderPath, String itemName, String contentClass, String icsBody, String etag, String noneMatch) throws IOException; 2878 2879 /** 2880 * Get current Exchange alias name from login name 2881 * 2882 * @return user name 2883 */ getAliasFromLogin()2884 public String getAliasFromLogin() { 2885 // login is email, not alias 2886 if (this.userName.indexOf('@') >= 0) { 2887 return null; 2888 } 2889 String result = this.userName; 2890 // remove domain name 2891 int index = Math.max(result.indexOf('\\'), result.indexOf('/')); 2892 if (index >= 0) { 2893 result = result.substring(index + 1); 2894 } 2895 return result; 2896 } 2897 2898 /** 2899 * Test if folderPath is inside user mailbox. 2900 * 2901 * @param folderPath absolute folder path 2902 * @return true if folderPath is a public or shared folder 2903 */ isSharedFolder(String folderPath)2904 public abstract boolean isSharedFolder(String folderPath); 2905 2906 /** 2907 * Test if folderPath is main calendar. 2908 * 2909 * @param folderPath absolute folder path 2910 * @return true if folderPath is a public or shared folder 2911 */ isMainCalendar(String folderPath)2912 public abstract boolean isMainCalendar(String folderPath) throws IOException; 2913 2914 protected static final String MAILBOX_BASE = "/cn="; 2915 2916 /** 2917 * Get current user email 2918 * 2919 * @return user email 2920 */ getEmail()2921 public String getEmail() { 2922 return email; 2923 } 2924 2925 /** 2926 * Get current user alias 2927 * 2928 * @return user email 2929 */ getAlias()2930 public String getAlias() { 2931 return alias; 2932 } 2933 2934 /** 2935 * Search global address list 2936 * 2937 * @param condition search filter 2938 * @param returningAttributes returning attributes 2939 * @param sizeLimit size limit 2940 * @return matching contacts from gal 2941 * @throws IOException on error 2942 */ galFind(Condition condition, Set<String> returningAttributes, int sizeLimit)2943 public abstract Map<String, Contact> galFind(Condition condition, Set<String> returningAttributes, int sizeLimit) throws IOException; 2944 2945 /** 2946 * Full Contact attribute list 2947 */ 2948 public static final Set<String> CONTACT_ATTRIBUTES = new HashSet<>(); 2949 2950 static { 2951 CONTACT_ATTRIBUTES.add("imapUid"); 2952 CONTACT_ATTRIBUTES.add("etag"); 2953 CONTACT_ATTRIBUTES.add("urlcompname"); 2954 2955 CONTACT_ATTRIBUTES.add("extensionattribute1"); 2956 CONTACT_ATTRIBUTES.add("extensionattribute2"); 2957 CONTACT_ATTRIBUTES.add("extensionattribute3"); 2958 CONTACT_ATTRIBUTES.add("extensionattribute4"); 2959 CONTACT_ATTRIBUTES.add("bday"); 2960 CONTACT_ATTRIBUTES.add("anniversary"); 2961 CONTACT_ATTRIBUTES.add("businesshomepage"); 2962 CONTACT_ATTRIBUTES.add("personalHomePage"); 2963 CONTACT_ATTRIBUTES.add("cn"); 2964 CONTACT_ATTRIBUTES.add("co"); 2965 CONTACT_ATTRIBUTES.add("department"); 2966 CONTACT_ATTRIBUTES.add("smtpemail1"); 2967 CONTACT_ATTRIBUTES.add("smtpemail2"); 2968 CONTACT_ATTRIBUTES.add("smtpemail3"); 2969 CONTACT_ATTRIBUTES.add("facsimiletelephonenumber"); 2970 CONTACT_ATTRIBUTES.add("givenName"); 2971 CONTACT_ATTRIBUTES.add("homeCity"); 2972 CONTACT_ATTRIBUTES.add("homeCountry"); 2973 CONTACT_ATTRIBUTES.add("homePhone"); 2974 CONTACT_ATTRIBUTES.add("homePostalCode"); 2975 CONTACT_ATTRIBUTES.add("homeState"); 2976 CONTACT_ATTRIBUTES.add("homeStreet"); 2977 CONTACT_ATTRIBUTES.add("homepostofficebox"); 2978 CONTACT_ATTRIBUTES.add("l"); 2979 CONTACT_ATTRIBUTES.add("manager"); 2980 CONTACT_ATTRIBUTES.add("mobile"); 2981 CONTACT_ATTRIBUTES.add("namesuffix"); 2982 CONTACT_ATTRIBUTES.add("nickname"); 2983 CONTACT_ATTRIBUTES.add("o"); 2984 CONTACT_ATTRIBUTES.add("pager"); 2985 CONTACT_ATTRIBUTES.add("personaltitle"); 2986 CONTACT_ATTRIBUTES.add("postalcode"); 2987 CONTACT_ATTRIBUTES.add("postofficebox"); 2988 CONTACT_ATTRIBUTES.add("profession"); 2989 CONTACT_ATTRIBUTES.add("roomnumber"); 2990 CONTACT_ATTRIBUTES.add("secretarycn"); 2991 CONTACT_ATTRIBUTES.add("sn"); 2992 CONTACT_ATTRIBUTES.add("spousecn"); 2993 CONTACT_ATTRIBUTES.add("st"); 2994 CONTACT_ATTRIBUTES.add("street"); 2995 CONTACT_ATTRIBUTES.add("telephoneNumber"); 2996 CONTACT_ATTRIBUTES.add("title"); 2997 CONTACT_ATTRIBUTES.add("description"); 2998 CONTACT_ATTRIBUTES.add("im"); 2999 CONTACT_ATTRIBUTES.add("middlename"); 3000 CONTACT_ATTRIBUTES.add("lastmodified"); 3001 CONTACT_ATTRIBUTES.add("otherstreet"); 3002 CONTACT_ATTRIBUTES.add("otherstate"); 3003 CONTACT_ATTRIBUTES.add("otherpostofficebox"); 3004 CONTACT_ATTRIBUTES.add("otherpostalcode"); 3005 CONTACT_ATTRIBUTES.add("othercountry"); 3006 CONTACT_ATTRIBUTES.add("othercity"); 3007 CONTACT_ATTRIBUTES.add("haspicture"); 3008 CONTACT_ATTRIBUTES.add("keywords"); 3009 CONTACT_ATTRIBUTES.add("othermobile"); 3010 CONTACT_ATTRIBUTES.add("otherTelephone"); 3011 CONTACT_ATTRIBUTES.add("gender"); 3012 CONTACT_ATTRIBUTES.add("private"); 3013 CONTACT_ATTRIBUTES.add("sensitivity"); 3014 CONTACT_ATTRIBUTES.add("fburl"); 3015 } 3016 3017 protected static final Set<String> DISTRIBUTION_LIST_ATTRIBUTES = new HashSet<>(); 3018 3019 static { 3020 DISTRIBUTION_LIST_ATTRIBUTES.add("imapUid"); 3021 DISTRIBUTION_LIST_ATTRIBUTES.add("etag"); 3022 DISTRIBUTION_LIST_ATTRIBUTES.add("urlcompname"); 3023 3024 DISTRIBUTION_LIST_ATTRIBUTES.add("cn"); 3025 DISTRIBUTION_LIST_ATTRIBUTES.add("members"); 3026 } 3027 3028 /** 3029 * Get freebusy data string from Exchange. 3030 * 3031 * @param attendee attendee email address 3032 * @param start start date in Exchange zulu format 3033 * @param end end date in Exchange zulu format 3034 * @param interval freebusy interval in minutes 3035 * @return freebusy data or null 3036 * @throws IOException on error 3037 */ getFreeBusyData(String attendee, String start, String end, int interval)3038 protected abstract String getFreeBusyData(String attendee, String start, String end, int interval) throws IOException; 3039 3040 /** 3041 * Get freebusy info for attendee between start and end date. 3042 * 3043 * @param attendee attendee email 3044 * @param startDateValue start date 3045 * @param endDateValue end date 3046 * @return FreeBusy info 3047 * @throws IOException on error 3048 */ getFreebusy(String attendee, String startDateValue, String endDateValue)3049 public FreeBusy getFreebusy(String attendee, String startDateValue, String endDateValue) throws IOException { 3050 // replace ical encoded attendee name 3051 attendee = replaceIcal4Principal(attendee); 3052 3053 // then check that email address is valid to avoid InvalidSmtpAddress error 3054 if (attendee == null || attendee.indexOf('@') < 0 || attendee.charAt(attendee.length() - 1) == '@') { 3055 return null; 3056 } 3057 3058 if (attendee.startsWith("mailto:") || attendee.startsWith("MAILTO:")) { 3059 attendee = attendee.substring("mailto:".length()); 3060 } 3061 3062 SimpleDateFormat exchangeZuluDateFormat = getExchangeZuluDateFormat(); 3063 SimpleDateFormat icalDateFormat = getZuluDateFormat(); 3064 3065 Date startDate; 3066 Date endDate; 3067 try { 3068 if (startDateValue.length() == 8) { 3069 startDate = parseDate(startDateValue); 3070 } else { 3071 startDate = icalDateFormat.parse(startDateValue); 3072 } 3073 if (endDateValue.length() == 8) { 3074 endDate = parseDate(endDateValue); 3075 } else { 3076 endDate = icalDateFormat.parse(endDateValue); 3077 } 3078 } catch (ParseException e) { 3079 throw new DavMailException("EXCEPTION_INVALID_DATES", e.getMessage()); 3080 } 3081 3082 FreeBusy freeBusy = null; 3083 String fbdata = getFreeBusyData(attendee, exchangeZuluDateFormat.format(startDate), exchangeZuluDateFormat.format(endDate), FREE_BUSY_INTERVAL); 3084 if (fbdata != null) { 3085 freeBusy = new FreeBusy(icalDateFormat, startDate, fbdata); 3086 } 3087 3088 if (freeBusy != null && freeBusy.knownAttendee) { 3089 return freeBusy; 3090 } else { 3091 return null; 3092 } 3093 } 3094 3095 /** 3096 * Exchange to iCalendar Free/Busy parser. 3097 * Free time returns 0, Tentative returns 1, Busy returns 2, and Out of Office (OOF) returns 3 3098 */ 3099 public static final class FreeBusy { 3100 final SimpleDateFormat icalParser; 3101 boolean knownAttendee = true; 3102 static final HashMap<Character, String> FBTYPES = new HashMap<>(); 3103 3104 static { 3105 FBTYPES.put('1', "BUSY-TENTATIVE"); 3106 FBTYPES.put('2', "BUSY"); 3107 FBTYPES.put('3', "BUSY-UNAVAILABLE"); 3108 } 3109 3110 final HashMap<String, StringBuilder> busyMap = new HashMap<>(); 3111 getBusyBuffer(char type)3112 StringBuilder getBusyBuffer(char type) { 3113 String fbType = FBTYPES.get(type); 3114 StringBuilder buffer = busyMap.get(fbType); 3115 if (buffer == null) { 3116 buffer = new StringBuilder(); 3117 busyMap.put(fbType, buffer); 3118 } 3119 return buffer; 3120 } 3121 startBusy(char type, Calendar currentCal)3122 void startBusy(char type, Calendar currentCal) { 3123 if (type == '4') { 3124 knownAttendee = false; 3125 } else if (type != '0') { 3126 StringBuilder busyBuffer = getBusyBuffer(type); 3127 if (busyBuffer.length() > 0) { 3128 busyBuffer.append(','); 3129 } 3130 busyBuffer.append(icalParser.format(currentCal.getTime())); 3131 } 3132 } 3133 endBusy(char type, Calendar currentCal)3134 void endBusy(char type, Calendar currentCal) { 3135 if (type != '0' && type != '4') { 3136 getBusyBuffer(type).append('/').append(icalParser.format(currentCal.getTime())); 3137 } 3138 } 3139 FreeBusy(SimpleDateFormat icalParser, Date startDate, String fbdata)3140 FreeBusy(SimpleDateFormat icalParser, Date startDate, String fbdata) { 3141 this.icalParser = icalParser; 3142 if (fbdata.length() > 0) { 3143 Calendar currentCal = Calendar.getInstance(TimeZone.getTimeZone("UTC")); 3144 currentCal.setTime(startDate); 3145 3146 startBusy(fbdata.charAt(0), currentCal); 3147 for (int i = 1; i < fbdata.length() && knownAttendee; i++) { 3148 currentCal.add(Calendar.MINUTE, FREE_BUSY_INTERVAL); 3149 char previousState = fbdata.charAt(i - 1); 3150 char currentState = fbdata.charAt(i); 3151 if (previousState != currentState) { 3152 endBusy(previousState, currentCal); 3153 startBusy(currentState, currentCal); 3154 } 3155 } 3156 currentCal.add(Calendar.MINUTE, FREE_BUSY_INTERVAL); 3157 endBusy(fbdata.charAt(fbdata.length() - 1), currentCal); 3158 } 3159 } 3160 3161 /** 3162 * Append freebusy information to buffer. 3163 * 3164 * @param buffer String buffer 3165 */ appendTo(StringBuilder buffer)3166 public void appendTo(StringBuilder buffer) { 3167 for (Map.Entry<String, StringBuilder> entry : busyMap.entrySet()) { 3168 buffer.append("FREEBUSY;FBTYPE=").append(entry.getKey()) 3169 .append(':').append(entry.getValue()).append((char) 13).append((char) 10); 3170 } 3171 } 3172 } 3173 3174 protected VObject vTimezone; 3175 3176 /** 3177 * Load and return current user OWA timezone. 3178 * 3179 * @return current timezone 3180 */ getVTimezone()3181 public VObject getVTimezone() { 3182 if (vTimezone == null) { 3183 // need to load Timezone info from OWA 3184 loadVtimezone(); 3185 } 3186 return vTimezone; 3187 } 3188 loadVtimezone()3189 protected abstract void loadVtimezone(); 3190 3191 } 3192