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