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