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