View Javadoc
1   /*
2    * DavMail POP/IMAP/SMTP/CalDav/LDAP Exchange Gateway
3    * Copyright (C) 2010  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  
20  package davmail.exchange.graph;
21  
22  import davmail.BundleMessage;
23  import davmail.Settings;
24  import davmail.exception.DavMailException;
25  import davmail.exception.HttpForbiddenException;
26  import davmail.exception.HttpNotFoundException;
27  import davmail.exception.HttpPreconditionFailedException;
28  import davmail.exchange.ExchangeSession;
29  import davmail.exchange.VCalendar;
30  import davmail.exchange.VObject;
31  import davmail.exchange.VProperty;
32  import davmail.exchange.auth.O365Token;
33  import davmail.http.HttpClientAdapter;
34  import davmail.http.URIUtil;
35  import davmail.ui.NotificationDialog;
36  import davmail.ui.tray.DavGatewayTray;
37  import davmail.util.DateUtil;
38  import davmail.util.IOUtil;
39  import davmail.util.StringUtil;
40  import org.apache.http.Header;
41  import org.apache.http.HttpStatus;
42  import org.apache.http.client.methods.CloseableHttpResponse;
43  import org.apache.http.client.methods.HttpDelete;
44  import org.apache.http.client.methods.HttpGet;
45  import org.apache.http.client.methods.HttpPatch;
46  import org.apache.http.client.methods.HttpPost;
47  import org.apache.http.client.methods.HttpPut;
48  import org.apache.http.client.methods.HttpRequestBase;
49  import org.codehaus.jettison.json.JSONArray;
50  import org.codehaus.jettison.json.JSONException;
51  import org.codehaus.jettison.json.JSONObject;
52  import org.htmlcleaner.HtmlCleaner;
53  import org.htmlcleaner.TagNode;
54  
55  import javax.mail.MessagingException;
56  import javax.mail.internet.MimeMessage;
57  import javax.mail.internet.MimeMultipart;
58  import javax.mail.internet.MimePart;
59  import javax.mail.internet.MimeUtility;
60  import javax.mail.util.SharedByteArrayInputStream;
61  import java.io.ByteArrayInputStream;
62  import java.io.ByteArrayOutputStream;
63  import java.io.FilterInputStream;
64  import java.io.IOException;
65  import java.io.InputStream;
66  import java.io.StringReader;
67  import java.net.NoRouteToHostException;
68  import java.net.URI;
69  import java.net.UnknownHostException;
70  import java.nio.charset.StandardCharsets;
71  import java.text.ParseException;
72  import java.text.SimpleDateFormat;
73  import java.util.ArrayList;
74  import java.util.Collections;
75  import java.util.Date;
76  import java.util.HashMap;
77  import java.util.HashSet;
78  import java.util.Iterator;
79  import java.util.List;
80  import java.util.Locale;
81  import java.util.Map;
82  import java.util.MissingResourceException;
83  import java.util.NoSuchElementException;
84  import java.util.Set;
85  import java.util.TimeZone;
86  import java.util.UUID;
87  import java.util.zip.GZIPInputStream;
88  
89  import static davmail.exchange.graph.GraphObject.convertTimezoneFromExchange;
90  
91  /**
92   * Implement ExchangeSession based on Microsoft Graph
93   */
94  public class GraphExchangeSession extends ExchangeSession {
95  
96      protected static final int PAGE_SIZE = 500;
97  
98      static final Map<String, String> partstatToResponseMap = new HashMap<>();
99      static final Map<String, String> responseTypeToPartstatMap = new HashMap<>();
100     static final Map<String, String> statusToBusyStatusMap = new HashMap<>();
101 
102     static {
103         partstatToResponseMap.put("ACCEPTED", "accepted");
104         partstatToResponseMap.put("TENTATIVE", "tentativelyAccepted");
105         partstatToResponseMap.put("DECLINED", "declined");
106         partstatToResponseMap.put("NEEDS-ACTION", "notResponded");
107 
108         responseTypeToPartstatMap.put("accepted", "ACCEPTED");
109         responseTypeToPartstatMap.put("organizer", "ACCEPTED");
110         responseTypeToPartstatMap.put("tentativelyAccepted", "TENTATIVE");
111         responseTypeToPartstatMap.put("declined", "DECLINED");
112         responseTypeToPartstatMap.put("none", "NEEDS-ACTION");
113         responseTypeToPartstatMap.put("notResponded", "NEEDS-ACTION");
114 
115         statusToBusyStatusMap.put("TENTATIVE", "Tentative");
116         statusToBusyStatusMap.put("CONFIRMED", "Busy");
117         // Unable to map CANCELLED: cancelled events are directly deleted on Exchange
118     }
119 
120     protected Map<String, String> urlcompnameToIdMap = new HashMap<>();
121 
122     /**
123      * Graph folder is identified by mailbox and id
124      */
125     protected class Folder extends ExchangeSession.Folder {
126         public FolderId folderId;
127         protected String specialFlag = "";
128 
129         protected boolean isDefaultCalendar = false;
130 
131         protected void setSpecialFlag(String specialFlag) {
132             this.specialFlag = "\\" + specialFlag + " ";
133         }
134 
135         /**
136          * Get IMAP folder flags.
137          *
138          * @return folder flags in IMAP format
139          */
140         @Override
141         public String getFlags() {
142             if (noInferiors) {
143                 return specialFlag + "\\NoInferiors";
144             } else if (hasChildren) {
145                 return specialFlag + "\\HasChildren";
146             } else {
147                 return specialFlag + "\\HasNoChildren";
148             }
149         }
150     }
151 
152     protected class Event extends ExchangeSession.Event {
153 
154         public FolderId folderId;
155 
156         public String id;
157 
158         protected GraphObject graphObject;
159 
160         public Event(String folderPath, FolderId folderId, GraphObject graphObject) {
161             this.folderPath = folderPath;
162             this.folderId = folderId;
163 
164             if (FolderId.IPF_TASK.equals(graphObject.optString("objecttype"))) {
165                 // replace folder on task items requested as part of the default calendar
166                 try {
167                     this.folderId = getFolderId(TASKS);
168                 } catch (IOException e) {
169                     LOGGER.warn("Unable to replace folder with tasks");
170                 }
171                 displayName = graphObject.optString("summary");
172                 subject = graphObject.optString("summary");
173             } else {
174                 displayName = graphObject.optString("subject");
175                 subject = graphObject.optString("subject");
176             }
177 
178             this.graphObject = graphObject;
179 
180             id = graphObject.optString("id");
181             etag = graphObject.optString("changeKey");
182 
183             // prefer id as itemName
184             itemName = StringUtil.base64ToUrl(id) + ".EML";
185         }
186 
187         public Event(String folderPath, String itemName, String contentClass, String itemBody, String etag, String noneMatch) throws IOException {
188             super(folderPath, itemName, contentClass, itemBody, etag, noneMatch);
189             folderId = getFolderId(folderPath);
190         }
191 
192         public Event(FolderId folderId, byte[] content) throws IOException {
193             vCalendar = new VCalendar(content, email, getVTimezone());
194             this.folderId = folderId;
195         }
196 
197         @Override
198         public byte[] getEventContent() throws IOException {
199             byte[] content;
200             if (LOGGER.isDebugEnabled()) {
201                 LOGGER.debug("Get event: " + itemName);
202             }
203             try {
204                 if (vCalendar != null) {
205                     return vCalendar.toString().getBytes(StandardCharsets.UTF_8);
206                 } else if (folderId.isTask()) {
207                     VCalendar localVCalendar = new VCalendar();
208                     VObject vTodo = new VObject();
209                     vTodo.type = "VTODO";
210                     localVCalendar.setTimezone(getVTimezone());
211                     vTodo.setPropertyValue("LAST-MODIFIED", graphObject.optString("lastModifiedDateTime"));
212                     vTodo.setPropertyValue("CREATED", graphObject.optString("createdDateTime"));
213                     // use item id as uid
214                     vTodo.setPropertyValue("UID", graphObject.optString("id"));
215                     vTodo.setPropertyValue("TITLE", graphObject.optString("summary"));
216                     vTodo.setPropertyValue("SUMMARY", graphObject.optString("summary"));
217 
218                     vTodo.addProperty(convertBodyToVproperty("DESCRIPTION", graphObject));
219 
220                     vTodo.setPropertyValue("PRIORITY", graphObject.getTaskPriority());
221                     // not supported over graph
222                     //vTodo.setPropertyValue("PERCENT-COMPLETE", );
223                     vTodo.setPropertyValue("STATUS", graphObject.getVTodoStatusFromTask());
224 
225                     vTodo.setPropertyValue("DUE;VALUE=DATE", convertDateTimeTimeZoneToTaskDate(graphObject.optDateTimeTimeZone("dueDateTime")));
226                     vTodo.setPropertyValue("DTSTART;VALUE=DATE", convertDateTimeTimeZoneToTaskDate(graphObject.optDateTimeTimeZone("startDateTime")));
227                     vTodo.setPropertyValue("COMPLETED;VALUE=DATE", convertDateTimeTimeZoneToTaskDate(graphObject.optDateTimeTimeZone("completedDateTime")));
228 
229                     vTodo.setPropertyValue("CATEGORIES", graphObject.optString("categories"));
230 
231                     // handleRecurrence(localVCalendar, graphObject); does not yet work on microsoft side
232 
233                     localVCalendar.addVObject(vTodo);
234                     content = localVCalendar.toString().getBytes(StandardCharsets.UTF_8);
235                 } else {
236                     // with graph API there is no way to directly retrieve the MIME content to access VCALENDAR object
237                     // so implementation is based on graph and mapi (extended) properties
238 
239                     VCalendar localVCalendar = new VCalendar();
240                     // set email on vcalendar object for shared calendars
241                     localVCalendar.setEmail(getCalendarEmail(folderPath));
242                     // set timezone based on start date timezone
243                     String originalStartTimeZone = graphObject.optString("originalStartTimeZone");
244                     if (originalStartTimeZone != null && !"tzone://Microsoft/Custom".equals(originalStartTimeZone)) {
245                         localVCalendar.setTimezone(getVTimezone(originalStartTimeZone));
246                     } else {
247                         localVCalendar.setTimezone(getVTimezone());
248                     }
249                     localVCalendar.addVObject(buildVEvent(graphObject));
250 
251                     handleException(localVCalendar, graphObject);
252 
253                     handleRecurrence(localVCalendar, graphObject);
254 
255                     content = localVCalendar.toString().getBytes(StandardCharsets.UTF_8);
256                 }
257             } catch (Exception e) {
258                 throw new IOException(e.getMessage(), e);
259             }
260             return content;
261         }
262 
263         private void handleException(VCalendar localVCalendar, GraphObject graphObject) throws DavMailException, JSONException {
264             JSONArray cancelledOccurrences = graphObject.optJSONArray("cancelledOccurrences");
265             if (cancelledOccurrences != null) {
266                 HashSet<String> exDateValues = new HashSet<>();
267                 VProperty startDate = localVCalendar.getFirstVevent().getProperty("DTSTART");
268                 for (int i = 0; i < cancelledOccurrences.length(); i++) {
269                     String cancelledOccurrence = null;
270                     try {
271                         cancelledOccurrence = cancelledOccurrences.getString(i);
272                         cancelledOccurrence = cancelledOccurrence.substring(cancelledOccurrence.lastIndexOf('.') + 1);
273                         String cancelledDate = convertDateFromExchange(cancelledOccurrence);
274 
275                         exDateValues.add(cancelledDate.substring(0, 8) + startDate.getValue().substring(8));
276 
277                     } catch (IndexOutOfBoundsException | JSONException e) {
278                         LOGGER.warn("Invalid cancelled occurrence: " + cancelledOccurrence);
279                     }
280                 }
281                 // add EXDATE values in a single property, will be converted back to multiple lines by fixICS
282                 VProperty exDate = new VProperty("EXDATE", StringUtil.join(exDateValues, ","));
283                 exDate.setParam("TZID", startDate.getParamValue("TZID"));
284                 localVCalendar.addFirstVeventProperty(exDate);
285             }
286 
287             JSONArray exceptionOccurrences = graphObject.optJSONArray("exceptionOccurrences");
288             if (exceptionOccurrences != null) {
289                 for (int i = 0; i < exceptionOccurrences.length(); i++) {
290                     GraphObject exceptionOccurrence = new GraphObject(exceptionOccurrences.optJSONObject(i)
291                             // need to override uid, iCalUid is different for each occurrence on server
292                             .put("iCalUId", graphObject.optString("iCalUId")));
293                     VObject vEvent = buildVEvent(exceptionOccurrence);
294                     vEvent.addProperty(exceptionOccurrence.getRecurrenceId());
295                     localVCalendar.addVObject(vEvent);
296                 }
297             }
298         }
299 
300         private VObject buildVEvent(GraphObject jsonEvent) throws DavMailException, JSONException {
301             VObject vEvent = new VObject();
302             vEvent.type = "VEVENT";
303             // fetch custom iCalUId from transactionId
304             String iCalUId = jsonEvent.optString("transactionId");
305             if (iCalUId == null) {
306                 // default to O365 iCalUid
307                 iCalUId = jsonEvent.optString("iCalUId");
308             }
309             vEvent.setPropertyValue("UID", iCalUId);
310             vEvent.setPropertyValue("SUMMARY", jsonEvent.optString("subject"));
311 
312             vEvent.addProperty(convertBodyToVproperty("DESCRIPTION", jsonEvent));
313 
314             vEvent.setPropertyValue("LAST-MODIFIED", jsonEvent.optString("lastModifiedDateTime"));
315             vEvent.setPropertyValue("DTSTAMP", jsonEvent.optString("lastModifiedDateTime"));
316 
317             // retrieve original start timezone to restore original timezone on recurring events across DST
318             String originalStartTimeZone = jsonEvent.optString("originalStartTimeZone");
319             vEvent.addProperty(convertDateTimeTimeZoneToVproperty("DTSTART", jsonEvent.optJSONObject("start"), DateUtil.getExchangeTimeZone(originalStartTimeZone)));
320             vEvent.addProperty(convertDateTimeTimeZoneToVproperty("DTEND", jsonEvent.optJSONObject("end"), DateUtil.getExchangeTimeZone(originalStartTimeZone)));
321 
322             vEvent.setPropertyValue("LOCATION", jsonEvent.optString("location", "displayName"));
323             vEvent.setPropertyValue("CATEGORIES", jsonEvent.optString("categories"));
324 
325             vEvent.setPropertyValue("CLASS", convertClassFromExchange(jsonEvent.optString("sensitivity")));
326 
327             // custom microsoft properties
328             String showAs = jsonEvent.optString("showAs");
329             if (showAs != null) {
330                 vEvent.setPropertyValue("X-MICROSOFT-CDO-BUSYSTATUS", showAs.toUpperCase());
331             }
332             String isAllDay = jsonEvent.optString("isAllDay");
333             if (isAllDay != null) {
334                 vEvent.setPropertyValue("X-MICROSOFT-CDO-ALLDAYEVENT", isAllDay.toUpperCase());
335             }
336             String responseRequested = jsonEvent.optString("responseRequested");
337             if (responseRequested != null) {
338                 vEvent.setPropertyValue("X-MICROSOFT-CDO-ISRESPONSEREQUESTED", responseRequested.toUpperCase());
339             }
340 
341             if (jsonEvent.optBoolean("isReminderOn")) {
342                 VObject vAlarm = new VObject();
343                 vAlarm.type = "VALARM";
344                 vAlarm.addPropertyValue("ACTION", "DISPLAY");
345                 int reminderMinutesBeforeStart = jsonEvent.optInt("reminderMinutesBeforeStart");
346                 if (reminderMinutesBeforeStart > 0) {
347                     vAlarm.addPropertyValue("TRIGGER", "-PT" + reminderMinutesBeforeStart + "M");
348                 }
349                 vEvent.addVObject(vAlarm);
350             }
351 
352             vEvent.setPropertyValue("X-MOZ-SEND-INVITATIONS", jsonEvent.optString("xmozsendinvitations"));
353             vEvent.setPropertyValue("X-MOZ-LASTACK", jsonEvent.optString("xmozlastack"));
354             vEvent.setPropertyValue("X-MOZ-SNOOZE-TIME", jsonEvent.optString("xmozsnoozetime"));
355 
356             vEvent.setPropertyValue("X-MICROSOFT-DISALLOW-COUNTER", jsonEvent.optBoolean("allowNewTimeProposals") ? "FALSE" : "TRUE");
357 
358             setAttendees(vEvent, jsonEvent);
359 
360             return vEvent;
361         }
362 
363         private void handleRecurrence(VCalendar localVCalendar, GraphObject graphObject) throws JSONException, DavMailException {
364 
365             JSONObject recurrence = graphObject.optJSONObject("recurrence");
366             if (recurrence != null) {
367                 StringBuilder rruleValue = new StringBuilder();
368                 JSONObject pattern = recurrence.getJSONObject("pattern");
369                 JSONObject range = recurrence.getJSONObject("range");
370                 // daily, weekly, absoluteMonthly, relativeMonthly, absoluteYearly, relativeYearly
371                 String patternType = pattern.getString("type");
372                 int interval = pattern.getInt("interval");
373                 //  first, second, third, fourth, last
374                 String index = pattern.optString("index", null);
375                 // convert index
376                 if ("first".equals(index)) {
377                     index = "1";
378                 } else if ("second".equals(index)) {
379                     index = "2";
380                 } else if ("third".equals(index)) {
381                     index = "3";
382                 } else if ("fourth".equals(index)) {
383                     index = "4";
384                 } else if ("last".equals(index)) {
385                     index = "-1";
386                 }
387                 // The month in which the event occurs
388                 String month = pattern.getString("month");
389                 if ("0".equals(month)) {
390                     month = null;
391                 }
392                 // The first day of the week
393                 String firstDayOfWeek = pattern.getString("firstDayOfWeek");
394                 // The day of the month on which the event occurs
395                 String dayOfMonth = pattern.getString("dayOfMonth");
396                 if ("0".equals(dayOfMonth)) {
397                     dayOfMonth = null;
398                 }
399                 // A collection of the days of the week on which the event occurs
400                 JSONArray daysOfWeek = pattern.optJSONArray("daysOfWeek");
401                 String rangeType = range.getString("type");
402 
403                 rruleValue.append("FREQ=");
404                 if (patternType.startsWith("absolute") || patternType.startsWith("relative")) {
405                     rruleValue.append(patternType.substring(8).toUpperCase());
406                 } else {
407                     rruleValue.append(patternType.toUpperCase());
408                 }
409                 if (rangeType.equals("endDate")) {
410                     String endDate = buildUntilDate(range.getString("endDate"), graphObject.optJSONObject("start"));
411                     rruleValue.append(";UNTIL=").append(endDate);
412                 } else if (rangeType.equals("numbered")) {
413                     int numberOfOccurrences = range.getInt("numberOfOccurrences");
414                     rruleValue.append(";COUNT=").append(numberOfOccurrences);
415                 } // noEnd is third option
416                 if (interval > 0) {
417                     rruleValue.append(";INTERVAL=").append(interval);
418                 }
419                 if (dayOfMonth != null && !dayOfMonth.isEmpty()) {
420                     rruleValue.append(";BYMONTHDAY=").append(dayOfMonth);
421                 }
422                 if (month != null && !month.isEmpty()) {
423                     rruleValue.append(";BYMONTH=").append(month);
424                 }
425                 if (daysOfWeek != null && daysOfWeek.length() > 0) {
426                     ArrayList<String> days = new ArrayList<>();
427                     for (int i = 0; i < daysOfWeek.length(); i++) {
428                         StringBuilder byDay = new StringBuilder();
429                         if (index != null && !"weekly".equals(patternType)) {
430                             byDay.append(index);
431                         }
432                         byDay.append(daysOfWeek.getString(i).substring(0, 2).toUpperCase());
433                         days.add(byDay.toString());
434                     }
435                     rruleValue.append(";BYDAY=").append(String.join(",", days));
436                 }
437                 // handle other frequencies
438                 if ("weekly".equals(patternType) && firstDayOfWeek.length() >= 2) {
439                     rruleValue.append(";WKST=").append(firstDayOfWeek.substring(0, 2).toUpperCase());
440                 }
441 
442                 localVCalendar.addFirstVeventProperty(new VProperty("RRULE", rruleValue.toString()));
443             }
444         }
445 
446         private String buildUntilDate(String date, JSONObject startDate) throws DavMailException {
447             String result = null;
448             if (date != null && date.length() == 10) {
449                 String startDateTimeZone = startDate.optString("timeZone");
450                 String startDateDateTime = startDate.optString("dateTime");
451                 // graph provided until date does not have time part, get value from startDate
452                 String untilDateTime = date + startDateDateTime.substring(10);
453 
454                 SimpleDateFormat parser = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
455                 SimpleDateFormat formatter = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
456                 formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
457                 parser.setTimeZone(TimeZone.getTimeZone(convertTimezoneFromExchange(startDateTimeZone)));
458                 try {
459                     result = formatter.format(parser.parse(untilDateTime));
460                 } catch (ParseException e) {
461                     throw new DavMailException("EXCEPTION_INVALID_DATE", date);
462                 }
463             }
464             return result;
465         }
466 
467         private String convertOriginalStartDate(String originalStart) throws DavMailException {
468             String result = originalStart;
469             // originalStart is in ISO8601 format, convert if not already zulu
470             if (originalStart != null && !originalStart.endsWith("Z")) {
471                 SimpleDateFormat parser = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX");
472                 SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
473                 formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
474                 try {
475                     result = formatter.format(parser.parse(originalStart));
476                 } catch (ParseException e) {
477                     throw new DavMailException("EXCEPTION_INVALID_DATE", originalStart);
478                 }
479             }
480             return result;
481         }
482 
483 
484         private void setAttendees(VObject vEvent, GraphObject jsonEvent) throws JSONException {
485             // handle organizer
486             JSONObject organizer = jsonEvent.optJSONObject("organizer");
487             if (organizer != null) {
488                 vEvent.addProperty(convertEmailAddressToVproperty("ORGANIZER", organizer.optJSONObject("emailAddress")));
489             }
490 
491             JSONArray attendees = jsonEvent.optJSONArray("attendees");
492             if (attendees != null) {
493                 for (int i = 0; i < attendees.length(); i++) {
494                     JSONObject attendee = attendees.getJSONObject(i);
495                     JSONObject emailAddress = attendee.getJSONObject("emailAddress");
496                     VProperty attendeeProperty = convertEmailAddressToVproperty("ATTENDEE", emailAddress);
497 
498                     // The response type. Possible values are: none, organizer, tentativelyAccepted, accepted, declined, notResponded.
499                     String responseType = attendee.getJSONObject("status").optString("response");
500                     String myResponseType = graphObject.optString("responseStatus", "response");
501 
502                     // TODO Test if applicable
503                     if (email.equalsIgnoreCase(emailAddress.optString("address")) && myResponseType != null) {
504                         attendeeProperty.addParam("PARTSTAT", responseTypeToPartstat(myResponseType));
505                     } else {
506                         attendeeProperty.addParam("PARTSTAT", responseTypeToPartstat(responseType));
507                     }
508                     // the attendee type: required, optional, resource.
509                     String type = attendee.optString("type");
510                     if ("required".equals(type)) {
511                         attendeeProperty.addParam("ROLE", "REQ-PARTICIPANT");
512                     } else if ("optional".equals(type)) {
513                         attendeeProperty.addParam("ROLE", "OPT-PARTICIPANT");
514                     }
515 
516                     vEvent.addProperty(attendeeProperty);
517                 }
518             }
519         }
520 
521         /**
522          * Convert response type to partstat value
523          *
524          * @param responseType response type
525          * @return partstat value
526          */
527         private String responseTypeToPartstat(String responseType) {
528             // The response type. Possible values are: none, organizer, tentativelyAccepted, accepted, declined, notResponded.
529             if ("accepted".equals(responseType) || "organizer".equals(responseType)) {
530                 return "ACCEPTED";
531             } else if ("tentativelyAccepted".equals(responseType)) {
532                 return "TENTATIVE";
533             } else if ("declined".equals(responseType)) {
534                 return "DECLINED";
535             } else {
536                 return "NEEDS-ACTION";
537             }
538         }
539 
540         @Override
541         public ItemResult createOrUpdate() throws IOException {
542             if (vCalendar.isTodo() && isMainCalendar(folderPath)) {
543                 // task item, move to tasks folder
544                 folderId = getFolderId(TASKS);
545             }
546 
547             String currentItemId = null;
548             String currentEtag = null;
549             boolean isExistingEvent = false;
550             boolean isMeetingResponse = false;
551             boolean isMozSendInvitations = false;
552             boolean isMozDismiss = false;
553 
554             boolean isOrganizer = false;
555             boolean isMeeting = false;
556 
557             JSONObject existingJsonEvent = getEventIfExists(folderId, itemName);
558             if (existingJsonEvent != null) {
559                 isExistingEvent = true;
560 
561                 GraphObject currentItem = new GraphObject(existingJsonEvent);
562                 currentItemId = existingJsonEvent.optString("id", null);
563                 currentEtag = new GraphObject(existingJsonEvent).optString("changeKey");
564 
565                 String myResponseType = currentItem.optString("responseStatus", "response");
566 
567                 String currentAttendeeStatus = responseTypeToPartstatMap.get(myResponseType);
568                 String newAttendeeStatus = vCalendar.getAttendeeStatus();
569 
570                 isOrganizer = currentItem.optBoolean("isOrganizer");
571                 isMeeting = currentItem.optJSONArray("attendees") != null;
572 
573                 isMeetingResponse = vCalendar.isMeeting() && !isOrganizer
574                         && newAttendeeStatus != null
575                         && !newAttendeeStatus.equals(currentAttendeeStatus)
576                         // avoid nullpointerexception on unknown status
577                         && partstatToResponseMap.get(newAttendeeStatus) != null;
578 
579                 // Check mozilla last ack and snooze
580                 String newmozlastack = vCalendar.getFirstVeventPropertyValue("X-MOZ-LASTACK");
581                 String currentmozlastack = currentItem.optString("xmozlastack");
582                 boolean ismozack = newmozlastack != null && !newmozlastack.equals(currentmozlastack);
583 
584                 String newmozsnoozetime = vCalendar.getFirstVeventPropertyValue("X-MOZ-SNOOZE-TIME");
585                 String currentmozsnoozetime = currentItem.optString("xmozsnoozetime");
586                 boolean ismozsnooze = newmozsnoozetime != null && !newmozsnoozetime.equals(currentmozsnoozetime);
587 
588                 isMozSendInvitations = (newmozlastack == null && newmozsnoozetime == null) // not thunderbird
589                         || !(ismozack || ismozsnooze);
590                 isMozDismiss = ismozack || ismozsnooze;
591 
592                 LOGGER.debug("Existing item found with etag: " + currentEtag + " client etag: " + etag + " id: " + currentItemId);
593             }
594 
595             ItemResult itemResult = new ItemResult();
596             if (isMeetingResponse || isMozDismiss) {
597                 LOGGER.debug("Ignore etag check, meeting response or dismiss");
598             } else if ("*".equals(noneMatch)) {
599                 // create requested but already exists
600                 if (isExistingEvent) {
601                     itemResult.status = HttpStatus.SC_PRECONDITION_FAILED;
602                     return itemResult;
603                 }
604             } else if (etag != null) {
605                 // update requested, fail if event does not exist or etag mismatch
606                 if (!isExistingEvent || !etag.equals(currentEtag)) {
607                     itemResult.status = HttpStatus.SC_PRECONDITION_FAILED;
608                     return itemResult;
609                 }
610             }
611 
612             VObject vEvent = vCalendar.getFirstVevent();
613             GraphObject graphResponse;
614             try {
615                 GraphRequestBuilder graphRequestBuilder = new GraphRequestBuilder();
616 
617                 if (isExistingEvent && isMeetingResponse) {
618                     graphResponse = sendMeetingResponse(currentItemId);
619                 } else if (isExistingEvent && isMozDismiss) {
620                     graphResponse = mozDismissEvent(currentItemId);
621                 } else if (folderId.isTask()) {
622                     graphResponse = createOrUpdateTask(currentItemId);
623                 } else if (isExistingEvent && isMeeting && !isOrganizer) {
624                     graphResponse = updateReminder(currentItemId);
625                 } else {
626 
627                     GraphObject newGraphEvent = buildJsonEvent(vEvent);
628 
629                     // set client provided itemName in extended property
630                     newGraphEvent.put("urlcompname", convertItemNameToEML(itemName));
631 
632                     // on event creation push iCalUId from event to transactionId
633                     String iCalUId = vEvent.getPropertyValue("UID");
634                     if (!isExistingEvent && iCalUId != null && !iCalUId.isEmpty()) {
635                         newGraphEvent.put("transactionId", iCalUId);
636                     }
637 
638                     // handle reminder configuration
639                     newGraphEvent.put("isReminderOn", vCalendar.hasVAlarm());
640                     newGraphEvent.put("reminderMinutesBeforeStart", vCalendar.getReminderMinutesBeforeStart());
641 
642                     if (vCalendar.isMeeting() && Settings.getBooleanProperty("davmail.caldav.enableOnlineMeeting", true)) {
643                         // new meeting, assume we want a Teams event
644                         newGraphEvent.put("isOnlineMeeting", true);
645                     }
646                     String disaLLowCounter = vEvent.getPropertyValue("X-MICROSOFT-DISALLOW-COUNTER");
647                     newGraphEvent.put("allowNewTimeProposals", !"TRUE".equals(disaLLowCounter));
648 
649                     convertRruleToGraph(newGraphEvent, vEvent.getProperty("RRULE"));
650 
651                     // store mozilla invitations option
652                     String xMozSendInvitations = vCalendar.getFirstVeventPropertyValue("X-MOZ-SEND-INVITATIONS");
653                     if (xMozSendInvitations != null) {
654                         newGraphEvent.put("xmozsendinvitations", xMozSendInvitations);
655                     }
656                     // handle mozilla alarm
657                     String xMozLastack = vCalendar.getFirstVeventPropertyValue("X-MOZ-LASTACK");
658                     if (xMozLastack != null) {
659                         newGraphEvent.put("xmozlastack", xMozLastack);
660                     }
661                     String xMozSnoozeTime = vCalendar.getFirstVeventPropertyValue("X-MOZ-SNOOZE-TIME");
662                     if (xMozSnoozeTime != null) {
663                         newGraphEvent.put("xmozsnoozetime", xMozSnoozeTime);
664                     }
665 
666                     newGraphEvent.put("isAllDay", vCalendar.isCdoAllDay());
667 
668                     // showAs: free, tentative, busy, oof, workingElsewhere, unknown
669                     String status = vCalendar.getFirstVeventPropertyValue("STATUS");
670                     if ("TENTATIVE".equals(status)) {
671                         // this is a tentative event
672                         newGraphEvent.put("showAs", "tentative");
673                     } else {
674                         // otherwise, we use the same value as before, as received from the server
675                         // however, the case matters, so we still have to transform it "BUSY" -> "Busy"
676                         newGraphEvent.put("showAs", "BUSY".equals(vCalendar.getFirstVeventPropertyValue("X-MICROSOFT-CDO-BUSYSTATUS")) ? "busy" : "free");
677                     }
678 
679                     if (isExistingEvent) {
680                         graphRequestBuilder.setMethod(HttpPatch.METHOD_NAME)
681                                 .setMailbox(folderId.mailbox)
682                                 .setObjectType("events")
683                                 .setObjectId(currentItemId)
684                                 .setJsonBody(newGraphEvent);
685                     } else {
686                         graphRequestBuilder.setMethod(HttpPost.METHOD_NAME)
687                                 .setMailbox(folderId.mailbox)
688                                 .setObjectType("calendars")
689                                 .setObjectId(folderId.id)
690                                 .setChildType("events")
691                                 .setJsonBody(newGraphEvent);
692                     }
693                     graphResponse = executeGraphRequest(graphRequestBuilder);
694 
695                     // at that point event exists
696                     currentItemId = graphResponse.optString("id");
697                     if (existingJsonEvent == null) {
698                         // new event, take response as reference
699                         existingJsonEvent = graphResponse.jsonObject;
700                     }
701 
702                     // find and delete exdate occurrences
703                     List<VProperty> exdateProperty = vEvent.getProperties("EXDATE");
704                     if (exdateProperty != null && !exdateProperty.isEmpty()) {
705                         for (VProperty exdate : exdateProperty) {
706                             String exdateTzid = exdate.getParamValue("TZID");
707                             String exDateValue = vCalendar.convertCalendarDateToGraph(exdate.getValue(), exdateTzid);
708                             deleteEventOccurrence(currentItemId, exDateValue);
709                         }
710                     }
711 
712                     handleModifiedOccurrences(vCalendar, existingJsonEvent);
713 
714                     // need to refresh etag after exception updates
715                     graphResponse = executeGraphRequest(new GraphRequestBuilder()
716                             .setMethod(HttpGet.METHOD_NAME)
717                             .setMailbox(folderId.mailbox)
718                             .setObjectType("events")
719                             .setObjectId(currentItemId)
720                             .setSelect("id"));
721                 }
722 
723                 itemResult.status = graphResponse.statusCode;
724                 if (itemResult.status == HttpStatus.SC_ACCEPTED) {
725                     LOGGER.debug("Sent meeting response");
726                     itemResult.status = HttpStatus.SC_OK;
727                 }
728 
729                 itemResult.etag = graphResponse.optString("changeKey");
730 
731                 // workaround for Thunderbird, keep a cache of itemName to id map
732                 urlcompnameToIdMap.put(itemName, graphResponse.optString("id"));
733 
734                 itemResult.itemName = itemName; // preserve requested itemName
735                 itemResult.etag = graphResponse.optString("changeKey");
736 
737             } catch (JSONException e) {
738                 throw new IOException(e);
739             }
740 
741             return itemResult;
742         }
743 
744         private GraphObject updateReminder(String currentItemId) throws JSONException, IOException {
745             LOGGER.debug("Update on existing meeting, not organizer, not a meeting response or dismiss: allow reminder updates only");
746 
747             // handle reminder configuration
748             GraphRequestBuilder graphRequestBuilder = new GraphRequestBuilder().setMethod(HttpPatch.METHOD_NAME)
749                     .setMailbox(folderId.mailbox)
750                     .setObjectType("events")
751                     .setObjectId(currentItemId)
752                     .setJsonBody(new GraphObject().put("isReminderOn", vCalendar.hasVAlarm())
753                             .put("reminderMinutesBeforeStart", vCalendar.getReminderMinutesBeforeStart())
754                     );
755             return executeGraphRequest(graphRequestBuilder);
756         }
757 
758         protected GraphObject createOrUpdateTask(String currentItemId) throws IOException, JSONException {
759             JSONObject jsonTask = buildJsonTask(vCalendar.getFirstVevent());
760             // handleRrule(jsonTask, vEvent.getProperty("RRULE")); does not yet work on microsoft side
761             GraphRequestBuilder graphRequestBuilder = new GraphRequestBuilder();
762 
763             if (currentItemId == null) {
764                 graphRequestBuilder
765                         .setMethod(HttpPost.METHOD_NAME)
766                         .setMailbox(folderId.mailbox)
767                         .setObjectType("todo/lists")
768                         .setObjectId(folderId.id)
769                         .setChildType("tasks")
770                         .setChildId(currentItemId)
771                         .setJsonBody(jsonTask);
772             } else {
773                 graphRequestBuilder
774                         .setMethod(HttpPatch.METHOD_NAME)
775                         .setMailbox(folderId.mailbox)
776                         .setObjectType("todo/lists")
777                         .setObjectId(folderId.id)
778                         .setChildType("tasks")
779                         .setChildId(currentItemId)
780                         .setJsonBody(jsonTask);
781             }
782             return executeGraphRequest(graphRequestBuilder);
783         }
784 
785         private GraphObject mozDismissEvent(String currentItemId) throws IOException, JSONException {
786             // Thunderbird snooze / dismiss, only update fields, do not call dismissReminder as this removes alarm completely
787             String newmozlastack = vCalendar.getFirstVeventPropertyValue("X-MOZ-LASTACK");
788             String newmozsnoozetime = vCalendar.getFirstVeventPropertyValue("X-MOZ-SNOOZE-TIME");
789 
790             GraphRequestBuilder graphRequestBuilder = new GraphRequestBuilder().setMethod(HttpPatch.METHOD_NAME)
791                     .setMailbox(folderId.mailbox)
792                     .setObjectType("events")
793                     .setObjectId(currentItemId)
794                     .setJsonBody(new GraphObject()
795                             .put("xmozlastack", newmozlastack)
796                             .put("xmozsnoozetime", newmozsnoozetime)
797                     );
798 
799             return executeGraphRequest(graphRequestBuilder);
800         }
801 
802         protected GraphObject sendMeetingResponse(String currentItemId) throws IOException {
803             // over graph always assume server side calendar management
804             String body = null;
805             boolean sendResponse = true;
806             // This is a meeting response, let user edit notification message
807             if (Settings.getBooleanProperty("davmail.caldavEditNotifications")) {
808                 String vEventSubject = vCalendar.getFirstVeventPropertyValue("SUMMARY");
809                 if (vEventSubject == null) {
810                     vEventSubject = BundleMessage.format("MEETING_REQUEST");
811                 }
812 
813                 String status = vCalendar.getAttendeeStatus();
814                 String notificationSubject = (status != null) ? (BundleMessage.format(status) + vEventSubject) : subject;
815 
816                 NotificationDialog notificationDialog = new NotificationDialog(notificationSubject, "");
817                 if (!notificationDialog.getSendNotification()) {
818                     LOGGER.debug("Notification canceled by user");
819                     sendResponse = false;
820                 }
821                 // get description from dialog
822                 body = notificationDialog.getBody();
823             }
824             // Prepare request to accept/tentativelyAccept/decline meeting request
825             try {
826                 JSONObject jsonBody = new JSONObject();
827                 jsonBody.put("sendResponse", sendResponse);
828                 if (body != null && !body.isEmpty()) {
829                     jsonBody.put("comment", body);
830                 }
831                 String action = "accept";
832                 String attendeeStatus = vCalendar.getAttendeeStatus();
833                 if ("ACCEPTED".equals(attendeeStatus)) {
834                     action = "accept";
835                 } else if ("DECLINED".equals(attendeeStatus)) {
836                     action = "decline";
837                 } else if ("TENTATIVE".equals(attendeeStatus)) {
838                     action = "tentativelyAccept";
839                 }
840 
841                 GraphRequestBuilder graphRequestBuilder = new GraphRequestBuilder().setMethod(HttpPost.METHOD_NAME)
842                         .setMailbox(folderId.mailbox)
843                         .setObjectType("events")
844                         .setObjectId(currentItemId)
845                         .setAction(action)
846                         .setJsonBody(jsonBody);
847 
848                 return executeGraphRequest(graphRequestBuilder);
849             } catch (JSONException e) {
850                 throw new IOException(e);
851             }
852 
853         }
854 
855         /**
856          * Convert vCalendar rrule to graph format.
857          * @param jsonEvent graph json event
858          * @param rrule vCalendar rrule
859          * @throws JSONException on error
860          * @throws DavMailException on error
861          */
862         private void convertRruleToGraph(GraphObject jsonEvent, VProperty rrule) throws JSONException, DavMailException {
863             if (rrule != null) {
864                 JSONObject start = jsonEvent.optJSONObject("start");
865                 if (start == null) {
866                     // failover for tasks
867                     start = jsonEvent.optJSONObject("startDateTime");
868                 }
869                 String startDate = start.getString("dateTime").substring(0, 10); // start date in yyyy-MM-dd format
870                 String startTimeZone = start.optString("timeZone");
871 
872                 // get information from rrule property
873                 Map<String, String> rrules = rrule.getValuesAsMap();
874                 String frequency = rrules.get("FREQ");
875                 String until = rrules.get("UNTIL");
876                 String count = rrules.get("COUNT");
877                 int interval = rrules.containsKey("INTERVAL") ? Integer.parseInt(rrules.get("INTERVAL")) : 1; // default interval is 1
878                 String byDay = rrules.get("BYDAY");
879                 String byMonthDay = rrules.get("BYMONTHDAY");
880                 String byMonth = rrules.get("BYMONTH");
881                 String wkst = rrules.get("WKST"); // week start day (sunday or monday)
882 
883                 // build range
884                 JSONObject range;
885                 if (until != null) {
886                     // endDate recurrenceRange
887                     String endDate = convertUntilToEndDate(until, startTimeZone);
888                     range = new JSONObject().put("type", "endDate").put("startDate", startDate)
889                             .put("endDate", endDate).put("recurrenceTimeZone", startTimeZone);
890                 } else if (count != null) {
891                     // numbered recurrenceRange
892                     range = new JSONObject().put("type", "numbered").put("startDate", startDate)
893                             .put("numberOfOccurrences", Integer.parseInt(count));
894                 } else {
895                     range = new JSONObject().put("type", "noEnd").put("startDate", startDate).put("endDate", "0001-01-01");
896                 }
897 
898                 // build pattern
899                 JSONObject pattern = new JSONObject().put("interval", interval);
900 
901                 if ("DAILY".equals(frequency)) {
902                     pattern.put("type", "daily").put("dayOfMonth", 0);
903                 } else if ("WEEKLY".equals(frequency)) {
904                     pattern.put("type", "weekly").put("daysOfWeek", byDay != null ? convertByDayToArray(byDay) : new JSONArray().put(getDayOfWeek(startDate)));
905                     if (wkst != null) { // TODO is this mandatory ?
906                         pattern.put("firstDayOfWeek", convertCaldavDayToGraph(wkst));
907                     }
908                 } else if ("MONTHLY".equals(frequency)) {
909                     if (byDay != null) {
910                         pattern.put("type", "relativeMonthly");
911                         setRelativePattern(pattern, byDay);
912                     } else {
913                         pattern.put("type", "absoluteMonthly");
914                         pattern.put("dayOfMonth", byMonthDay != null ? Integer.parseInt(byMonthDay) : Integer.parseInt(startDate.substring(8, 10)));
915                     }
916                 } else if ("YEARLY".equals(frequency)) {
917                     if (byDay != null) {
918                         pattern.put("type", "relativeYearly");
919                         setRelativePattern(pattern, byDay);
920                     } else {
921                         pattern.put("type", "absoluteYearly")
922                                 .put("dayOfMonth", byMonthDay != null ? Integer.parseInt(byMonthDay) : Integer.parseInt(startDate.substring(8, 10)));
923                     }
924                     if (byMonth != null) {
925                         pattern.put("month", Integer.parseInt(byMonth));
926                     } else {
927                         pattern.put("month", Integer.parseInt(startDate.substring(5, 7)));
928                     }
929                 }
930 
931                 jsonEvent.put("recurrence", new JSONObject().put("pattern", pattern).put("range", range));
932             }
933         }
934 
935         private JSONArray convertByDayToArray(String byDay) {
936             JSONArray daysOfWeek = new JSONArray();
937             for (String day : byDay.split(",")) {
938                 // strip numeric prefix (e.g. "2MO" -> "MO", "-1FR" -> "FR")
939                 daysOfWeek.put(convertCaldavDayToGraph(day.replaceAll("^-?\\d+", "")));
940             }
941             return daysOfWeek;
942         }
943 
944         private String convertCaldavDayToGraph(String weekDay) {
945             switch (weekDay) {
946                 case "MO":
947                     return "monday";
948                 case "TU":
949                     return "tuesday";
950                 case "WE":
951                     return "wednesday";
952                 case "TH":
953                     return "thursday";
954                 case "FR":
955                     return "friday";
956                 case "SA":
957                     return "saturday";
958                 case "SU":
959                     return "sunday";
960                 default:
961                     return weekDay.toLowerCase();
962             }
963         }
964 
965         private void setRelativePattern(JSONObject pattern, String byDay) throws JSONException {
966             // extract index from the first BYDAY value (e.g. "2MO" -> index "second", "-1FR" -> index "last")
967             String firstDay = byDay.split(",")[0];
968             int i = 0;
969             while (i < firstDay.length() && (Character.isDigit(firstDay.charAt(i)) || firstDay.charAt(i) == '-')) {
970                 i++;
971             }
972             String indexStr = firstDay.substring(0, i);
973             if (!indexStr.isEmpty()) {
974                 pattern.put("index", convertIndex(Integer.parseInt(indexStr)));
975             }
976             pattern.put("daysOfWeek", convertByDayToArray(byDay));
977         }
978 
979         private String convertIndex(int index) {
980             switch (index) {
981                 case 1:
982                     return "first";
983                 case 2:
984                     return "second";
985                 case 3:
986                     return "third";
987                 case 4:
988                     return "fourth";
989                 case -1:
990                     return "last";
991                 default:
992                     return "first";
993             }
994         }
995 
996         private String convertUntilToEndDate(String until, String timeZone) throws DavMailException {
997             try {
998                 SimpleDateFormat parser;
999                 if (until.length() == 8) {
1000                     parser = new SimpleDateFormat("yyyyMMdd");
1001                     parser.setTimeZone(TimeZone.getTimeZone(convertTimezoneFromExchange(timeZone)));
1002                 } else if (until.endsWith("Z")) {
1003                     parser = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
1004                     parser.setTimeZone(TimeZone.getTimeZone("UTC"));
1005                 } else {
1006                     parser = new SimpleDateFormat("yyyyMMdd'T'HHmmss");
1007                     parser.setTimeZone(TimeZone.getTimeZone(convertTimezoneFromExchange(timeZone)));
1008                 }
1009                 SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd");
1010                 formatter.setTimeZone(TimeZone.getTimeZone(convertTimezoneFromExchange(timeZone)));
1011                 return formatter.format(parser.parse(until));
1012             } catch (ParseException e) {
1013                 throw new DavMailException("EXCEPTION_INVALID_DATE", until);
1014             }
1015         }
1016 
1017         private String getDayOfWeek(String date) throws DavMailException {
1018             if (date != null) {
1019                 try {
1020                     SimpleDateFormat parser = new SimpleDateFormat("yyyy-MM-dd");
1021                     parser.setTimeZone(TimeZone.getTimeZone("UTC"));
1022                     SimpleDateFormat formatter = new SimpleDateFormat("EEEE", Locale.ENGLISH);
1023                     formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
1024                     return formatter.format(parser.parse(date));
1025                 } catch (ParseException e) {
1026                     throw new DavMailException("EXCEPTION_INVALID_DATE", date);
1027                 }
1028             }
1029             return null;
1030         }
1031 
1032         private void handleModifiedOccurrences(VCalendar vCalendar, JSONObject existingJsonEvent) throws IOException, JSONException {
1033             for (VObject modifiedOccurrence : vCalendar.getModifiedOccurrences()) {
1034                 VProperty originalDateProperty = modifiedOccurrence.getProperty("RECURRENCE-ID");
1035                 String originalDateZulu;
1036                 try {
1037                     originalDateZulu = vCalendar.convertCalendarDateToExchangeZulu(originalDateProperty.getValue(), originalDateProperty.getParamValue("TZID"));
1038                 } catch (IOException e) {
1039                     throw new DavMailException("EXCEPTION_INVALID_DATE", originalDateProperty.getValue());
1040                 }
1041                 LOGGER.debug("Looking for occurrence " + originalDateZulu);
1042                 // try to find modified occurrence in existing event
1043                 JSONArray exceptionOccurrences = existingJsonEvent.optJSONArray("exceptionOccurrences");
1044                 boolean occurrenceFound = false;
1045                 if (exceptionOccurrences != null) {
1046                     for (int i = 0; i < exceptionOccurrences.length(); i++) {
1047                         JSONObject exceptionOccurrence = exceptionOccurrences.optJSONObject(i);
1048                         String exceptionOriginalStart = convertOriginalStartDate(exceptionOccurrence.optString("originalStart"));
1049                         LOGGER.debug("Looking at occurrence " + exceptionOriginalStart + " for " + originalDateZulu);
1050                         if (originalDateZulu.equals(exceptionOriginalStart)) {
1051                             updateExceptionOccurrence(modifiedOccurrence, exceptionOccurrence.getString("id"));
1052                             occurrenceFound = true;
1053                             break;
1054                         }
1055                     }
1056                 }
1057                 if (!occurrenceFound) {
1058                     createNewModifiedOccurrence(modifiedOccurrence, existingJsonEvent, originalDateZulu);
1059                 }
1060             }
1061         }
1062 
1063         /**
1064          * Create a new modified occurrence on event.
1065          * @param modifiedOccurrence modified occurrence vEvent
1066          * @param existingJsonEvent master graph event
1067          * @param originalDateZulu original date in zulu format
1068          * @throws IOException on error
1069          * @throws JSONException on error
1070          */
1071         private void createNewModifiedOccurrence(VObject modifiedOccurrence, JSONObject existingJsonEvent, String originalDateZulu) throws IOException, JSONException {
1072             // assume instance is on same day in UTC timezone
1073             String startDateTime = originalDateZulu.substring(0, 10) + "T00:00:00.0000000";
1074             String endDateTime = originalDateZulu.substring(0, 10) + "T23:59:59.9999999";
1075             // search for occurrence in master event instances
1076             GraphObject graphResponse = executeGraphRequest(new GraphRequestBuilder().setMethod(HttpGet.METHOD_NAME)
1077                     .setMailbox(folderId.mailbox)
1078                     .setObjectType("events")
1079                     .setObjectId(existingJsonEvent.optString("id"))
1080                     .setChildType("instances")
1081                     .setStartDateTime(startDateTime)
1082                     .setEndDateTime(endDateTime));
1083 
1084             JSONArray occurrences = graphResponse.optJSONArray("value");
1085             if (occurrences != null && occurrences.length() > 0) {
1086                 for (int i = 0; i < occurrences.length(); i++) {
1087                     JSONObject occurrence = occurrences.getJSONObject(i);
1088                     String occurrenceId = occurrence.optString("id");
1089                     if (occurrenceId != null) {
1090                         updateExceptionOccurrence(modifiedOccurrence, occurrenceId);
1091                     }
1092                 }
1093             } else {
1094                 LOGGER.warn("No occurrence found for " + originalDateZulu);
1095             }
1096         }
1097 
1098         private void updateExceptionOccurrence(VObject modifiedOccurrence, String exceptionOccurrenceId) throws IOException, JSONException {
1099             LOGGER.debug("Updating occurrence " + modifiedOccurrence.getPropertyValue("SUMMARY") + " " + modifiedOccurrence.getPropertyValue("RECURRENCE-ID"));
1100 
1101             GraphObject graphEventOccurrence = buildJsonEvent(modifiedOccurrence);
1102 
1103             GraphObject graphResponse = executeGraphRequest(new GraphRequestBuilder()
1104                     .setMethod(HttpPatch.METHOD_NAME)
1105                     .setMailbox(folderId.mailbox)
1106                     .setObjectType("events")
1107                     .setObjectId(exceptionOccurrenceId)
1108                     .setJsonBody(graphEventOccurrence));
1109 
1110             LOGGER.debug("Updated occurrence: " + graphResponse.jsonObject.toString());
1111         }
1112 
1113         private GraphObject buildJsonEvent(VObject vEvent) throws JSONException, IOException {
1114             GraphObject newGraphEvent = new GraphObject();
1115 
1116             newGraphEvent.put("subject", vEvent.getPropertyValue("SUMMARY"));
1117 
1118             // set start and end date
1119             VProperty dtStart = vEvent.getProperty("DTSTART");
1120             String dtStartTzid = dtStart.getParamValue("TZID");
1121             newGraphEvent.put("start", new JSONObject().put("dateTime", vCalendar.convertCalendarDateToGraph(dtStart.getValue(), dtStartTzid)).put("timeZone", dtStartTzid));
1122 
1123             VProperty dtEnd = vEvent.getProperty("DTEND");
1124             String dtEndTzid = dtEnd.getParamValue("TZID");
1125             newGraphEvent.put("end", new JSONObject().put("dateTime", vCalendar.convertCalendarDateToGraph(dtEnd.getValue(), dtEndTzid)).put("timeZone", dtEndTzid));
1126 
1127             VProperty descriptionProperty = vEvent.getProperty("DESCRIPTION");
1128             String description = null;
1129             if (descriptionProperty != null) {
1130                 // prefer html description
1131                 description = descriptionProperty.getParamValue("ALTREP");
1132             }
1133             if (description != null && description.startsWith("data:text/html,")) {
1134                 description = URIUtil.decode(description.replaceFirst("data:text/html,", ""));
1135                 newGraphEvent.put("body", new JSONObject().put("content", description).put("contentType", "html"));
1136             } else if (descriptionProperty != null) {
1137                 description = descriptionProperty.getValue();
1138                 newGraphEvent.put("body", new JSONObject().put("content", description).put("contentType", "text"));
1139             }
1140 
1141             String location = vEvent.getPropertyValue("LOCATION");
1142             newGraphEvent.put("location", new JSONObject().put("displayName", location));
1143 
1144             newGraphEvent.setCategories(vEvent.getPropertyValue("CATEGORIES"));
1145             // Collect categories on multiple lines
1146             List<VProperty> categories = vEvent.getProperties("CATEGORIES");
1147             if (categories != null) {
1148                 HashSet<String> categoryValues = new HashSet<>();
1149                 for (VProperty category : categories) {
1150                     categoryValues.add(category.getValue());
1151                 }
1152                 newGraphEvent.setCategories(StringUtil.join(categoryValues, ","));
1153             }
1154 
1155             if (vCalendar.isMeeting()) {
1156                 // build attendee list
1157                 JSONArray attendees = new JSONArray();
1158                 newGraphEvent.put("attendees", attendees);
1159 
1160                 List<VProperty> attendeeProperties = vEvent.getProperties("ATTENDEE");
1161                 if (attendeeProperties != null) {
1162                     for (VProperty property : attendeeProperties) {
1163                         String attendeeEmail = vCalendar.getEmailValue(property);
1164                         if (attendeeEmail != null && attendeeEmail.indexOf('@') >= 0) {
1165                             String cn = property.getParamValue("CN");
1166                             JSONObject jsonAttendee = new JSONObject()
1167                                     .put("emailAddress", new JSONObject().put("name", cn)
1168                                             .put("address", attendeeEmail));
1169 
1170                             String attendeeRole = property.getParamValue("ROLE");
1171                             if ("REQ-PARTICIPANT".equals(attendeeRole)) {
1172                                 jsonAttendee.put("type", "required");
1173                             } else {
1174                                 jsonAttendee.put("type", "optional");
1175                             }
1176                             attendees.put(jsonAttendee);
1177                         }
1178                     }
1179                 }
1180             }
1181 
1182             return newGraphEvent;
1183         }
1184 
1185         private void deleteEventOccurrence(String id, String exDateValue) throws IOException, JSONException {
1186             String startDateTime = exDateValue.substring(0, 10) + "T00:00:00.0000000";
1187             String endDateTime = exDateValue.substring(0, 10) + "T23:59:59.9999999";
1188             GraphObject graphResponse = executeGraphRequest(new GraphRequestBuilder().setMethod(HttpGet.METHOD_NAME)
1189                     .setMailbox(folderId.mailbox)
1190                     .setObjectType("events")
1191                     .setObjectId(id)
1192                     .setChildType("instances")
1193                     .setStartDateTime(startDateTime)
1194                     .setEndDateTime(endDateTime));
1195 
1196             JSONArray occurrences = graphResponse.optJSONArray("value");
1197             if (occurrences != null && occurrences.length() > 0) {
1198                 for (int i = 0; i < occurrences.length(); i++) {
1199                     JSONObject occurrence = occurrences.getJSONObject(i);
1200                     String occurrenceId = occurrence.optString("id");
1201                     if (occurrenceId != null) {
1202                         executeJsonRequest(new GraphRequestBuilder().setMethod(HttpDelete.METHOD_NAME)
1203                                 .setMailbox(folderId.mailbox)
1204                                 .setObjectType("events")
1205                                 .setObjectId(occurrenceId));
1206                     }
1207                 }
1208             }
1209         }
1210 
1211         private JSONObject buildJsonTask(VObject vTodo) throws JSONException, IOException {
1212             JSONObject jsonEvent = new JSONObject();
1213             GraphObject localGraphObject = new GraphObject(jsonEvent);
1214 
1215             localGraphObject.put("summary", vTodo.getPropertyValue("SUMMARY"));
1216 
1217             localGraphObject.setTaskImportanceFromVTodo(vTodo);
1218             localGraphObject.setTaskStatusFromVTodo(vTodo);
1219 
1220             // TODO refactor duplicate code with event
1221             VProperty descriptionProperty = vTodo.getProperty("DESCRIPTION");
1222             String description = null;
1223             if (descriptionProperty != null) {
1224                 description = vTodo.getProperty("DESCRIPTION").getParamValue("ALTREP");
1225             }
1226             if (description != null && description.startsWith("data:text/html,")) {
1227                 description = URIUtil.decode(description.replaceFirst("data:text/html,", ""));
1228                 jsonEvent.put("body", new JSONObject().put("content", description).put("contentType", "html"));
1229             } else {
1230                 description = vTodo.getPropertyValue("DESCRIPTION");
1231                 jsonEvent.put("body", new JSONObject().put("content", description).put("contentType", "text"));
1232             }
1233 
1234             VProperty dtStart = vTodo.getProperty("DTSTART");
1235             if (dtStart != null) {
1236                 String dtStartTzid = dtStart.getParamValue("TZID");
1237                 if (dtStartTzid == null) {
1238                     dtStartTzid = vCalendar.getVTimezone().getPropertyValue("TZID");
1239                 }
1240                 jsonEvent.put("startDateTime", new JSONObject().put("dateTime", vCalendar.convertCalendarDateToGraph(dtStart.getValue(), dtStartTzid)).put("timeZone", dtStartTzid));
1241             }
1242 
1243             VProperty due = vTodo.getProperty("DUE");
1244             if (due != null) {
1245                 String dueTzid = due.getParamValue("TZID");
1246                 if (dueTzid == null) {
1247                     dueTzid = vCalendar.getVTimezone().getPropertyValue("TZID");
1248                 }
1249                 jsonEvent.put("dueDateTime", new JSONObject().put("dateTime", vCalendar.convertCalendarDateToGraph(due.getValue(), dueTzid)).put("timeZone", dueTzid));
1250             }
1251 
1252             VProperty completed = vTodo.getProperty("COMPLETED");
1253             if (completed != null) {
1254                 String completedTzid = completed.getParamValue("TZID");
1255                 if (completedTzid == null) {
1256                     completedTzid = vCalendar.getVTimezone().getPropertyValue("TZID");
1257                 }
1258                 jsonEvent.put("completedDateTime", new JSONObject().put("dateTime", vCalendar.convertCalendarDateToGraph(completed.getValue(), completedTzid)).put("timeZone", completedTzid));
1259             }
1260 
1261             localGraphObject.setCategories(vTodo.getPropertyValue("CATEGORIES"));
1262             // Collect categories on multiple lines
1263             List<VProperty> categories = vTodo.getProperties("CATEGORIES");
1264             if (categories != null) {
1265                 HashSet<String> categoryValues = new HashSet<>();
1266                 for (VProperty category : categories) {
1267                     categoryValues.add(category.getValue());
1268                 }
1269                 localGraphObject.setCategories(StringUtil.join(categoryValues, ","));
1270             }
1271 
1272             return jsonEvent;
1273         }
1274 
1275     }
1276 
1277     /**
1278      * Override isExpired check.
1279      * @return true if session / token is expired
1280      * @throws NoRouteToHostException on network error
1281      * @throws UnknownHostException on network error
1282      */
1283     @Override
1284     public boolean isExpired() throws NoRouteToHostException, UnknownHostException {
1285         boolean isExpired = false;
1286         try {
1287             executeJsonRequest(new GraphRequestBuilder().setMethod(HttpGet.METHOD_NAME).setObjectType("mailFolders").setSelect("id"));
1288         } catch (UnknownHostException | NoRouteToHostException exc) {
1289             throw exc;
1290         } catch (IOException e) {
1291             isExpired = true;
1292         }
1293 
1294         return isExpired;
1295     }
1296 
1297     private String convertHtmlToText(String htmlText) {
1298         StringBuilder builder = new StringBuilder();
1299 
1300         HtmlCleaner cleaner = new HtmlCleaner();
1301         cleaner.getProperties().setDeserializeEntities(true);
1302         try {
1303             TagNode node = cleaner.clean(new StringReader(htmlText));
1304             for (TagNode childNode : node.getAllElementsList(true)) {
1305                 builder.append(childNode.getText());
1306             }
1307         } catch (IOException e) {
1308             LOGGER.error("Error converting html to text", e);
1309         }
1310         return builder.toString();
1311     }
1312 
1313     private VProperty convertBodyToVproperty(String propertyName, GraphObject graphObject) {
1314         JSONObject jsonBody = graphObject.optJSONObject("body");
1315 
1316         if (jsonBody == null) {
1317             return new VProperty(propertyName, "");
1318         } else {
1319             // body is html only over graph by default
1320             String content = jsonBody.optString("content");
1321             String contentType = jsonBody.optString("contentType");
1322             VProperty vProperty;
1323 
1324             if ("text".equals(contentType)) {
1325                 vProperty = new VProperty(propertyName, content);
1326             } else {
1327                 // html
1328                 if (content != null) {
1329                     vProperty = new VProperty(propertyName, convertHtmlToText(content));
1330                     // remove CR LF from html content
1331                     content = content.replace("\n", "").replace("\r", "");
1332                     vProperty.addParam("ALTREP", "data:text/html," + URIUtil.encodeWithinQuery(content));
1333                 } else {
1334                     vProperty = new VProperty(propertyName, null);
1335                 }
1336 
1337             }
1338             return vProperty;
1339         }
1340     }
1341 
1342     private VProperty convertDateTimeTimeZoneToVproperty(String vPropertyName, JSONObject jsonDateTimeTimeZone, String originalStartTimeZone) throws DavMailException {
1343 
1344         if (jsonDateTimeTimeZone != null) {
1345             String timeZone = jsonDateTimeTimeZone.optString("timeZone");
1346             String dateTime = convertDateFromExchange(jsonDateTimeTimeZone.optString("dateTime"));
1347 
1348             if (originalStartTimeZone != null && !timeZone.equals(originalStartTimeZone)) {
1349                 LOGGER.debug("originalStartTimeZone different from requested timeZone: " + originalStartTimeZone + " vs " + timeZone);
1350                 // convert to original timezone to preserve time over DST
1351                 SimpleDateFormat parser = new SimpleDateFormat("yyyyMMdd'T'HHmmss");
1352                 SimpleDateFormat formatter = new SimpleDateFormat("yyyyMMdd'T'HHmmss");
1353                 parser.setTimeZone(DateUtil.getTimeZone(timeZone));
1354                 formatter.setTimeZone(DateUtil.getTimeZone(originalStartTimeZone));
1355                 try {
1356                     dateTime = formatter.format(parser.parse(dateTime));
1357                     timeZone = originalStartTimeZone;
1358                 } catch (ParseException e) {
1359                     LOGGER.warn("Unable to convert to original timezone: " + dateTime + ", " + originalStartTimeZone);
1360                 }
1361             }
1362 
1363             VProperty vProperty = new VProperty(vPropertyName, dateTime);
1364             vProperty.addParam("TZID", timeZone);
1365             return vProperty;
1366         }
1367         return new VProperty(vPropertyName, null);
1368     }
1369 
1370     private VProperty convertEmailAddressToVproperty(String propertyName, JSONObject jsonEmailAddress) {
1371         VProperty attendeeProperty = new VProperty(propertyName, "mailto:" + jsonEmailAddress.optString("address"));
1372         attendeeProperty.addParam("CN", jsonEmailAddress.optString("name"));
1373         return attendeeProperty;
1374     }
1375 
1376     private String convertDateTimeTimeZoneToTaskDate(Date exchangeDateValue) {
1377         String zuluDateValue = null;
1378         if (exchangeDateValue != null) {
1379             SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd", Locale.ENGLISH);
1380             dateFormat.setTimeZone(GMT_TIMEZONE);
1381             zuluDateValue = dateFormat.format(exchangeDateValue);
1382         }
1383         return zuluDateValue;
1384 
1385     }
1386 
1387     protected class Contact extends ExchangeSession.Contact {
1388         // item id
1389         FolderId folderId;
1390         String id;
1391 
1392         protected Contact(GraphObject response) throws DavMailException {
1393             id = response.optString("id");
1394             etag = response.optString("@odata.etag");
1395 
1396             displayName = response.optString("displayname");
1397             // prefer urlcompname (client provided item name) for contacts
1398             itemName = StringUtil.decodeUrlcompname(response.optString("urlcompname"));
1399             // if urlcompname is empty, contact was created on the server side
1400             if (itemName == null) {
1401                 itemName = StringUtil.base64ToUrl(id) + ".EML";
1402             }
1403             put("uid", response.optString("uid"));
1404 
1405             for (GraphField attribute : CONTACT_ATTRIBUTES) {
1406                 String alias = attribute.getAlias();
1407                 if (!alias.startsWith("smtpemail")) {
1408                     String value = response.optString(attribute);
1409                     if (value != null && !value.isEmpty()) {
1410                         put(alias, value);
1411                     }
1412                 }
1413             }
1414 
1415             JSONArray emailAddresses = response.optJSONArray("emailAddresses");
1416             if (emailAddresses != null) {
1417                 for (int i = 0; i < emailAddresses.length(); i++) {
1418                     JSONObject emailAddress = emailAddresses.optJSONObject(i);
1419                     if (emailAddress != null) {
1420                         String email = emailAddress.optString("address");
1421                         String type = emailAddress.optString("type");
1422                         if (email != null && !email.isEmpty()) {
1423                             if ("other".equals(type)) {
1424                                 put("smtpemail3", email);
1425                             } else if ("personal".equals(type)) {
1426                                 put("smtpemail2", email);
1427                             } else if ("work".equals(type)) {
1428                                 put("smtpemail1", email);
1429                             }
1430                         }
1431                     }
1432                 }
1433                 // iterate a second time to fill unknown email types
1434                 for (int i = 0; i < emailAddresses.length(); i++) {
1435                     JSONObject emailAddress = emailAddresses.optJSONObject(i);
1436                     if (emailAddress != null) {
1437                         String email = emailAddress.optString("address");
1438                         String type = emailAddress.optString("type");
1439                         if (email != null && !email.isEmpty()) {
1440                             if ("unknown".equals(type)) {
1441                                 if (get("smtpemail1") == null) {
1442                                     put("smtpemail1", email);
1443                                 } else if (get("smtpemail2") == null) {
1444                                     put("smtpemail2", email);
1445                                 } else if (get("smtpemail3") == null) {
1446                                     put("smtpemail3", email);
1447                                 }
1448                             }
1449                         }
1450                     }
1451                 }
1452             }
1453         }
1454 
1455         protected Contact(String folderPath, String itemName, Map<String, String> properties, String etag, String noneMatch) {
1456             super(folderPath, itemName, properties, etag, noneMatch);
1457         }
1458 
1459         /**
1460          * Empty constructor for GalFind
1461          */
1462         protected Contact() {
1463         }
1464 
1465         /**
1466          * Create or update contact.
1467          * <a href="https://learn.microsoft.com/en-us/graph/api/user-post-contacts">user-post-contacts</a>
1468          *
1469          * @return action result
1470          * @throws IOException on error
1471          */
1472         @Override
1473         public ItemResult createOrUpdate() throws IOException {
1474 
1475             FolderId folderId = getFolderId(folderPath);
1476             String id = null;
1477             String currentEtag = null;
1478             JSONObject jsonContact = getContactIfExists(folderId, itemName);
1479             if (jsonContact != null) {
1480                 id = jsonContact.optString("id", null);
1481                 currentEtag = new GraphObject(jsonContact).optString("changeKey");
1482             }
1483 
1484             ItemResult itemResult = new ItemResult();
1485             if ("*".equals(noneMatch)) {
1486                 // create requested but already exists
1487                 if (id != null) {
1488                     itemResult.status = HttpStatus.SC_PRECONDITION_FAILED;
1489                     return itemResult;
1490                 }
1491             } else if (etag != null) {
1492                 // update requested
1493                 if (id == null || !etag.equals(currentEtag)) {
1494                     itemResult.status = HttpStatus.SC_PRECONDITION_FAILED;
1495                     return itemResult;
1496                 }
1497             }
1498 
1499             try {
1500                 JSONObject jsonObject = new JSONObject();
1501                 GraphObject graphObject = new GraphObject(jsonObject);
1502                 for (Map.Entry<String, String> entry : entrySet()) {
1503                     if ("keywords".equals(entry.getKey())) {
1504                         graphObject.setCategories(entry.getValue());
1505                     } else if ("bday".equals(entry.getKey())) {
1506                         graphObject.put(entry.getKey(), convertZuluToIso(entry.getValue()));
1507                     } else if ("anniversary".equals(entry.getKey())) {
1508                         graphObject.put(entry.getKey(), convertZuluToDate(entry.getValue()));
1509                     } else if ("photo".equals(entry.getKey())) {
1510                         LOGGER.debug("Contact has a photo");
1511                     } else if (!entry.getKey().startsWith("email") && !entry.getKey().startsWith("smtpemail")
1512                             && !"usersmimecertificate".equals(entry.getKey()) // not supported over Graph
1513                             && !"msexchangecertificate".equals(entry.getKey()) // not supported over Graph
1514                             && !"pager".equals(entry.getKey()) && !"otherTelephone".equals(entry.getKey()) // see below
1515                             && !"fileas".equals(entry.getKey()) && !"outlookmessageclass".equals(entry.getKey())
1516                             && !"subject".equals(entry.getKey())
1517                     ) {
1518                         graphObject.put(entry.getKey(), entry.getValue());
1519                     }
1520                 }
1521 
1522                 // pager and otherTelephone is a single field
1523                 String pager = get("pager");
1524                 if (pager == null) {
1525                     pager = get("otherTelephone");
1526                 }
1527                 graphObject.put("pager", pager);
1528 
1529                 // force urlcompname
1530                 graphObject.put("urlcompname", convertItemNameToEML(itemName));
1531 
1532                 // handle emails
1533                 JSONArray emailAddresses = new JSONArray();
1534                 String smtpemail1 = get("smtpemail1");
1535                 if (smtpemail1 != null) {
1536                     JSONObject emailAddress = new JSONObject();
1537                     emailAddress.put("address", smtpemail1);
1538                     emailAddress.put("type", "work");
1539                     emailAddresses.put(emailAddress);
1540                 }
1541 
1542                 String smtpemail2 = get("smtpemail2");
1543                 if (smtpemail2 != null) {
1544                     JSONObject emailAddress = new JSONObject();
1545                     emailAddress.put("address", smtpemail2);
1546                     emailAddress.put("type", "personal");
1547                     emailAddresses.put(emailAddress);
1548                 }
1549 
1550                 String smtpemail3 = get("smtpemail3");
1551                 if (smtpemail3 != null) {
1552                     JSONObject emailAddress = new JSONObject();
1553                     emailAddress.put("address", smtpemail3);
1554                     emailAddress.put("type", "other");
1555                     emailAddresses.put(emailAddress);
1556                 }
1557                 graphObject.put("emailAddresses", emailAddresses);
1558 
1559                 GraphRequestBuilder graphRequestBuilder = new GraphRequestBuilder();
1560                 if (id == null) {
1561                     graphRequestBuilder.setMethod(HttpPost.METHOD_NAME)
1562                             .setMailbox(folderId.mailbox)
1563                             .setObjectType("contactFolders")
1564                             .setObjectId(folderId.id)
1565                             .setChildType("contacts")
1566                             .setJsonBody(jsonObject);
1567                 } else {
1568                     graphRequestBuilder.setMethod(HttpPatch.METHOD_NAME)
1569                             .setMailbox(folderId.mailbox)
1570                             .setObjectType("contactFolders")
1571                             .setObjectId(folderId.id)
1572                             .setChildType("contacts")
1573                             .setChildId(id)
1574                             .setJsonBody(jsonObject);
1575                 }
1576 
1577                 GraphObject graphResponse = executeGraphRequest(graphRequestBuilder);
1578 
1579                 if (LOGGER.isDebugEnabled()) {
1580                     LOGGER.debug(graphResponse.toString(4));
1581                 }
1582 
1583                 itemResult.status = graphResponse.statusCode;
1584 
1585                 updatePhoto(folderId, graphResponse.optString("id"));
1586 
1587                 // reload to get latest etag
1588                 graphResponse = new GraphObject(getContactIfExists(folderId, itemName));
1589 
1590                 itemResult.itemName = itemName;
1591                 itemResult.etag = graphResponse.optString("etag");
1592 
1593             } catch (JSONException e) {
1594                 throw new IOException(e);
1595             }
1596             if (itemResult.status == HttpStatus.SC_CREATED) {
1597                 LOGGER.debug("Created contact " + getHref());
1598             } else {
1599                 LOGGER.debug("Updated contact " + getHref());
1600             }
1601 
1602             return itemResult;
1603         }
1604 
1605         private void updatePhoto(FolderId folderId, String contactId) throws IOException {
1606             String photo = get("photo");
1607             if (photo != null) {
1608                 // convert image to jpeg
1609                 byte[] resizedImageBytes = IOUtil.resizeImage(IOUtil.decodeBase64(photo), 90);
1610 
1611                 // Upload resized image to contact photo endpoint
1612                 JSONObject jsonResponse = executeJsonRequest(new GraphRequestBuilder()
1613                         .setMethod(HttpPut.METHOD_NAME)
1614                         .setMailbox(folderId.mailbox)
1615                         .setObjectType("contactFolders")
1616                         .setObjectId(folderId.id)
1617                         .setChildType("contacts")
1618                         .setChildId(contactId)
1619                         .setChildSuffix("photo/$value")
1620                         .setContentType("image/jpeg")
1621                         .setMimeContent(resizedImageBytes));
1622 
1623                 if (LOGGER.isDebugEnabled()) {
1624                     LOGGER.debug(jsonResponse);
1625                 }
1626             } else {
1627                 // Delete the contact photo
1628                 executeJsonRequest(new GraphRequestBuilder()
1629                         .setMethod(HttpDelete.METHOD_NAME)
1630                         .setMailbox(folderId.mailbox)
1631                         .setObjectType("contactFolders")
1632                         .setObjectId(folderId.id)
1633                         .setChildType("contacts")
1634                         .setChildId(contactId)
1635                         .setChildSuffix("photo"));
1636             }
1637         }
1638     }
1639 
1640     /**
1641      * Converts a Zulu date-time string to ISO format by removing unnecessary
1642      * precision in the fractional seconds if present.
1643      *
1644      * @param value a date-time string in Zulu format to be converted; may be null.
1645      * @return the converted date-time string in ISO format, or the original
1646      *         value if it was null.
1647      */
1648     private String convertZuluToIso(String value) {
1649         if (value != null) {
1650             return value.replace(".000Z", "Z");
1651         } else {
1652             return null;
1653         }
1654     }
1655 
1656     /**
1657      * Converts a Zulu date-time string to a basic day format by extracting the
1658      * date portion of the string before the "T" character.
1659      *
1660      * @param value a date-time string in Zulu format to be converted; may be null.
1661      *              The string is expected to contain a "T" character separating
1662      *              the date and time portions.
1663      * @return the extracted date portion as a string if the input contains "T",
1664      *         or the original input string if the "T" is not present or the input is null.
1665      */
1666     private String convertZuluToDate(String value) {
1667         if (value != null && value.contains("T")) {
1668             return value.substring(0, value.indexOf("T"));
1669         } else {
1670             return value;
1671         }
1672     }
1673 
1674     // special folders https://learn.microsoft.com/en-us/graph/api/resources/mailfolder
1675     @SuppressWarnings("SpellCheckingInspection")
1676     public enum WellKnownFolderName {
1677         archive,
1678         deleteditems,
1679         calendar, contacts, tasks,
1680         drafts, inbox, outbox, sentitems, junkemail,
1681         msgfolderroot,
1682         searchfolders
1683     }
1684 
1685     // https://www.rfc-editor.org/rfc/rfc6154.html map well-known names to special flags
1686     protected static HashMap<String, String> wellKnownFolderMap = new HashMap<>();
1687 
1688     static {
1689         wellKnownFolderMap.put(WellKnownFolderName.inbox.name(), ExchangeSession.INBOX);
1690         wellKnownFolderMap.put(WellKnownFolderName.archive.name(), ExchangeSession.ARCHIVE);
1691         wellKnownFolderMap.put(WellKnownFolderName.drafts.name(), ExchangeSession.DRAFTS);
1692         wellKnownFolderMap.put(WellKnownFolderName.junkemail.name(), ExchangeSession.JUNK);
1693         wellKnownFolderMap.put(WellKnownFolderName.sentitems.name(), ExchangeSession.SENT);
1694         wellKnownFolderMap.put(WellKnownFolderName.deleteditems.name(), ExchangeSession.TRASH);
1695     }
1696 
1697     protected static final HashSet<GraphField> IMAP_MESSAGE_ATTRIBUTES = new HashSet<>();
1698 
1699     static {
1700         // TODO: review, permanenturl is no longer relevant
1701         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("permanenturl"));
1702         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("changeKey"));
1703         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("isDraft"));
1704         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("isRead"));
1705         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("receivedDateTime"));
1706         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("lastModifiedDateTime"));
1707 
1708         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("urlcompname"));
1709         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("uid"));
1710         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("messageSize"));
1711         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("imapUid"));
1712         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("junk"));
1713         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("flagStatus"));
1714         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("messageFlags"));
1715         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("lastVerbExecuted"));
1716         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("read"));
1717         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("deleted"));
1718         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("date"));
1719         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("lastmodified"));
1720         // OSX IMAP requests content-class
1721         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("contentclass"));
1722         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("keywords"));
1723 
1724         // experimental, retrieve message headers
1725         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("messageheaders"));
1726         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("outlookmessageclass"));
1727     }
1728 
1729     protected static final HashSet<GraphField> CONTACT_ATTRIBUTES = new HashSet<>();
1730 
1731     static {
1732         CONTACT_ATTRIBUTES.add(GraphField.get("uid"));
1733 
1734         CONTACT_ATTRIBUTES.add(GraphField.get("imapUid"));
1735         // CONTACT_ATTRIBUTES.add(GraphField.get("etag")); replace with @odata.etag
1736         CONTACT_ATTRIBUTES.add(GraphField.get("urlcompname"));
1737         CONTACT_ATTRIBUTES.add(GraphField.get("keywords"));
1738 
1739         CONTACT_ATTRIBUTES.add(GraphField.get("extensionattribute1"));
1740         CONTACT_ATTRIBUTES.add(GraphField.get("extensionattribute2"));
1741         CONTACT_ATTRIBUTES.add(GraphField.get("extensionattribute3"));
1742         CONTACT_ATTRIBUTES.add(GraphField.get("extensionattribute4"));
1743         CONTACT_ATTRIBUTES.add(GraphField.get("bday"));
1744         CONTACT_ATTRIBUTES.add(GraphField.get("anniversary"));
1745         CONTACT_ATTRIBUTES.add(GraphField.get("businesshomepage"));
1746         CONTACT_ATTRIBUTES.add(GraphField.get("personalHomePage"));
1747         CONTACT_ATTRIBUTES.add(GraphField.get("cn"));
1748         CONTACT_ATTRIBUTES.add(GraphField.get("co"));
1749         CONTACT_ATTRIBUTES.add(GraphField.get("department"));
1750         CONTACT_ATTRIBUTES.add(GraphField.get("smtpemail1"));
1751         CONTACT_ATTRIBUTES.add(GraphField.get("smtpemail2"));
1752         CONTACT_ATTRIBUTES.add(GraphField.get("smtpemail3"));
1753         CONTACT_ATTRIBUTES.add(GraphField.get("facsimiletelephonenumber"));
1754         CONTACT_ATTRIBUTES.add(GraphField.get("givenName"));
1755         CONTACT_ATTRIBUTES.add(GraphField.get("homeCity"));
1756         CONTACT_ATTRIBUTES.add(GraphField.get("homeCountry"));
1757         CONTACT_ATTRIBUTES.add(GraphField.get("homePhone"));
1758         CONTACT_ATTRIBUTES.add(GraphField.get("homePostalCode"));
1759         CONTACT_ATTRIBUTES.add(GraphField.get("homeState"));
1760         CONTACT_ATTRIBUTES.add(GraphField.get("homeStreet"));
1761         CONTACT_ATTRIBUTES.add(GraphField.get("homepostofficebox"));
1762         CONTACT_ATTRIBUTES.add(GraphField.get("l"));
1763         CONTACT_ATTRIBUTES.add(GraphField.get("manager"));
1764         CONTACT_ATTRIBUTES.add(GraphField.get("mobile"));
1765         CONTACT_ATTRIBUTES.add(GraphField.get("namesuffix"));
1766         CONTACT_ATTRIBUTES.add(GraphField.get("nickname"));
1767         CONTACT_ATTRIBUTES.add(GraphField.get("o"));
1768         CONTACT_ATTRIBUTES.add(GraphField.get("pager"));
1769         CONTACT_ATTRIBUTES.add(GraphField.get("personaltitle"));
1770         CONTACT_ATTRIBUTES.add(GraphField.get("postalcode"));
1771         CONTACT_ATTRIBUTES.add(GraphField.get("postofficebox"));
1772         CONTACT_ATTRIBUTES.add(GraphField.get("profession"));
1773         CONTACT_ATTRIBUTES.add(GraphField.get("roomnumber"));
1774         CONTACT_ATTRIBUTES.add(GraphField.get("secretarycn"));
1775         CONTACT_ATTRIBUTES.add(GraphField.get("sn"));
1776         CONTACT_ATTRIBUTES.add(GraphField.get("spousecn"));
1777         CONTACT_ATTRIBUTES.add(GraphField.get("st"));
1778         CONTACT_ATTRIBUTES.add(GraphField.get("street"));
1779         CONTACT_ATTRIBUTES.add(GraphField.get("telephoneNumber"));
1780         CONTACT_ATTRIBUTES.add(GraphField.get("title"));
1781         CONTACT_ATTRIBUTES.add(GraphField.get("description"));
1782         CONTACT_ATTRIBUTES.add(GraphField.get("im"));
1783         CONTACT_ATTRIBUTES.add(GraphField.get("middlename"));
1784         CONTACT_ATTRIBUTES.add(GraphField.get("lastmodified"));
1785         CONTACT_ATTRIBUTES.add(GraphField.get("otherstreet"));
1786         CONTACT_ATTRIBUTES.add(GraphField.get("otherstate"));
1787         CONTACT_ATTRIBUTES.add(GraphField.get("otherpostofficebox"));
1788         CONTACT_ATTRIBUTES.add(GraphField.get("otherpostalcode"));
1789         CONTACT_ATTRIBUTES.add(GraphField.get("othercountry"));
1790         CONTACT_ATTRIBUTES.add(GraphField.get("othercity"));
1791         CONTACT_ATTRIBUTES.add(GraphField.get("haspicture"));
1792         CONTACT_ATTRIBUTES.add(GraphField.get("othermobile"));
1793         CONTACT_ATTRIBUTES.add(GraphField.get("otherTelephone"));
1794         CONTACT_ATTRIBUTES.add(GraphField.get("gender"));
1795         CONTACT_ATTRIBUTES.add(GraphField.get("private"));
1796         CONTACT_ATTRIBUTES.add(GraphField.get("sensitivity"));
1797         CONTACT_ATTRIBUTES.add(GraphField.get("fburl"));
1798         // certificates not supported over graph
1799         // CONTACT_ATTRIBUTES.add(GraphField.get("msexchangecertificate"));
1800         // CONTACT_ATTRIBUTES.add(GraphField.get("usersmimecertificate"));
1801     }
1802 
1803     private static final Set<GraphField> TODO_PROPERTIES = new HashSet<>();
1804 
1805     static {
1806         // Task properties https://learn.microsoft.com/en-us/graph/api/resources/todotask
1807         TODO_PROPERTIES.add(GraphField.get("id"));
1808         TODO_PROPERTIES.add(GraphField.get("summary"));
1809         TODO_PROPERTIES.add(GraphField.get("body"));
1810         TODO_PROPERTIES.add(GraphField.get("lastModifiedDateTime"));
1811         TODO_PROPERTIES.add(GraphField.get("createdDateTime"));
1812         TODO_PROPERTIES.add(GraphField.get("importance"));
1813         TODO_PROPERTIES.add(GraphField.get("status"));
1814         TODO_PROPERTIES.add(GraphField.get("dueDateTime"));
1815         TODO_PROPERTIES.add(GraphField.get("startDateTime"));
1816         TODO_PROPERTIES.add(GraphField.get("completedDateTime"));
1817         TODO_PROPERTIES.add(GraphField.get("categories"));
1818     }
1819 
1820     /**
1821      * EVENT_ATTRIBUTES to retrieve all fields including modified occurrences, EVENT_LIST_ATTRIBUTES for search
1822      */
1823     protected static final HashSet<GraphField> EVENT_LIST_ATTRIBUTES = new HashSet<>();
1824     protected static final HashSet<GraphField> EVENT_ATTRIBUTES = new HashSet<>();
1825 
1826     static {
1827         EVENT_LIST_ATTRIBUTES.add(GraphField.get("id"));
1828         EVENT_LIST_ATTRIBUTES.add(GraphField.get("urlcompname"));
1829         EVENT_LIST_ATTRIBUTES.add(GraphField.get("changeKey"));
1830 
1831         EVENT_ATTRIBUTES.add(GraphField.get("urlcompname"));
1832         EVENT_ATTRIBUTES.add(GraphField.get("allowNewTimeProposals"));
1833         EVENT_ATTRIBUTES.add(GraphField.get("attendees"));
1834         EVENT_ATTRIBUTES.add(GraphField.get("bodyPreview"));
1835         EVENT_ATTRIBUTES.add(GraphField.get("body"));
1836         EVENT_ATTRIBUTES.add(GraphField.get("cancelledOccurrences"));
1837         EVENT_ATTRIBUTES.add(GraphField.get("categories"));
1838         EVENT_ATTRIBUTES.add(GraphField.get("changeKey"));
1839         EVENT_ATTRIBUTES.add(GraphField.get("createdDateTime"));
1840         EVENT_ATTRIBUTES.add(GraphField.get("end"));
1841         EVENT_ATTRIBUTES.add(GraphField.get("exceptionOccurrences"));
1842         EVENT_ATTRIBUTES.add(GraphField.get("hasAttachments"));
1843         EVENT_ATTRIBUTES.add(GraphField.get("iCalUId"));
1844         EVENT_ATTRIBUTES.add(GraphField.get("transactionId"));
1845         EVENT_ATTRIBUTES.add(GraphField.get("id"));
1846         EVENT_ATTRIBUTES.add(GraphField.get("importance"));
1847         EVENT_ATTRIBUTES.add(GraphField.get("isAllDay"));
1848         EVENT_ATTRIBUTES.add(GraphField.get("isOnlineMeeting"));
1849         EVENT_ATTRIBUTES.add(GraphField.get("isOrganizer"));
1850         EVENT_ATTRIBUTES.add(GraphField.get("isReminderOn"));
1851         EVENT_ATTRIBUTES.add(GraphField.get("lastModifiedDateTime"));
1852         EVENT_ATTRIBUTES.add(GraphField.get("location"));
1853         EVENT_ATTRIBUTES.add(GraphField.get("organizer"));
1854         EVENT_ATTRIBUTES.add(GraphField.get("originalStartTimeZone"));
1855         EVENT_ATTRIBUTES.add(GraphField.get("originalStart"));
1856         EVENT_ATTRIBUTES.add(GraphField.get("recurrence"));
1857         EVENT_ATTRIBUTES.add(GraphField.get("reminderMinutesBeforeStart"));
1858         EVENT_ATTRIBUTES.add(GraphField.get("responseRequested"));
1859         EVENT_ATTRIBUTES.add(GraphField.get("responseStatus"));
1860         EVENT_ATTRIBUTES.add(GraphField.get("sensitivity"));
1861         EVENT_ATTRIBUTES.add(GraphField.get("showAs"));
1862         EVENT_ATTRIBUTES.add(GraphField.get("start"));
1863         EVENT_ATTRIBUTES.add(GraphField.get("subject"));
1864         EVENT_ATTRIBUTES.add(GraphField.get("type"));
1865 
1866         EVENT_ATTRIBUTES.add(GraphField.get("xmozlastack"));
1867         EVENT_ATTRIBUTES.add(GraphField.get("xmozsnoozetime"));
1868     }
1869 
1870     protected static class FolderId {
1871         protected static final String IPF_NOTE = "IPF.Note";
1872         protected static final String IPF_CONTACT = "IPF.Contact";
1873         protected static final String IPF_APPOINTMENT = "IPF.Appointment";
1874         protected static final String IPF_TASK = "IPF.Task";
1875 
1876 
1877         protected String mailbox;
1878         protected String id;
1879         protected String parentFolderId;
1880         protected String folderClass;
1881 
1882         public FolderId() {
1883         }
1884 
1885         public FolderId(String mailbox, String id) {
1886             this.mailbox = mailbox;
1887             this.id = id;
1888         }
1889 
1890         public FolderId(String mailbox, String id, String folderClass) {
1891             this.mailbox = mailbox;
1892             this.id = id;
1893             this.folderClass = folderClass;
1894         }
1895 
1896         public FolderId(String mailbox, WellKnownFolderName wellKnownFolderName) {
1897             this.mailbox = mailbox;
1898             this.id = wellKnownFolderName.name();
1899         }
1900 
1901         public FolderId(String mailbox, WellKnownFolderName wellKnownFolderName, String folderClass) {
1902             this.mailbox = mailbox;
1903             this.id = wellKnownFolderName.name();
1904             this.folderClass = folderClass;
1905         }
1906 
1907         public String getMailboxName() {
1908             if (mailbox == null) {
1909                 return "me";
1910             } else {
1911                 return mailbox;
1912             }
1913         }
1914 
1915         public boolean isMail() {
1916             return IPF_NOTE.equals(folderClass);
1917         }
1918 
1919         public boolean isCalendar() {
1920             return IPF_APPOINTMENT.equals(folderClass);
1921         }
1922 
1923         public boolean isContact() {
1924             return IPF_CONTACT.equals(folderClass);
1925         }
1926 
1927         public boolean isTask() {
1928             return IPF_TASK.equals(folderClass);
1929         }
1930     }
1931 
1932     HttpClientAdapter httpClient;
1933     O365Token token;
1934 
1935     /**
1936      * Default folder properties list
1937      */
1938     protected static final HashSet<GraphField> FOLDER_PROPERTIES = new HashSet<>();
1939 
1940     static {
1941         // reference at https://learn.microsoft.com/en-us/graph/api/resources/mailfolder
1942         FOLDER_PROPERTIES.add(GraphField.get("folderlastmodified"));
1943         FOLDER_PROPERTIES.add(GraphField.get("folderclass"));
1944         FOLDER_PROPERTIES.add(GraphField.get("ctag"));
1945         FOLDER_PROPERTIES.add(GraphField.get("uidNext"));
1946     }
1947 
1948     public GraphExchangeSession(HttpClientAdapter httpClient, O365Token token, String userName) throws IOException {
1949         this.httpClient = httpClient;
1950         this.token = token;
1951         this.userName = userName;
1952 
1953         buildSessionInfo(httpClient.getUri());
1954     }
1955 
1956     @Override
1957     public void close() {
1958         httpClient.close();
1959     }
1960 
1961     /**
1962      * Format date to exchange search format.
1963      * TODO: review
1964      *
1965      * @param date date object
1966      * @return formatted search date
1967      */
1968     @Override
1969     public String formatSearchDate(Date date) {
1970         SimpleDateFormat dateFormatter = new SimpleDateFormat(YYYY_MM_DD_T_HHMMSS_Z, Locale.ENGLISH);
1971         dateFormatter.setTimeZone(GMT_TIMEZONE);
1972         return dateFormatter.format(date);
1973     }
1974 
1975     @Override
1976     protected void buildSessionInfo(URI uri) throws IOException {
1977         currentMailboxPath = "/users/" + userName.toLowerCase();
1978 
1979         // assume email is username
1980         email = userName;
1981         alias = userName.substring(0, email.indexOf("@"));
1982 
1983         LOGGER.debug("Current user email is " + email + ", alias is " + alias);
1984     }
1985 
1986     @Override
1987     public ExchangeSession.Message createMessage(String folderPath, String messageName, HashMap<String, String> properties, MimeMessage mimeMessage) throws IOException {
1988         byte[] mimeContent = IOUtil.encodeBase64(mimeMessage);
1989 
1990         // do we want created message to have draft flag?
1991         // draft is set for mapi PR_MESSAGE_FLAGS property combined with read flag by IMAPConnection, 8 means draft and 1 means read
1992         boolean isDraft = properties != null && ("8".equals(properties.get("draft")) || "9".equals(properties.get("draft")));
1993 
1994         // https://learn.microsoft.com/en-us/graph/api/user-post-messages
1995 
1996         FolderId folderId = getFolderId(folderPath);
1997 
1998         // create message in default drafts folder first
1999         GraphObject graphResponse = executeGraphRequest(new GraphRequestBuilder()
2000                 .setMethod(HttpPost.METHOD_NAME)
2001                 .setContentType("text/plain")
2002                 .setMimeContent(mimeContent)
2003                 .setChildType("messages"));
2004         if (isDraft) {
2005             try {
2006                 // we have the message in the right folder, apply flags
2007                 applyMessageProperties(graphResponse, properties);
2008                 graphResponse = executeGraphRequest(new GraphRequestBuilder()
2009                         .setMethod(HttpPatch.METHOD_NAME)
2010                         .setMailbox(folderId.mailbox)
2011                         .setObjectType("messages")
2012                         .setObjectId(graphResponse.optString("id"))
2013                         .setJsonBody(graphResponse.jsonObject));
2014 
2015                 graphResponse = executeGraphRequest(new GraphRequestBuilder().setMethod(HttpPost.METHOD_NAME)
2016                         .setMailbox(folderId.mailbox)
2017                         .setObjectType("messages")
2018                         .setObjectId(graphResponse.optString("id"))
2019                         .setChildType("move")
2020                         .setJsonBody(new JSONObject().put("destinationId", folderId.id)));
2021             } catch (JSONException e) {
2022                 throw new IOException(e);
2023             }
2024         } else {
2025             String draftMessageId = null;
2026             try {
2027                 // save draft message id
2028                 draftMessageId = graphResponse.getString("id");
2029 
2030                 // unset draft flag on returned draft message properties
2031                 graphResponse.put("messageFlags", "4");
2032                 // clear read flag by default
2033                 graphResponse.put("read", false);
2034                 applyMessageProperties(graphResponse, properties);
2035 
2036                 // now use this to recreate message in the right folder
2037                 graphResponse = executeGraphRequest(new GraphRequestBuilder()
2038                         .setMethod(HttpPost.METHOD_NAME)
2039                         .setMailbox(folderId.mailbox)
2040                         .setObjectType("mailFolders")
2041                         .setObjectId(folderId.id)
2042                         .setJsonBody(graphResponse.jsonObject)
2043                         .setChildType("messages"));
2044 
2045             } catch (JSONException e) {
2046                 throw new IOException(e);
2047             } finally {
2048                 // delete draft message
2049                 if (draftMessageId != null) {
2050                     executeJsonRequest(new GraphRequestBuilder()
2051                             .setMethod(HttpDelete.METHOD_NAME)
2052                             .setObjectType("messages")
2053                             .setObjectId(draftMessageId));
2054                 }
2055             }
2056 
2057         }
2058         return buildMessage(executeJsonRequest(new GraphRequestBuilder()
2059                 .setMethod(HttpGet.METHOD_NAME)
2060                 .setObjectType("messages")
2061                 .setMailbox(folderId.mailbox)
2062                 .setObjectId(graphResponse.optString("id"))
2063                 .setSelectFields(IMAP_MESSAGE_ATTRIBUTES)));
2064     }
2065 
2066     private void applyMessageProperties(GraphObject graphResponse, Map<String, String> properties) throws JSONException {
2067         if (properties != null) {
2068             for (Map.Entry<String, String> entry : properties.entrySet()) {
2069                 if ("read".equals(entry.getKey())) {
2070                     graphResponse.put(entry.getKey(), "1".equals(entry.getValue()));
2071                 } else if ("junk".equals(entry.getKey())) {
2072                     graphResponse.put(entry.getKey(), entry.getValue());
2073                 } else if ("flagged".equals(entry.getKey())) {
2074                     graphResponse.put("flagStatus", entry.getValue());
2075                 } else if ("answered".equals(entry.getKey())) {
2076                     graphResponse.put("lastVerbExecuted", entry.getValue());
2077                     if ("102".equals(entry.getValue())) {
2078                         graphResponse.put("iconIndex", "261");
2079                     }
2080                 } else if ("forwarded".equals(entry.getKey())) {
2081                     graphResponse.put("lastVerbExecuted", entry.getValue());
2082                     if ("104".equals(entry.getValue())) {
2083                         graphResponse.put("iconIndex", "262");
2084                     }
2085                 } else if ("deleted".equals(entry.getKey())) {
2086                     graphResponse.put(entry.getKey(), entry.getValue());
2087                 } else if ("datereceived".equals(entry.getKey())) {
2088                     graphResponse.put(entry.getKey(), entry.getValue());
2089                 } else if ("keywords".equals(entry.getKey())) {
2090                     graphResponse.setCategories(entry.getValue());
2091                 }
2092             }
2093         }
2094     }
2095 
2096     class Message extends ExchangeSession.Message {
2097         protected FolderId folderId;
2098         protected String id;
2099         protected String changeKey;
2100 
2101         @Override
2102         public String getPermanentId() {
2103             return id;
2104         }
2105 
2106         @Override
2107         protected InputStream getMimeHeaders() {
2108             InputStream result = null;
2109             try {
2110                 HashSet<GraphField> selectFields = new HashSet<>();
2111                 selectFields.add(GraphField.get("from"));
2112                 selectFields.add(GraphField.get("messageheaders"));
2113 
2114                 GraphObject graphResponse = new GraphObject(executeJsonRequest(new GraphRequestBuilder()
2115                         .setMethod(HttpGet.METHOD_NAME)
2116                         .setMailbox(folderId.mailbox)
2117                         .setObjectType("messages")
2118                         .setObjectId(id)
2119                         .setSelectFields(selectFields)));
2120 
2121                 String messageHeaders = graphResponse.optString("messageheaders");
2122 
2123                 // alternative: use parsed headers response.optJSONArray("internetMessageHeaders");
2124                 if (messageHeaders != null
2125                         // workaround for broken message headers on Exchange 2010
2126                         && messageHeaders.toLowerCase().contains("message-id:")) {
2127                     String from = graphResponse.optString("from");
2128                     // workaround for messages in Sent folder
2129                     if (from != null && !messageHeaders.contains("From:")) {
2130                         messageHeaders = "From: " + MimeUtility.encodeText(from, "UTF-8", null) + '\r' + '\n' + messageHeaders;
2131                     }
2132 
2133                     result = new ByteArrayInputStream(messageHeaders.getBytes(StandardCharsets.UTF_8));
2134                 }
2135             } catch (Exception e) {
2136                 LOGGER.warn(e.getMessage());
2137             }
2138 
2139             return result;
2140 
2141         }
2142     }
2143 
2144     private Message buildMessage(JSONObject response) {
2145         Message message = new Message();
2146         GraphObject graphResponse = new GraphObject(response);
2147 
2148         try {
2149             // get item id
2150             message.id = graphResponse.getString("id");
2151             message.changeKey = graphResponse.getString("changeKey");
2152 
2153             message.read = graphResponse.getBoolean("isRead");
2154             message.draft = graphResponse.getBoolean("isDraft");
2155             message.date = graphResponse.getString("receivedDateTime");
2156 
2157             String lastmodified = graphResponse.optString("lastModifiedDateTime");
2158             message.recent = !message.read && lastmodified != null && lastmodified.equals(message.date);
2159 
2160             message.keywords = graphResponse.optString("keywords");
2161 
2162         } catch (JSONException e) {
2163             LOGGER.warn("Error parsing message " + e.getMessage(), e);
2164         }
2165 
2166         JSONArray singleValueExtendedProperties = response.optJSONArray("singleValueExtendedProperties");
2167         if (singleValueExtendedProperties != null) {
2168             for (int i = 0; i < singleValueExtendedProperties.length(); i++) {
2169                 try {
2170                     JSONObject responseValue = singleValueExtendedProperties.getJSONObject(i);
2171                     String responseId = responseValue.optString("id");
2172                     if (GraphField.getGraphId("imapUid").equals(responseId)) {
2173                         message.imapUid = responseValue.getLong("value");
2174                     } else if (GraphField.getGraphId("messageSize").equals(responseId)) {
2175                         message.size = responseValue.getInt("value");
2176                     } else if (GraphField.getGraphId("uid").equals(responseId)) {
2177                         message.uid = responseValue.getString("value");
2178                     } else if (GraphField.getGraphId("permanenturl").equals(responseId)) {
2179                         message.permanentUrl = responseValue.getString("value"); // always null
2180                     } else if (GraphField.getGraphId("lastVerbExecuted").equals(responseId)) {
2181                         String lastVerbExecuted = responseValue.getString("value");
2182                         message.answered = "102".equals(lastVerbExecuted) || "103".equals(lastVerbExecuted);
2183                         message.forwarded = "104".equals(lastVerbExecuted);
2184                     } else if (GraphField.getGraphId("contentclass").equals(responseId)) {
2185                         message.contentClass = responseValue.getString("value");
2186                     } else if (GraphField.getGraphId("junk").equals(responseId)) {
2187                         message.junk = "1".equals(responseValue.getString("value"));
2188                     } else if (GraphField.getGraphId("flagStatus").equals(responseId)) {
2189                         message.flagged = "2".equals(responseValue.getString("value"));
2190                     } else if (GraphField.getGraphId("deleted").equals(responseId)) {
2191                         message.deleted = "1".equals(responseValue.getString("value"));
2192                     }
2193 
2194                 } catch (JSONException e) {
2195                     LOGGER.warn("Error parsing json response value");
2196                 }
2197             }
2198         }
2199 
2200         JSONArray multiValueExtendedProperties = response.optJSONArray("multiValueExtendedProperties");
2201         if (multiValueExtendedProperties != null) {
2202             for (int i = 0; i < multiValueExtendedProperties.length(); i++) {
2203                 try {
2204                     JSONObject responseValue = multiValueExtendedProperties.getJSONObject(i);
2205                     String responseId = responseValue.optString("id");
2206                     if (GraphField.get("keywords").getGraphId().equals(responseId)) {
2207                         JSONArray keywordsJsonArray = responseValue.getJSONArray("value");
2208                         HashSet<String> keywords = new HashSet<>();
2209                         for (int j = 0; j < keywordsJsonArray.length(); j++) {
2210                             keywords.add(keywordsJsonArray.getString(j));
2211                         }
2212                         message.keywords = StringUtil.join(keywords, ",");
2213                     }
2214 
2215                 } catch (JSONException e) {
2216                     LOGGER.warn("Error parsing json response value");
2217                 }
2218             }
2219         }
2220 
2221         if (LOGGER.isDebugEnabled()) {
2222             StringBuilder buffer = new StringBuilder();
2223             buffer.append("Message");
2224             if (message.imapUid != 0) {
2225                 buffer.append(" IMAP uid: ").append(message.imapUid);
2226             }
2227             if (message.uid != null) {
2228                 buffer.append(" uid: ").append(message.uid);
2229             }
2230             buffer.append(" ItemId: ").append(message.id);
2231             buffer.append(" ChangeKey: ").append(message.changeKey);
2232             LOGGER.debug(buffer.toString());
2233         }
2234 
2235         return message;
2236 
2237     }
2238 
2239     /**
2240      * Lightweight conversion method to avoid full string to date and back conversions.
2241      * Note: Duplicate from EWSEchangeSession, added nanosecond handling
2242      * See <a href="https://learn.microsoft.com/en-us/graph/api/resources/datetimetimezone">datetimetimezone</a>
2243      * @param exchangeDateValue date returned from O365
2244      * @return converted date
2245      * @throws DavMailException on error
2246      */
2247     protected static String convertDateFromExchange(String exchangeDateValue) throws DavMailException {
2248         // yyyy-MM-dd'T'HH:mm:ss'Z' to yyyyMMdd'T'HHmmss'Z'
2249         if (exchangeDateValue == null) {
2250             return null;
2251         } else {
2252             StringBuilder buffer = new StringBuilder();
2253             if (exchangeDateValue.length() >= 21 || exchangeDateValue.length() == 20 || exchangeDateValue.length() == 10) {
2254                 for (int i = 0; i < exchangeDateValue.length(); i++) {
2255                     // skip '-' and ':'
2256                     if (i == 4 || i == 7 || i == 13 || i == 16) {
2257                         i++;
2258                     }
2259                     if (i == 19) {
2260                         // optional append Zulu tag
2261                         if (exchangeDateValue.endsWith("Z")) {
2262                             buffer.append('Z');
2263                         }
2264                         break;
2265                     } else {
2266                         buffer.append(exchangeDateValue.charAt(i));
2267                     }
2268                 }
2269                 if (exchangeDateValue.length() == 10) {
2270                     buffer.append("T000000Z");
2271                 }
2272             } else {
2273                 throw new DavMailException("EXCEPTION_INVALID_DATE", exchangeDateValue);
2274             }
2275             return buffer.toString();
2276         }
2277     }
2278 
2279     @Override
2280     public void updateMessage(ExchangeSession.Message message, Map<String, String> properties) throws IOException {
2281         try {
2282             GraphObject graphObject = new GraphObject(new JSONObject());
2283             // we have the message in the right folder, apply flags
2284             applyMessageProperties(graphObject, properties);
2285             try {
2286                 executeJsonRequest(new GraphRequestBuilder()
2287                         .setMethod(HttpPatch.METHOD_NAME)
2288                         .setMailbox(((Message) message).folderId.mailbox)
2289                         .setObjectType("messages")
2290                         .setObjectId(((Message) message).id)
2291                         .setJsonBody(graphObject.jsonObject));
2292             } catch (HttpPreconditionFailedException e) {
2293                 LOGGER.debug("Received HTTP 412 Precondition Failed");
2294 
2295                 // this will trigger an HttpNotFoundException if message was deleted
2296                 executeJsonRequest(new GraphRequestBuilder()
2297                         .setMethod(HttpGet.METHOD_NAME)
2298                         .setMailbox(((Message) message).folderId.mailbox)
2299                         .setObjectType("messages")
2300                         .setObjectId(((Message) message).id)
2301                         .setSelect("id"));
2302 
2303                 // retry once
2304                 executeJsonRequest(new GraphRequestBuilder()
2305                         .setMethod(HttpPatch.METHOD_NAME)
2306                         .setMailbox(((Message) message).folderId.mailbox)
2307                         .setObjectType("messages")
2308                         .setObjectId(((Message) message).id)
2309                         .setJsonBody(graphObject.jsonObject));
2310 
2311             }
2312         } catch (JSONException e) {
2313             throw new IOException(e);
2314         }
2315     }
2316 
2317     @Override
2318     public void deleteMessage(ExchangeSession.Message message) throws IOException {
2319         executeJsonRequest(new GraphRequestBuilder()
2320                 .setMethod(HttpDelete.METHOD_NAME)
2321                 .setMailbox(((Message) message).folderId.mailbox)
2322                 .setObjectType("messages")
2323                 .setObjectId(((Message) message).id));
2324     }
2325 
2326     @Override
2327     protected byte[] getContent(ExchangeSession.Message message) throws IOException {
2328         GraphRequestBuilder graphRequestBuilder = new GraphRequestBuilder()
2329                 .setMethod(HttpGet.METHOD_NAME)
2330                 .setMailbox(((Message) message).folderId.mailbox)
2331                 .setObjectType("messages")
2332                 .setObjectId(message.getPermanentId())
2333                 .setChildType("$value")
2334                 .setAccessToken(token.getAccessToken());
2335 
2336         // Load MIME content from $value endpoint
2337         byte[] mimeContent;
2338         try (
2339                 CloseableHttpResponse response = httpClient.execute(graphRequestBuilder.build());
2340                 InputStream inputStream = response.getEntity().getContent()
2341         ) {
2342             // wrap inputstream to log progress
2343             FilterInputStream filterInputStream = new FilterInputStream(inputStream) {
2344                 int totalCount;
2345                 int lastLogCount;
2346 
2347                 @Override
2348                 public int read(byte[] buffer, int offset, int length) throws IOException {
2349                     int count = super.read(buffer, offset, length);
2350                     totalCount += count;
2351                     if (totalCount - lastLogCount > 1024 * 128) {
2352                         DavGatewayTray.debug(new BundleMessage("LOG_DOWNLOAD_PROGRESS", String.valueOf(totalCount / 1024), message.getPermanentId()));
2353                         DavGatewayTray.switchIcon();
2354                         lastLogCount = totalCount;
2355                     }
2356                     /*if (count > 0 && LOGGER.isDebugEnabled()) {
2357                         LOGGER.debug(new String(buffer, offset, count, "UTF-8"));
2358                     }*/
2359                     return count;
2360                 }
2361             };
2362             if (HttpClientAdapter.isGzipEncoded(response)) {
2363                 mimeContent = IOUtil.readFully(new GZIPInputStream(filterInputStream));
2364             } else {
2365                 mimeContent = IOUtil.readFully(filterInputStream);
2366             }
2367         }
2368         return mimeContent;
2369     }
2370 
2371     @Override
2372     public MessageList searchMessages(String folderName, Set<String> attributes, Condition condition) throws IOException {
2373         MessageList messageList = new MessageList();
2374         FolderId folderId = getFolderId(folderName);
2375 
2376         GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder()
2377                 .setMethod(HttpGet.METHOD_NAME)
2378                 .setMailbox(folderId.mailbox)
2379                 .setObjectType("mailFolders")
2380                 .setObjectId(folderId.id)
2381                 .setChildType("messages")
2382                 .setSelectFields(IMAP_MESSAGE_ATTRIBUTES)
2383                 .setFilter(condition);
2384         int maxCount = Settings.getIntProperty("davmail.folderSizeLimit", 0);
2385         if (maxCount == 0) {
2386             maxCount = Integer.MAX_VALUE;
2387         }
2388         // set paging to folderFetchPageSize
2389         httpRequestBuilder.setSizeLimit(Math.min(maxCount,
2390                 Settings.getIntProperty("davmail.folderFetchPageSize", PAGE_SIZE)
2391                 ));
2392 
2393         LOGGER.debug("searchMessages " + folderId.getMailboxName() + " " + folderName);
2394         GraphIterator graphIterator = executeSearchRequest(httpRequestBuilder);
2395 
2396         while (graphIterator.hasNext() && messageList.size() < maxCount) {
2397             Message message = buildMessage(graphIterator.next());
2398             message.messageList = messageList;
2399             message.folderId = folderId;
2400             messageList.add(message);
2401         }
2402         Collections.sort(messageList);
2403         return messageList;
2404     }
2405 
2406     static class AttributeCondition extends ExchangeSession.AttributeCondition {
2407 
2408         protected AttributeCondition(String attributeName, Operator operator, String value) {
2409             super(attributeName, operator, value);
2410         }
2411 
2412         protected Operator getOperator() {
2413             return operator;
2414         }
2415 
2416         protected GraphField getField() {
2417             GraphField fieldURI = GraphField.get(attributeName);
2418             // check to detect broken field mapping
2419             //noinspection ConstantConditions
2420             if (fieldURI == null) {
2421                 throw new IllegalArgumentException("Unknown field: " + attributeName);
2422             }
2423             return fieldURI;
2424         }
2425 
2426         private String convertOperator(Operator operator) {
2427             if (Operator.IsEqualTo.equals(operator)) {
2428                 return "eq";
2429             } else if (Operator.IsGreaterThan.equals(operator)) {
2430                 return "gt";
2431             } else if (Operator.IsGreaterThanOrEqualTo.equals(operator)) {
2432                 return "ge";
2433             } else if (Operator.IsLessThan.equals(operator)) {
2434                 return "lt";
2435             } else if (Operator.IsLessThanOrEqualTo.equals(operator)) {
2436                 return "le";
2437             } else {
2438                 LOGGER.warn("Unsupported operator: " + operator + ", switch to equals");
2439                 return "eq";
2440             }
2441         }
2442 
2443         @Override
2444         public void appendTo(StringBuilder buffer) {
2445             GraphField field = getField();
2446             String graphId = field.getGraphId();
2447             if (field.isExtended()) {
2448                 if (field.isInternetHeaders()) {
2449                     // header search does not work over graph, try to match full internet headers
2450                     buffer.append("singleValueExtendedProperties/any(ep:ep/id eq 'String 0x007D' and contains(ep/value, '")
2451                             .append(attributeName).append(": ").append(StringUtil.escapeQuotes(value)).append("'))");
2452                 } else if (field.isNumber()) {
2453                     // check value
2454                     int intValue = 0;
2455                     try {
2456                         intValue = Integer.parseInt(value);
2457                     } catch (NumberFormatException e) {
2458                         // invalid value, replace with 0
2459                         LOGGER.warn("Invalid integer value for " + graphId + " " + value);
2460                     }
2461                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphId)
2462                             .append("' and cast(ep/value, Edm.Int32) ").append(convertOperator(operator)).append(" ").append(intValue).append(")");
2463                 } else if (Operator.Contains.equals(operator)) {
2464                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphId)
2465                             .append("' and contains(ep/value,'").append(StringUtil.escapeQuotes(value)).append("'))");
2466                 } else if (Operator.StartsWith.equals(operator)) {
2467                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphId)
2468                             .append("' and startswith(ep/value,'").append(StringUtil.escapeQuotes(value)).append("'))");
2469                 } else if (field.isBinary()) {
2470                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphId)
2471                             .append("' and cast(ep/value,Edm.Binary) ").append(convertOperator(operator)).append(" binary'").append(StringUtil.escapeQuotes(value)).append("')");
2472                 } else if (field.isDate()) {
2473                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphId)
2474                             .append("' and cast(ep/value,Edm.DateTimeOffset) ").append(convertOperator(operator)).append(" datetimeoffset'").append(StringUtil.escapeQuotes(value)).append("')");
2475                 } else if (field.isBoolean()) {
2476                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphId)
2477                             .append("' and cast(ep/value,Edm.Boolean) ").append(convertOperator(operator)).append(" ").append(value).append(")");
2478                 } else {
2479                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphId)
2480                             .append("' and ep/value ").append(convertOperator(operator)).append(" '").append(StringUtil.escapeQuotes(value)).append("')");
2481                 }
2482             } else if (field.isMultiValued()) {
2483                 buffer.append(graphId).append("/any(a:a ").append(convertOperator(operator)).append(" '").append(StringUtil.escapeQuotes(value)).append("')");
2484             } else if ("body".equals(graphId)) {
2485                 // only contains supported for body
2486                 buffer.append("contains(").append(graphId).append("/content,'").append(StringUtil.escapeQuotes(value)).append("')");
2487             } else if (Operator.Contains.equals(operator)) {
2488                 // search graph property
2489                 buffer.append("contains(").append(graphId).append(",'").append(StringUtil.escapeQuotes(value)).append("')");
2490             } else if (Operator.StartsWith.equals(operator)) {
2491                 buffer.append("startswith(").append(graphId).append(",'").append(StringUtil.escapeQuotes(value)).append("')");
2492             } else if (field.isDate() || field.isBoolean()) {
2493                 buffer.append(graphId).append(" ").append(convertOperator(operator)).append(" ").append(value);
2494             } else if ("start".equals(graphId) || "end".equals(graphId)) { // TODO check date value
2495                 buffer.append(graphId).append("/dateTime ").append(convertOperator(operator)).append(" '").append(StringUtil.escapeQuotes(value)).append("'");
2496             } else {
2497                 buffer.append(graphId).append(" ").append(convertOperator(operator)).append(" '").append(StringUtil.escapeQuotes(value)).append("'");
2498             }
2499         }
2500 
2501         @Override
2502         public boolean isMatch(ExchangeSession.Contact contact) {
2503             return false;
2504         }
2505     }
2506 
2507     protected static class HeaderCondition extends AttributeCondition {
2508 
2509         protected HeaderCondition(String attributeName, String value) {
2510             super(attributeName, Operator.Contains, value);
2511         }
2512 
2513         @Override
2514         protected GraphField getField() {
2515             return new GraphField(attributeName, GraphField.DistinguishedPropertySetType.InternetHeaders, attributeName);
2516         }
2517 
2518         /**
2519          * Graph field internetMessageHeader is not searchable, use MAPI property PR_TRANSPORT_MESSAGE_HEADERS directly instead.
2520          * @param buffer search filter buffer
2521          */
2522         public void appendTo(StringBuilder buffer) {
2523             buffer.append("singleValueExtendedProperties/any(ep:ep/id eq 'String 0x007D' and contains(ep/value, '")
2524                     .append(attributeName).append(": ").append(StringUtil.escapeQuotes(value)).append("'))");
2525         }
2526     }
2527 
2528     protected static class IsNullCondition implements ExchangeSession.Condition, SearchExpression {
2529         protected final String attributeName;
2530 
2531         protected IsNullCondition(String attributeName) {
2532             this.attributeName = attributeName;
2533         }
2534 
2535         public void appendTo(StringBuilder buffer) {
2536             GraphField graphField = GraphField.get(attributeName);
2537             if (graphField.isExtended()) {
2538                 if (graphField.isNumber()) {
2539                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphField.getGraphId())
2540                             .append("' and cast(ep/value, Edm.Int32) eq null)");
2541                 } else if (graphField.isBoolean()) {
2542                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphField.getGraphId())
2543                             .append("' and cast(ep/value, Edm.Boolean) eq null)");
2544                 } else {
2545                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphField.getGraphId())
2546                             .append("' and ep/value eq null)");
2547                 }
2548             } else {
2549                 buffer.append(graphField.getGraphId()).append(" eq null");
2550             }
2551         }
2552 
2553         public boolean isEmpty() {
2554             return false;
2555         }
2556 
2557         public boolean isMatch(ExchangeSession.Contact contact) {
2558             String actualValue = contact.get(attributeName);
2559             return actualValue == null;
2560         }
2561 
2562     }
2563 
2564     protected static class ExistsCondition implements ExchangeSession.Condition, SearchExpression {
2565         protected final String attributeName;
2566 
2567         protected ExistsCondition(String attributeName) {
2568             this.attributeName = attributeName;
2569         }
2570 
2571         public void appendTo(StringBuilder buffer) {
2572             buffer.append(GraphField.get(attributeName).getGraphId()).append(" ne null");
2573         }
2574 
2575         public boolean isEmpty() {
2576             return false;
2577         }
2578 
2579         public boolean isMatch(ExchangeSession.Contact contact) {
2580             String actualValue = contact.get(attributeName);
2581             return actualValue != null;
2582         }
2583 
2584     }
2585 
2586 
2587     static class MultiCondition extends ExchangeSession.MultiCondition {
2588 
2589         protected MultiCondition(Operator operator, Condition... conditions) {
2590             super(operator, conditions);
2591         }
2592 
2593         @Override
2594         public void appendTo(StringBuilder buffer) {
2595             int actualConditionCount = 0;
2596             for (Condition condition : conditions) {
2597                 if (!condition.isEmpty()) {
2598                     actualConditionCount++;
2599                 }
2600             }
2601             if (actualConditionCount > 0) {
2602                 boolean isFirst = true;
2603 
2604                 for (Condition condition : conditions) {
2605                     if (isFirst) {
2606                         isFirst = false;
2607 
2608                     } else {
2609                         buffer.append(" ").append(operator.toString()).append(" ");
2610                     }
2611                     if (condition instanceof MultiCondition) {
2612                         buffer.append("(");
2613                         condition.appendTo(buffer);
2614                         buffer.append(")");
2615                     } else {
2616                         condition.appendTo(buffer);
2617                     }
2618                 }
2619             }
2620         }
2621     }
2622 
2623     static class NotCondition extends ExchangeSession.NotCondition {
2624 
2625         protected NotCondition(Condition condition) {
2626             super(condition);
2627         }
2628 
2629         @Override
2630         public void appendTo(StringBuilder buffer) {
2631             buffer.append("not (");
2632             condition.appendTo(buffer);
2633             buffer.append(")");
2634         }
2635     }
2636 
2637     @Override
2638     public ExchangeSession.MultiCondition and(Condition... conditions) {
2639         return new MultiCondition(Operator.And, conditions);
2640     }
2641 
2642     @Override
2643     public ExchangeSession.MultiCondition or(Condition... conditions) {
2644         return new MultiCondition(Operator.Or, conditions);
2645     }
2646 
2647     @Override
2648     public Condition not(Condition condition) {
2649         return new NotCondition(condition);
2650     }
2651 
2652     @Override
2653     public Condition isEqualTo(String attributeName, String value) {
2654         return new AttributeCondition(attributeName, Operator.IsEqualTo, value);
2655     }
2656 
2657     @Override
2658     public Condition isEqualTo(String attributeName, int value) {
2659         return new AttributeCondition(attributeName, Operator.IsEqualTo, String.valueOf(value));
2660     }
2661 
2662     @Override
2663     public Condition headerIsEqualTo(String headerName, String value) {
2664         return new HeaderCondition(headerName, value);
2665     }
2666 
2667     @Override
2668     public Condition gte(String attributeName, String value) {
2669         return new AttributeCondition(attributeName, Operator.IsGreaterThanOrEqualTo, value);
2670     }
2671 
2672     @Override
2673     public Condition gt(String attributeName, String value) {
2674         return new AttributeCondition(attributeName, Operator.IsGreaterThan, value);
2675     }
2676 
2677     @Override
2678     public Condition lt(String attributeName, String value) {
2679         return new AttributeCondition(attributeName, Operator.IsLessThan, value);
2680     }
2681 
2682     @Override
2683     public Condition lte(String attributeName, String value) {
2684         return new AttributeCondition(attributeName, Operator.IsLessThanOrEqualTo, value);
2685     }
2686 
2687     @Override
2688     public Condition contains(String attributeName, String value) {
2689         return new AttributeCondition(attributeName, Operator.Contains, value);
2690     }
2691 
2692     @Override
2693     public Condition startsWith(String attributeName, String value) {
2694         return new AttributeCondition(attributeName, Operator.StartsWith, value);
2695     }
2696 
2697     @Override
2698     public Condition isNull(String attributeName) {
2699         return new IsNullCondition(attributeName);
2700     }
2701 
2702     @Override
2703     public Condition exists(String attributeName) {
2704         return new ExistsCondition(attributeName);
2705     }
2706 
2707     @Override
2708     public Condition isTrue(String attributeName) {
2709         return new AttributeCondition(attributeName, Operator.IsEqualTo, "true");
2710     }
2711 
2712     @Override
2713     public Condition isFalse(String attributeName) {
2714         return new AttributeCondition(attributeName, Operator.IsEqualTo, "false");
2715     }
2716 
2717     @Override
2718     public List<ExchangeSession.Folder> getSubCalendarFolders(String folderName, boolean recursive) throws IOException {
2719         GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder();
2720         // search calendars, ignore condition
2721         httpRequestBuilder.setMethod(HttpGet.METHOD_NAME)
2722                 .setObjectType("calendars")
2723                 .setSelectFields(FOLDER_PROPERTIES);
2724 
2725         GraphIterator graphIterator = executeSearchRequest(httpRequestBuilder);
2726         List<ExchangeSession.Folder> folders = new ArrayList<>();
2727         while (graphIterator.hasNext()) {
2728             Folder folder = buildFolder(graphIterator.next());
2729             folder.folderPath = folder.displayName;
2730             if (!folder.isDefaultCalendar) {
2731                 folders.add(folder);
2732             }
2733         }
2734         return folders;
2735     }
2736 
2737     @Override
2738     public List<ExchangeSession.Folder> getSubFolders(String folderPath, Condition condition, boolean recursive) throws IOException {
2739 
2740         List<ExchangeSession.Folder> folders = new ArrayList<>();
2741 
2742         appendSubFolders(folders, getSubfolderPath(folderPath), getFolderId(folderPath), condition, recursive);
2743         return folders;
2744     }
2745 
2746     protected void appendSubFolders(List<ExchangeSession.Folder> folders,
2747                                     String parentFolderPath, FolderId parentFolderId,
2748                                     Condition condition, boolean recursive) throws IOException {
2749         LOGGER.debug("appendSubFolders " + (parentFolderId.mailbox != null ? parentFolderId.mailbox : "me") + " " + parentFolderPath);
2750 
2751         GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder()
2752                 .setMethod(HttpGet.METHOD_NAME)
2753                 .setObjectType("mailFolders")
2754                 .setMailbox(parentFolderId.mailbox)
2755                 .setObjectId(parentFolderId.id)
2756                 .setChildType("childFolders")
2757                 .setSelectFields(FOLDER_PROPERTIES)
2758                 .setFilter(condition);
2759 
2760         GraphIterator graphIterator = executeSearchRequest(httpRequestBuilder);
2761 
2762         while (graphIterator.hasNext()) {
2763             Folder folder = buildFolder(graphIterator.next());
2764             folder.folderId.mailbox = parentFolderId.mailbox;
2765             // check parentFolder
2766             if (parentFolderId.id.equals(folder.folderId.parentFolderId)) {
2767                 if (!parentFolderPath.isEmpty()) {
2768                     if (parentFolderPath.endsWith("/")) {
2769                         folder.folderPath = parentFolderPath + folder.displayName;
2770                     } else {
2771                         folder.folderPath = parentFolderPath + '/' + folder.displayName;
2772                     }
2773                     // TODO folderIdMap?
2774                 } else {
2775                     folder.folderPath = folder.displayName;
2776                 }
2777                 folders.add(folder);
2778                 if (recursive && folder.hasChildren) {
2779                     appendSubFolders(folders, folder.folderPath, folder.folderId, condition, true);
2780                 }
2781             } else {
2782                 LOGGER.debug("appendSubFolders skip " + folder.folderId.mailbox + " " + folder.folderId.id + " " + folder.displayName + " not a child of " + parentFolderPath);
2783             }
2784         }
2785 
2786     }
2787 
2788 
2789     @Override
2790     public void sendMessage(MimeMessage mimeMessage) throws IOException, MessagingException {
2791         // https://learn.microsoft.com/en-us/graph/api/user-sendmail
2792         executeJsonRequest(new GraphRequestBuilder()
2793                 .setMethod(HttpPost.METHOD_NAME)
2794                 .setObjectType("sendMail")
2795                 .setContentType("text/plain")
2796                 .setMimeContent(IOUtil.encodeBase64(mimeMessage)));
2797     }
2798 
2799     public void sendMessage(byte[] byteArray) throws IOException {
2800         // https://learn.microsoft.com/en-us/graph/api/user-sendmail
2801         executeJsonRequest(new GraphRequestBuilder()
2802                 .setMethod(HttpPost.METHOD_NAME)
2803                 .setObjectType("sendMail")
2804                 .setContentType("text/plain")
2805                 .setMimeContent(IOUtil.encodeBase64(byteArray)));
2806     }
2807 
2808     @Override
2809     protected Folder internalGetFolder(String folderPath) throws IOException {
2810         FolderId folderId = getFolderId(folderPath);
2811 
2812         // base folder get https://graph.microsoft.com/v1.0/me/mailFolders/inbox
2813         GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder()
2814                 .setMethod(HttpGet.METHOD_NAME)
2815                 .setMailbox(folderId.mailbox)
2816                 .setObjectId(folderId.id);
2817         if (folderId.isCalendar()) {
2818             httpRequestBuilder
2819                     .setSelectFields(FOLDER_PROPERTIES)
2820                     .setObjectType("calendars");
2821         } else if (folderId.isTask()) {
2822             httpRequestBuilder.setObjectType("todo/lists");
2823         } else if (folderId.isContact()) {
2824             httpRequestBuilder
2825                     .setSelectFields(FOLDER_PROPERTIES)
2826                     .setObjectType("contactFolders");
2827         } else {
2828             httpRequestBuilder
2829                     .setSelectFields(FOLDER_PROPERTIES)
2830                     .setObjectType("mailFolders");
2831         }
2832 
2833         JSONObject jsonResponse = executeJsonRequest(httpRequestBuilder);
2834 
2835         Folder folder = buildFolder(jsonResponse);
2836         folder.folderPath = folderPath;
2837 
2838         return folder;
2839     }
2840 
2841     private Folder buildFolder(JSONObject jsonResponse) throws IOException {
2842         try {
2843             Folder folder = new Folder();
2844             folder.folderId = new FolderId();
2845             folder.folderId.id = jsonResponse.getString("id");
2846             folder.folderId.parentFolderId = jsonResponse.optString("parentFolderId", null);
2847             if (folder.folderId.parentFolderId == null) {
2848                 // calendar
2849                 folder.displayName = StringUtil.encodeFolderName(jsonResponse.optString("name"));
2850                 folder.isDefaultCalendar = jsonResponse.optBoolean("isDefaultCalendar");
2851             } else {
2852                 String wellKnownName = wellKnownFolderMap.get(jsonResponse.optString("wellKnownName"));
2853                 if (ExchangeSession.INBOX.equals(wellKnownName)) {
2854                     folder.displayName = wellKnownName;
2855                 } else {
2856                     if (wellKnownName != null) {
2857                         folder.setSpecialFlag(wellKnownName);
2858                     }
2859 
2860                     folder.displayName = StringUtil.encodeFolderName(jsonResponse.getString("displayName"));
2861                 }
2862 
2863                 folder.messageCount = jsonResponse.optInt("totalItemCount");
2864                 folder.unreadCount = jsonResponse.optInt("unreadItemCount");
2865                 // fake recent value
2866                 folder.recent = folder.unreadCount;
2867                 // hassubs computed from childFolderCount
2868                 folder.hasChildren = jsonResponse.optInt("childFolderCount") > 0;
2869             }
2870 
2871             // retrieve property values
2872             JSONArray singleValueExtendedProperties = jsonResponse.optJSONArray("singleValueExtendedProperties");
2873             if (singleValueExtendedProperties != null) {
2874                 for (int i = 0; i < singleValueExtendedProperties.length(); i++) {
2875                     JSONObject singleValueProperty = singleValueExtendedProperties.getJSONObject(i);
2876                     String singleValueId = singleValueProperty.getString("id");
2877                     String singleValue = singleValueProperty.getString("value");
2878                     if (GraphField.get("folderlastmodified").getGraphId().equals(singleValueId)) {
2879                         folder.etag = singleValue;
2880                     } else if (GraphField.get("folderclass").getGraphId().equals(singleValueId)) {
2881                         folder.folderClass = singleValue;
2882                         folder.folderId.folderClass = folder.folderClass;
2883                     } else if (GraphField.get("uidNext").getGraphId().equals(singleValueId)) {
2884                         folder.uidNext = Long.parseLong(singleValue);
2885                     } else if (GraphField.get("ctag").getGraphId().equals(singleValueId)) {
2886                         folder.ctag = singleValue;
2887                     }
2888 
2889                 }
2890             }
2891 
2892             return folder;
2893         } catch (JSONException e) {
2894             throw new IOException(e.getMessage(), e);
2895         }
2896     }
2897 
2898     /**
2899      * Compute folderId from folderName
2900      * @param folderPath folder name (path)
2901      * @return folder id
2902      */
2903     private FolderId getFolderId(String folderPath) throws IOException {
2904         FolderId folderId = getFolderIdIfExists(folderPath);
2905         if (folderId == null) {
2906             throw new HttpNotFoundException("Folder '" + folderPath + "' not found");
2907         }
2908         return folderId;
2909     }
2910 
2911     protected static final String USERS_ROOT = "/users/";
2912     protected static final String ARCHIVE_ROOT = "/archive/";
2913 
2914 
2915     private FolderId getFolderIdIfExists(String folderPath) throws IOException {
2916         String lowerCaseFolderPath = folderPath.toLowerCase();
2917         if (lowerCaseFolderPath.equals(currentMailboxPath)) {
2918             return getSubFolderIdIfExists(null, "");
2919         } else if (lowerCaseFolderPath.startsWith(currentMailboxPath + '/')) {
2920             return getSubFolderIdIfExists(null, folderPath.substring(currentMailboxPath.length() + 1));
2921         } else if (folderPath.startsWith(USERS_ROOT)) {
2922             int slashIndex = folderPath.indexOf('/', USERS_ROOT.length());
2923             String mailbox;
2924             String subFolderPath;
2925             if (slashIndex >= 0) {
2926                 mailbox = folderPath.substring(USERS_ROOT.length(), slashIndex);
2927                 subFolderPath = folderPath.substring(slashIndex + 1);
2928             } else {
2929                 mailbox = folderPath.substring(USERS_ROOT.length());
2930                 subFolderPath = "";
2931             }
2932             return getSubFolderIdIfExists(mailbox, subFolderPath);
2933         } else {
2934             return getSubFolderIdIfExists(null, folderPath);
2935         }
2936     }
2937 
2938     private FolderId getSubFolderIdIfExists(String mailbox, String folderPath) throws IOException {
2939         String[] folderNames;
2940         FolderId currentFolderId;
2941 
2942         // TODO test various use cases
2943         if ("/public".equals(folderPath)) {
2944             throw new UnsupportedOperationException("public folders not supported on Graph");
2945         } else if ("/archive".equals(folderPath)) {
2946             return getWellKnownFolderId(mailbox, WellKnownFolderName.archive);
2947         } else if (isSubFolderOf(folderPath, PUBLIC_ROOT)) {
2948             throw new UnsupportedOperationException("public folders not supported on Graph");
2949         } else if (isSubFolderOf(folderPath, ARCHIVE_ROOT)) {
2950             currentFolderId = getWellKnownFolderId(mailbox, WellKnownFolderName.archive);
2951             folderNames = folderPath.substring(ARCHIVE_ROOT.length()).split("/");
2952         } else if (isSubFolderOf(folderPath, INBOX) ||
2953                 isSubFolderOf(folderPath, LOWER_CASE_INBOX) ||
2954                 isSubFolderOf(folderPath, MIXED_CASE_INBOX)) {
2955             currentFolderId = getWellKnownFolderId(mailbox, WellKnownFolderName.inbox);
2956             folderNames = folderPath.substring(INBOX.length()).split("/");
2957         } else if (isSubFolderOf(folderPath, CALENDAR)) {
2958             currentFolderId = new FolderId(mailbox, WellKnownFolderName.calendar, FolderId.IPF_APPOINTMENT);
2959             // TODO subfolders not supported with graph
2960             folderNames = folderPath.substring(CALENDAR.length()).split("/");
2961         } else if (isSubFolderOf(folderPath, TASKS)) {
2962             currentFolderId = getWellKnownFolderId(mailbox, WellKnownFolderName.tasks);
2963             folderNames = folderPath.substring(TASKS.length()).split("/");
2964         } else if (isSubFolderOf(folderPath, CONTACTS)) {
2965             currentFolderId = new FolderId(mailbox, WellKnownFolderName.contacts, FolderId.IPF_CONTACT);
2966             folderNames = folderPath.substring(CONTACTS.length()).split("/");
2967         } else if (isSubFolderOf(folderPath, SENT)) {
2968             currentFolderId = new FolderId(mailbox, WellKnownFolderName.sentitems);
2969             folderNames = folderPath.substring(SENT.length()).split("/");
2970         } else if (isSubFolderOf(folderPath, DRAFTS)) {
2971             currentFolderId = new FolderId(mailbox, WellKnownFolderName.drafts);
2972             folderNames = folderPath.substring(DRAFTS.length()).split("/");
2973         } else if (isSubFolderOf(folderPath, TRASH)) {
2974             currentFolderId = new FolderId(mailbox, WellKnownFolderName.deleteditems);
2975             folderNames = folderPath.substring(TRASH.length()).split("/");
2976         } else if (isSubFolderOf(folderPath, JUNK)) {
2977             currentFolderId = new FolderId(mailbox, WellKnownFolderName.junkemail);
2978             folderNames = folderPath.substring(JUNK.length()).split("/");
2979         } else if (isSubFolderOf(folderPath, UNSENT)) {
2980             currentFolderId = new FolderId(mailbox, WellKnownFolderName.outbox);
2981             folderNames = folderPath.substring(UNSENT.length()).split("/");
2982         } else {
2983             currentFolderId = getWellKnownFolderId(mailbox, WellKnownFolderName.msgfolderroot);
2984             folderNames = folderPath.split("/");
2985         }
2986         String folderClass = currentFolderId.folderClass;
2987         for (String folderName : folderNames) {
2988             if (!folderName.isEmpty()) {
2989                 currentFolderId = getSubFolderByName(currentFolderId, folderName);
2990                 if (currentFolderId == null) {
2991                     break;
2992                 }
2993                 currentFolderId.folderClass = folderClass;
2994             }
2995         }
2996         return currentFolderId;
2997     }
2998 
2999     protected HashMap<String, FolderId> folderIdCache = new HashMap<>();
3000 
3001     /**
3002      * Build folderId for well-known folders.
3003      * Set EWS folderClass values according to: <a href="https://learn.microsoft.com/en-us/exchange/client-developer/exchange-web-services/folders-and-items-in-ews-in-exchange">...</a>
3004      * @param mailbox user mailbox
3005      * @param wellKnownFolderName well-known value
3006      * @return folderId
3007      * @throws IOException on error
3008      */
3009     private FolderId getWellKnownFolderId(String mailbox, WellKnownFolderName wellKnownFolderName) throws IOException {
3010         FolderId wellKnownFolderId = null;
3011         if (mailbox == null && folderIdCache.containsKey(wellKnownFolderName.name())) {
3012             // get folder id from cache
3013             wellKnownFolderId = folderIdCache.get(wellKnownFolderName.name());
3014         } else if (wellKnownFolderName == WellKnownFolderName.tasks) {
3015             // retrieve folder id from todo endpoint
3016             GraphIterator graphIterator = executeSearchRequest(new GraphRequestBuilder()
3017                     .setMethod(HttpGet.METHOD_NAME)
3018                     .setMailbox(mailbox)
3019                     .setObjectType("todo/lists"));
3020             while (graphIterator.hasNext()) {
3021                 JSONObject jsonResponse = graphIterator.next();
3022                 if (jsonResponse.optString("wellknownListName").equals("defaultList")) {
3023                     wellKnownFolderId = new FolderId(mailbox, jsonResponse.optString("id"), FolderId.IPF_TASK);
3024                 }
3025             }
3026             // should not happen
3027             if (wellKnownFolderId == null) {
3028                 throw new HttpNotFoundException("Folder '" + wellKnownFolderName.name() + "' not found");
3029             }
3030 
3031         } else {
3032             JSONObject jsonResponse = executeJsonRequest(new GraphRequestBuilder()
3033                     .setMethod(HttpGet.METHOD_NAME)
3034                     .setMailbox(mailbox)
3035                     .setObjectType("mailFolders")
3036                     .setObjectId(wellKnownFolderName.name())
3037                     .setSelect("id"));
3038             String id = jsonResponse.optString("id");
3039             if (id == null) {
3040                 LOGGER.warn("Missing id on folder '" + wellKnownFolderName.name() + "'");
3041                 // should not happen, return wellknown name as id
3042                 id = wellKnownFolderName.name();
3043             }
3044             wellKnownFolderId = new FolderId(mailbox, id, FolderId.IPF_NOTE);
3045         }
3046         // cache folder id on personal mailbox
3047         if (mailbox == null && !folderIdCache.containsKey(wellKnownFolderName.name())) {
3048             folderIdCache.put(wellKnownFolderName.name(), wellKnownFolderId);
3049         }
3050 
3051         return wellKnownFolderId;
3052     }
3053 
3054     /**
3055      * Search subfolder by name, return null when no folders found
3056      * @param currentFolderId parent folder id
3057      * @param folderName child folder name
3058      * @return child folder id if exists
3059      * @throws IOException on error
3060      */
3061     protected FolderId getSubFolderByName(FolderId currentFolderId, String folderName) throws IOException {
3062         LOGGER.debug("getSubFolderByName " + currentFolderId.id + " " + folderName);
3063         GraphRequestBuilder httpRequestBuilder;
3064         if (currentFolderId.isCalendar()) {
3065             httpRequestBuilder = new GraphRequestBuilder()
3066                     .setMethod(HttpGet.METHOD_NAME)
3067                     .setMailbox(currentFolderId.mailbox)
3068                     .setObjectType("calendars")
3069                     .setSelect("id")
3070                     .setFilter("name eq '" + StringUtil.escapeQuotes(StringUtil.decodeFolderName(folderName)) + "'");
3071         } else if (currentFolderId.isTask()) {
3072             httpRequestBuilder = new GraphRequestBuilder()
3073                     .setMethod(HttpGet.METHOD_NAME)
3074                     .setMailbox(currentFolderId.mailbox)
3075                     .setObjectType("todo/lists")
3076                     .setSelect("id")
3077                     .setFilter("displayName eq '" + StringUtil.escapeQuotes(StringUtil.decodeFolderName(folderName)) + "'");
3078         } else {
3079             String objectType = "mailFolders";
3080             if (currentFolderId.isContact()) {
3081                 objectType = "contactFolders";
3082             }
3083             httpRequestBuilder = new GraphRequestBuilder()
3084                     .setMethod(HttpGet.METHOD_NAME)
3085                     .setMailbox(currentFolderId.mailbox)
3086                     .setObjectType(objectType)
3087                     .setObjectId(currentFolderId.id)
3088                     .setChildType("childFolders")
3089                     .setSelect("id")
3090                     .setFilter("displayName eq '" + StringUtil.escapeQuotes(StringUtil.decodeFolderName(folderName)) + "'");
3091         }
3092 
3093         JSONObject jsonResponse = executeJsonRequest(httpRequestBuilder);
3094 
3095         FolderId folderId = null;
3096         try {
3097             JSONArray values = jsonResponse.getJSONArray("value");
3098             if (values.length() > 0) {
3099                 folderId = new FolderId(currentFolderId.mailbox, values.getJSONObject(0).getString("id"), currentFolderId.folderClass);
3100                 folderId.parentFolderId = currentFolderId.id;
3101             }
3102         } catch (JSONException e) {
3103             throw new IOException(e.getMessage(), e);
3104         }
3105 
3106         return folderId;
3107     }
3108 
3109     private boolean isSubFolderOf(String folderPath, String baseFolder) {
3110         if (PUBLIC_ROOT.equals(baseFolder) || ARCHIVE_ROOT.equals(baseFolder)) {
3111             return folderPath.startsWith(baseFolder);
3112         } else {
3113             return folderPath.startsWith(baseFolder)
3114                     && (folderPath.length() == baseFolder.length() || folderPath.charAt(baseFolder.length()) == '/');
3115         }
3116     }
3117 
3118     @Override
3119     public int createFolder(String folderPath, String folderClass, Map<String, String> properties) throws IOException {
3120         if (FolderId.IPF_APPOINTMENT.equals(folderClass) && folderPath.startsWith("calendar/")) {
3121             // calendars/calendarName
3122             String calendarName = folderPath.substring(folderPath.indexOf('/') + 1);
3123             // create calendar
3124             try {
3125                 executeJsonRequest(new GraphRequestBuilder()
3126                         .setMethod(HttpPost.METHOD_NAME)
3127                         // TODO mailbox?
3128                         //.setMailbox("")
3129                         .setObjectType("calendars")
3130                         .setJsonBody(new JSONObject().put("name", calendarName)));
3131 
3132             } catch (JSONException e) {
3133                 throw new IOException(e);
3134             }
3135         } else {
3136             FolderId parentFolderId;
3137             String folderName;
3138             if (folderPath.contains("/")) {
3139                 String parentFolderPath = folderPath.substring(0, folderPath.lastIndexOf('/'));
3140                 parentFolderId = getFolderId(parentFolderPath);
3141                 folderName = StringUtil.decodeFolderName(folderPath.substring(folderPath.lastIndexOf('/') + 1));
3142             } else {
3143                 parentFolderId = getFolderId("");
3144                 folderName = StringUtil.decodeFolderName(folderPath);
3145             }
3146 
3147             try {
3148                 String objectType = "mailFolders";
3149                 if (FolderId.IPF_CONTACT.equals(folderClass)) {
3150                     objectType = "contactFolders";
3151                 }
3152                 executeJsonRequest(new GraphRequestBuilder()
3153                         .setMethod(HttpPost.METHOD_NAME)
3154                         .setMailbox(parentFolderId.mailbox)
3155                         .setObjectType(objectType)
3156                         .setObjectId(parentFolderId.id)
3157                         .setChildType("childFolders")
3158                         .setJsonBody(new JSONObject().put("displayName", folderName)));
3159 
3160             } catch (JSONException e) {
3161                 throw new IOException(e);
3162             }
3163         }
3164 
3165         return HttpStatus.SC_CREATED;
3166 
3167     }
3168 
3169     @Override
3170     public int updateFolder(String folderName, Map<String, String> properties) throws IOException {
3171         return 0;
3172     }
3173 
3174     @Override
3175     public void deleteFolder(String folderPath) throws IOException {
3176         FolderId folderId = getFolderIdIfExists(folderPath);
3177         if (folderPath.startsWith("calendar/")) {
3178             // TODO shared mailboxes
3179             if (folderId != null) {
3180                 executeJsonRequest(new GraphRequestBuilder()
3181                         .setMethod(HttpDelete.METHOD_NAME)
3182                         //.setMailbox()
3183                         .setObjectType("calendars")
3184                         .setObjectId(folderId.id));
3185             }
3186         } else {
3187             if (folderId != null) {
3188                 String objectType = "mailFolders";
3189                 if (folderId.isContact()) {
3190                     objectType = "contactFolders";
3191                 }
3192                 executeJsonRequest(new GraphRequestBuilder()
3193                         .setMethod(HttpDelete.METHOD_NAME)
3194                         .setMailbox(folderId.mailbox)
3195                         .setObjectType(objectType)
3196                         .setObjectId(folderId.id));
3197             }
3198         }
3199 
3200     }
3201 
3202     @Override
3203     public void copyMessage(ExchangeSession.Message message, String targetFolder) throws IOException {
3204         try {
3205             FolderId targetFolderId = getFolderId(targetFolder);
3206 
3207             executeJsonRequest(new GraphRequestBuilder().setMethod(HttpPost.METHOD_NAME)
3208                     .setMailbox(((Message) message).folderId.mailbox)
3209                     .setObjectType("messages")
3210                     .setObjectId(((Message) message).id)
3211                     .setChildType("copy")
3212                     .setJsonBody(new JSONObject().put("destinationId", targetFolderId.id)));
3213 
3214         } catch (JSONException e) {
3215             throw new IOException(e);
3216         }
3217     }
3218 
3219     @Override
3220     public void moveMessage(ExchangeSession.Message message, String targetFolder) throws IOException {
3221         try {
3222             FolderId targetFolderId = getFolderId(targetFolder);
3223 
3224             executeJsonRequest(new GraphRequestBuilder().setMethod(HttpPost.METHOD_NAME)
3225                     .setMailbox(((Message) message).folderId.mailbox)
3226                     .setObjectType("messages")
3227                     .setObjectId(((Message) message).id)
3228                     .setChildType("move")
3229                     .setJsonBody(new JSONObject().put("destinationId", targetFolderId.id)));
3230         } catch (JSONException e) {
3231             throw new IOException(e);
3232         }
3233     }
3234 
3235     @Override
3236     public void moveFolder(String folderPath, String targetFolderPath) throws IOException {
3237         FolderId folderId = getFolderId(folderPath);
3238         String targetFolderName;
3239         String targetFolderParentPath;
3240         if (targetFolderPath.contains("/")) {
3241             targetFolderParentPath = targetFolderPath.substring(0, targetFolderPath.lastIndexOf('/'));
3242             targetFolderName = StringUtil.decodeFolderName(targetFolderPath.substring(targetFolderPath.lastIndexOf('/') + 1));
3243         } else {
3244             targetFolderParentPath = "";
3245             targetFolderName = StringUtil.decodeFolderName(targetFolderPath);
3246         }
3247         FolderId targetFolderId = getFolderId(targetFolderParentPath);
3248 
3249         // rename
3250         try {
3251             executeJsonRequest(new GraphRequestBuilder().setMethod(HttpPatch.METHOD_NAME)
3252                     .setMailbox(folderId.mailbox)
3253                     .setObjectType("mailFolders")
3254                     .setObjectId(folderId.id)
3255                     .setJsonBody(new JSONObject().put("displayName", targetFolderName)));
3256         } catch (JSONException e) {
3257             throw new IOException(e);
3258         }
3259 
3260         try {
3261             executeJsonRequest(new GraphRequestBuilder().setMethod(HttpPost.METHOD_NAME)
3262                     .setMailbox(folderId.mailbox)
3263                     .setObjectType("mailFolders")
3264                     .setObjectId(folderId.id)
3265                     .setChildType("move")
3266                     .setJsonBody(new JSONObject().put("destinationId", targetFolderId.id)));
3267         } catch (JSONException e) {
3268             throw new IOException(e);
3269         }
3270     }
3271 
3272     @Override
3273     public void moveItem(String sourcePath, String targetPath) throws IOException {
3274 
3275     }
3276 
3277     @Override
3278     protected void moveToTrash(ExchangeSession.Message message) throws IOException {
3279         moveMessage(message, WellKnownFolderName.deleteditems.name());
3280     }
3281 
3282 
3283     /**
3284      * Common item properties, not used over Graph
3285      */
3286     protected static final Set<String> ITEM_PROPERTIES = new HashSet<>();
3287 
3288     protected static final HashSet<String> EVENT_REQUEST_PROPERTIES = new HashSet<>();
3289 
3290     static {
3291         EVENT_REQUEST_PROPERTIES.add("permanenturl");
3292         EVENT_REQUEST_PROPERTIES.add("etag");
3293         EVENT_REQUEST_PROPERTIES.add("displayname");
3294         EVENT_REQUEST_PROPERTIES.add("subject");
3295         EVENT_REQUEST_PROPERTIES.add("urlcompname");
3296         EVENT_REQUEST_PROPERTIES.add("displayto");
3297         EVENT_REQUEST_PROPERTIES.add("displaycc");
3298 
3299         EVENT_REQUEST_PROPERTIES.add("xmozlastack");
3300         EVENT_REQUEST_PROPERTIES.add("xmozsnoozetime");
3301     }
3302 
3303     protected static final HashSet<String> CALENDAR_ITEM_REQUEST_PROPERTIES = new HashSet<>();
3304 
3305     static {
3306         CALENDAR_ITEM_REQUEST_PROPERTIES.addAll(EVENT_REQUEST_PROPERTIES);
3307         CALENDAR_ITEM_REQUEST_PROPERTIES.add("ismeeting");
3308         CALENDAR_ITEM_REQUEST_PROPERTIES.add("myresponsetype");
3309     }
3310 
3311     @Override
3312     protected Set<String> getItemProperties() {
3313         return ITEM_PROPERTIES;
3314     }
3315 
3316     @Override
3317     public List<ExchangeSession.Contact> searchContacts(String folderPath, Set<String> attributes, Condition condition, int maxCount) throws IOException {
3318         ArrayList<ExchangeSession.Contact> contactList = new ArrayList<>();
3319         FolderId folderId = getFolderId(folderPath);
3320 
3321         GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder()
3322                 .setMethod(HttpGet.METHOD_NAME)
3323                 .setMailbox(folderId.mailbox)
3324                 .setObjectType("contactFolders")
3325                 .setObjectId(folderId.id)
3326                 .setChildType("contacts")
3327                 .setSelectFields(CONTACT_ATTRIBUTES)
3328                 .setFilter(condition);
3329         LOGGER.debug("searchContacts " + folderId.getMailboxName() + "/" + folderPath + " " + httpRequestBuilder.select);
3330 
3331         GraphIterator graphIterator = executeSearchRequest(httpRequestBuilder);
3332 
3333         while (graphIterator.hasNext() && (maxCount == 0 || contactList.size() < maxCount)) {
3334             Contact contact = new Contact(new GraphObject(graphIterator.next()));
3335             contact.folderPath = folderPath;
3336             contact.folderId = folderId;
3337             contactList.add(contact);
3338         }
3339 
3340         return contactList;
3341     }
3342 
3343     @Override
3344     public List<ExchangeSession.Event> getEventMessages(String folderPath) throws IOException {
3345         return searchEvents(folderPath, ITEM_PROPERTIES,
3346                 and(startsWith("outlookmessageclass", "IPM.Schedule.Meeting."),
3347                         or(isNull("processed"), isFalse("processed"))));
3348     }
3349 
3350     @Override
3351     protected Condition getCalendarItemCondition(Condition dateCondition) {
3352         return or(isTrue("isrecurring"),
3353                 and(isFalse("isrecurring"), dateCondition));
3354     }
3355 
3356     /**
3357      * Tasks folders are no longer supported, reroute to todos.
3358      * @param folderPath Exchange folder path
3359      * @return todos as events
3360      * @throws IOException on error
3361      */
3362     @Override
3363     public List<ExchangeSession.Event> searchTasksOnly(String folderPath) throws IOException {
3364         ArrayList<ExchangeSession.Event> eventList = new ArrayList<>();
3365         FolderId folderId = getFolderId(folderPath);
3366 
3367         // GET /me/todo/lists/{todoTaskListId}/tasks
3368         GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder()
3369                 .setMethod(HttpGet.METHOD_NAME)
3370                 .setMailbox(folderId.mailbox)
3371                 .setObjectType("todo/lists")
3372                 .setObjectId(folderId.id)
3373                 .setChildType("tasks")
3374                 //.setSelectFields(TODO_PROPERTIES)
3375                 ;
3376         LOGGER.debug("searchTasksOnly " + folderId.getMailboxName() + " " + folderPath);
3377 
3378         GraphIterator graphIterator = executeSearchRequest(httpRequestBuilder);
3379 
3380         while (graphIterator.hasNext()) {
3381             Event event = new Event(folderPath, folderId, new GraphObject(graphIterator.next()));
3382             eventList.add(event);
3383         }
3384 
3385         return eventList;
3386     }
3387 
3388     @Override
3389     public List<ExchangeSession.Event> searchEvents(String folderPath, Set<String> attributes, Condition condition) throws IOException {
3390         ArrayList<ExchangeSession.Event> eventList = new ArrayList<>();
3391         FolderId folderId = getFolderId(folderPath);
3392 
3393         if (folderId.isCalendar()) {
3394             // /users/{id | userPrincipalName}/calendars/{id}/events
3395             GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder()
3396                     .setMethod(HttpGet.METHOD_NAME)
3397                     .setMailbox(folderId.mailbox)
3398                     .setObjectType("calendars")
3399                     .setObjectId(folderId.id)
3400                     .setChildType("events")
3401                     .setSelectFields(EVENT_LIST_ATTRIBUTES)
3402                     .setTimezone(getVTimezone().getPropertyValue("TZID"))
3403                     .setFilter(condition);
3404             LOGGER.debug("searchEvents " + folderId.getMailboxName() + " " + folderPath);
3405 
3406             GraphIterator graphIterator = executeSearchRequest(httpRequestBuilder);
3407 
3408             while (graphIterator.hasNext()) {
3409                 Event event = new Event(folderPath, folderId, new GraphObject(graphIterator.next()));
3410                 eventList.add(event);
3411             }
3412         } else {
3413             // event messages
3414             GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder()
3415                     .setMethod(HttpGet.METHOD_NAME)
3416                     .setMailbox(folderId.mailbox)
3417                     .setObjectType("mailFolders")
3418                     .setObjectId(folderId.id)
3419                     .setChildType("messages")
3420                     .setSelectFields(IMAP_MESSAGE_ATTRIBUTES)
3421                     .setFilter(condition);
3422             LOGGER.debug("searchEventMessages " + folderId.getMailboxName() + " " + folderPath);
3423 
3424             GraphIterator graphIterator = executeSearchRequest(httpRequestBuilder);
3425 
3426             while (graphIterator.hasNext()) {
3427                 JSONObject jsonResponse = graphIterator.next();
3428                 GraphExchangeSession.Message message = buildMessage(jsonResponse);
3429                 message.folderId = folderId;
3430                 LOGGER.debug("searchEventMessages " + message.contentClass + " " + message.id);
3431                 try {
3432                     byte[] content = getContent(message);
3433                     if (content == null) {
3434                         throw new IOException("empty event body");
3435                     }
3436                     content = getICS(new SharedByteArrayInputStream(content));
3437 
3438                     Event event = new Event(folderId, content);
3439                     eventList.add(event);
3440 
3441                 } catch (IOException | MessagingException e) {
3442                     LOGGER.warn("searchEventMessages " + message.id, e);
3443                 }
3444             }
3445         }
3446 
3447         return eventList;
3448 
3449     }
3450 
3451     // TODO refactor to avoid duplicate code
3452 
3453     protected static final String TEXT_CALENDAR = "text/calendar";
3454     protected static final String APPLICATION_ICS = "application/ics";
3455 
3456     protected boolean isCalendarContentType(String contentType) {
3457         return TEXT_CALENDAR.regionMatches(true, 0, contentType, 0, TEXT_CALENDAR.length()) ||
3458                 APPLICATION_ICS.regionMatches(true, 0, contentType, 0, APPLICATION_ICS.length());
3459     }
3460 
3461     protected MimePart getCalendarMimePart(MimeMultipart multiPart) throws IOException, MessagingException {
3462         MimePart bodyPart = null;
3463         for (int i = 0; i < multiPart.getCount(); i++) {
3464             String contentType = multiPart.getBodyPart(i).getContentType();
3465             if (isCalendarContentType(contentType)) {
3466                 bodyPart = (MimePart) multiPart.getBodyPart(i);
3467                 break;
3468             } else if (contentType.startsWith("multipart")) {
3469                 Object content = multiPart.getBodyPart(i).getContent();
3470                 if (content instanceof MimeMultipart) {
3471                     bodyPart = getCalendarMimePart((MimeMultipart) content);
3472                 }
3473             }
3474         }
3475 
3476         return bodyPart;
3477     }
3478 
3479     /**
3480      * Load ICS content from MIME message input stream
3481      *
3482      * @param mimeInputStream mime message input stream
3483      * @return mime message ics attachment body
3484      * @throws IOException        on error
3485      * @throws MessagingException on error
3486      */
3487     protected byte[] getICS(InputStream mimeInputStream) throws IOException, MessagingException {
3488         byte[] result;
3489         MimeMessage mimeMessage = new MimeMessage(null, mimeInputStream);
3490         String[] contentClassHeader = mimeMessage.getHeader("Content-class");
3491         // task item, return null
3492         if (contentClassHeader != null && contentClassHeader.length > 0 && "urn:content-classes:task".equals(contentClassHeader[0])) {
3493             return null;
3494         }
3495         Object mimeBody = mimeMessage.getContent();
3496         MimePart bodyPart = null;
3497         if (mimeBody instanceof MimeMultipart) {
3498             bodyPart = getCalendarMimePart((MimeMultipart) mimeBody);
3499         } else if (isCalendarContentType(mimeMessage.getContentType())) {
3500             // no multipart, single body
3501             bodyPart = mimeMessage;
3502         }
3503 
3504 
3505         if (bodyPart != null) {
3506             try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
3507                 bodyPart.getDataHandler().writeTo(baos);
3508                 result = baos.toByteArray();
3509             }
3510         } else {
3511             try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
3512                 mimeMessage.writeTo(baos);
3513                 throw new DavMailException("EXCEPTION_INVALID_MESSAGE_CONTENT", new String(baos.toByteArray(), StandardCharsets.UTF_8));
3514             }
3515         }
3516         return result;
3517     }
3518 
3519     @Override
3520     public Item getItem(String folderPath, String itemName) throws IOException {
3521         FolderId folderId = getFolderId(folderPath);
3522 
3523         if (folderId.isContact()) {
3524             JSONObject jsonResponse = getContactIfExists(folderId, itemName);
3525             if (jsonResponse != null) {
3526                 Contact contact = new Contact(new GraphObject(jsonResponse));
3527                 contact.folderPath = folderPath;
3528                 contact.folderId = folderId;
3529                 return contact;
3530             } else {
3531                 throw new IOException("Item " + folderPath + " " + itemName + " not found");
3532             }
3533         } else if (folderId.isCalendar()) {
3534             JSONObject jsonResponse = getEventIfExists(folderId, itemName);
3535             if (jsonResponse != null) {
3536                 return new Event(folderPath, folderId, new GraphObject(jsonResponse));
3537             } else {
3538                 throw new IOException("Item " + folderPath + " " + itemName + " not found");
3539             }
3540         } else {
3541             throw new UnsupportedOperationException("Item type " + folderId.folderClass + " not supported");
3542         }
3543     }
3544 
3545     @Override
3546     protected String convertItemNameToEML(String itemName) {
3547         if (itemName.endsWith(".vcf") || itemName.endsWith(".ics")) {
3548             return itemName.substring(0, itemName.length() - 3) + "EML";
3549         } else {
3550             return itemName;
3551         }
3552     }
3553 
3554     protected String convertItemNameToItemId(String itemName) {
3555         return itemName.substring(0, itemName.length() - 4);
3556     }
3557 
3558 
3559     private JSONObject getEventIfExists(FolderId folderId, String itemName) throws IOException {
3560         String urlcompname = convertItemNameToEML(itemName);
3561         String itemId = null;
3562         if (isItemId(urlcompname)) {
3563             itemId = convertItemNameToItemId(urlcompname);
3564         } else {
3565             // try to retrieve item id by urlcompname
3566             try {
3567                 if (urlcompnameToIdMap.containsKey(urlcompname)) {
3568                     // try to fetch id from cache
3569                     itemId = urlcompnameToIdMap.get(urlcompname);
3570                 } else if (folderId.isCalendar()) {
3571                     JSONObject jsonResponse = executeJsonRequest(new GraphRequestBuilder()
3572                             .setMethod(HttpGet.METHOD_NAME)
3573                             .setMailbox(folderId.mailbox)
3574                             .setObjectType("calendars")
3575                             .setObjectId(folderId.id)
3576                             .setChildType("events")
3577                             .setFilter(isEqualTo("urlcompname", urlcompname))
3578                             .setSelect("id") // retrieve id only
3579                     );
3580 
3581                     JSONArray values = jsonResponse.optJSONArray("value");
3582                     if (values != null && values.length() > 0) {
3583                         if (LOGGER.isDebugEnabled()) {
3584                             LOGGER.debug("Found event " + values.optJSONObject(0));
3585                         }
3586                         itemId = values.optJSONObject(0).optString("id");
3587                     }
3588                 }
3589 
3590             } catch (HttpNotFoundException e) {
3591                 LOGGER.debug("No event found for urlcompname " + urlcompname);
3592             }
3593         }
3594         // fetch item by id
3595         if (itemId != null) {
3596             try {
3597                 return executeJsonRequest(new GraphRequestBuilder()
3598                         .setMethod(HttpGet.METHOD_NAME)
3599                         .setMailbox(folderId.mailbox)
3600                         .setObjectType("events")
3601                         .setObjectId(itemId)
3602                         .setSelectFields(EVENT_ATTRIBUTES)
3603                         .setTimezone(getTimezoneId())
3604                 );
3605             } catch (HttpNotFoundException e) {
3606                 // this may be a task item
3607                 FolderId taskFolderId = getFolderId(TASKS);
3608                 try {
3609                     return executeJsonRequest(new GraphRequestBuilder()
3610                                     .setMethod(HttpGet.METHOD_NAME)
3611                                     .setMailbox(folderId.mailbox)
3612                                     .setObjectType("todo/lists")
3613                                     .setObjectId(taskFolderId.id)
3614                                     .setChildType("tasks")
3615                                     .setChildId(itemId)
3616                             //.setSelectFields(TODO_PROPERTIES) // bug on title breaks request
3617                     ).put("objecttype", FolderId.IPF_TASK); // mark object as task item
3618                 } catch (JSONException jsonException) {
3619                     throw new IOException(jsonException.getMessage(), jsonException);
3620                 }
3621             }
3622         }
3623         return null;
3624     }
3625 
3626     private JSONObject getContactIfExists(FolderId folderId, String itemName) throws IOException {
3627         String urlcompname = convertItemNameToEML(itemName);
3628         if (isItemId(urlcompname)) {
3629             // lookup item directly
3630             return executeJsonRequest(new GraphRequestBuilder()
3631                     .setMethod(HttpGet.METHOD_NAME)
3632                     .setMailbox(folderId.mailbox)
3633                     .setObjectType("contactFolders")
3634                     .setObjectId(folderId.id)
3635                     .setChildType("contacts")
3636                     .setChildId(convertItemNameToItemId(itemName))
3637                     .setSelectFields(CONTACT_ATTRIBUTES)
3638             );
3639 
3640         } else {
3641             JSONObject jsonResponse = executeJsonRequest(new GraphRequestBuilder()
3642                     .setMethod(HttpGet.METHOD_NAME)
3643                     .setMailbox(folderId.mailbox)
3644                     .setObjectType("contactFolders")
3645                     .setObjectId(folderId.id)
3646                     .setChildType("contacts")
3647                     .setFilter(isEqualTo("urlcompname", urlcompname))
3648                     .setSelectFields(CONTACT_ATTRIBUTES)
3649             );
3650             // need at least one value
3651             JSONArray values = jsonResponse.optJSONArray("value");
3652             if (values != null && values.length() > 0) {
3653                 if (LOGGER.isDebugEnabled()) {
3654                     LOGGER.debug("Found contact " + values.optJSONObject(0));
3655                 }
3656                 return values.optJSONObject(0);
3657             }
3658         }
3659         return null;
3660     }
3661 
3662     @Override
3663     public ContactPhoto getContactPhoto(ExchangeSession.Contact contact) throws IOException {
3664         // don't fetch if haspicture flag is false
3665         if ("false".equals(contact.get("haspicture"))) {
3666             return null;
3667         }
3668         GraphRequestBuilder graphRequestBuilder = new GraphRequestBuilder()
3669                 .setMethod(HttpGet.METHOD_NAME)
3670                 .setMailbox(((Contact) contact).folderId.mailbox)
3671                 .setObjectType("contactFolders")
3672                 .setObjectId(((Contact) contact).folderId.id)
3673                 .setChildType("contacts")
3674                 .setChildId(((Contact) contact).id)
3675                 .setChildSuffix("photo/$value")
3676                 .setAccessToken(token.getAccessToken());
3677 
3678         byte[] contactPhotoBytes;
3679         try (
3680                 CloseableHttpResponse response = httpClient.execute(graphRequestBuilder.build());
3681                 InputStream inputStream = response.getEntity().getContent()
3682         ) {
3683             if (response.getStatusLine().getStatusCode() == HttpStatus.SC_BAD_REQUEST) {
3684                 throw new IOException("Unable to fetch photo" + response.getStatusLine().getReasonPhrase());
3685             }
3686             if (HttpClientAdapter.isGzipEncoded(response)) {
3687                 contactPhotoBytes = IOUtil.readFully(new GZIPInputStream(inputStream));
3688             } else {
3689                 contactPhotoBytes = IOUtil.readFully(inputStream);
3690             }
3691         }
3692         ContactPhoto contactPhoto = new ContactPhoto();
3693         contactPhoto.contentType = "image/jpeg";
3694         contactPhoto.content = IOUtil.encodeBase64AsString(contactPhotoBytes);
3695 
3696         return contactPhoto;
3697     }
3698 
3699     @Override
3700     public void deleteItem(String folderPath, String itemName) throws IOException {
3701         Item item = getItem(folderPath, itemName);
3702         if (item instanceof GraphExchangeSession.Contact) {
3703             FolderId folderId = ((Contact) item).folderId;
3704             executeJsonRequest(new GraphRequestBuilder()
3705                     .setMethod(HttpDelete.METHOD_NAME)
3706                     .setMailbox(folderId.mailbox)
3707                     .setObjectType("contactFolders")
3708                     .setObjectId(folderId.id)
3709                     .setChildType("contacts")
3710                     .setChildId(((Contact) item).id)
3711             );
3712         } else if (item instanceof GraphExchangeSession.Event) {
3713             FolderId folderId = ((Event) item).folderId;
3714 
3715             if (folderId.isCalendar()) {
3716                 executeJsonRequest(new GraphRequestBuilder()
3717                         .setMethod(HttpDelete.METHOD_NAME)
3718                         .setMailbox(folderId.mailbox)
3719                         .setObjectType("events")
3720                         .setObjectId(((Event) item).id));
3721             } else {
3722                 executeJsonRequest(new GraphRequestBuilder()
3723                         .setMethod(HttpDelete.METHOD_NAME)
3724                         .setMailbox(folderId.mailbox)
3725                         .setObjectType("todo/lists")
3726                         .setObjectId(folderId.id)
3727                         .setChildType("tasks")
3728                         .setChildId(((Event) item).id)
3729                 );
3730             }
3731         }
3732     }
3733 
3734     @Override
3735     public void processItem(String folderPath, String itemName) throws IOException {
3736         // TODO mark event messages in inbox processed
3737     }
3738 
3739     @Override
3740     public int sendEvent(String icsBody) throws IOException {
3741         String itemName = UUID.randomUUID() + ".EML";
3742         byte[] mimeContent = new GraphExchangeSession.Event(DRAFTS, itemName, "urn:content-classes:calendarmessage", icsBody, null, null).createMimeContent();
3743         if (mimeContent == null) {
3744             // no recipients, cancel
3745             return HttpStatus.SC_NO_CONTENT;
3746         } else {
3747             sendMessage(mimeContent);
3748             return HttpStatus.SC_OK;
3749         }
3750     }
3751 
3752     @Override
3753     protected Contact buildContact(String folderPath, String itemName, Map<String, String> properties, String etag, String noneMatch) throws IOException {
3754         return new Contact(folderPath, itemName, properties, StringUtil.removeQuotes(etag), noneMatch);
3755     }
3756 
3757     @Override
3758     protected ItemResult internalCreateOrUpdateEvent(String folderPath, String itemName, String contentClass, String icsBody, String etag, String noneMatch) throws IOException {
3759         return new Event(folderPath, itemName, contentClass, icsBody, StringUtil.removeQuotes(etag), noneMatch).createOrUpdate();
3760     }
3761 
3762     @Override
3763     public boolean isSharedFolder(String folderPath) {
3764         return folderPath.startsWith("/") && !folderPath.toLowerCase().startsWith(currentMailboxPath);
3765     }
3766 
3767     @Override
3768     public boolean isMainCalendar(String folderPath) throws IOException {
3769         FolderId folderId = getFolderIdIfExists(folderPath);
3770         return folderId.parentFolderId == null && WellKnownFolderName.calendar.name().equals(folderId.id);
3771     }
3772 
3773     @Override
3774     protected String getCalendarEmail(String folderPath) throws IOException {
3775         FolderId folderId = getFolderId(folderPath);
3776         if (folderId.mailbox == null) {
3777             return email;
3778         } else {
3779             return folderId.mailbox;
3780         }
3781     }
3782 
3783     /**
3784      * Map people attributes to LDAP
3785      */
3786     public static final HashMap<String, String> GALFIND_ATTRIBUTE_MAP = new HashMap<>();
3787 
3788     static {
3789         GALFIND_ATTRIBUTE_MAP.put("id", "uid"); // use id as uid
3790 
3791         GALFIND_ATTRIBUTE_MAP.put("displayName", "cn"); // common name
3792         GALFIND_ATTRIBUTE_MAP.put("surname", "sn");
3793         GALFIND_ATTRIBUTE_MAP.put("givenName", "givenname");
3794         GALFIND_ATTRIBUTE_MAP.put("personNotes", "description"); // map personNotes to description
3795 
3796         GALFIND_ATTRIBUTE_MAP.put("companyName", "company"); // company or o
3797         GALFIND_ATTRIBUTE_MAP.put("profession", "profession");
3798         GALFIND_ATTRIBUTE_MAP.put("title", "title");
3799         GALFIND_ATTRIBUTE_MAP.put("department", "department");
3800         GALFIND_ATTRIBUTE_MAP.put("officeLocation", "location");
3801 
3802         GALFIND_ATTRIBUTE_MAP.put("birthday", "birthday"); // TODO may have to convert value
3803 
3804         GALFIND_ATTRIBUTE_MAP.put("yomiCompany", "yomicompany");
3805 
3806         GALFIND_ATTRIBUTE_MAP.put("mailboxType", "mailboxtype");
3807         GALFIND_ATTRIBUTE_MAP.put("personType", "persontype");
3808         GALFIND_ATTRIBUTE_MAP.put("userPrincipalName", "userprincipalname"); // for Active Directory / EntraID entries
3809         GALFIND_ATTRIBUTE_MAP.put("isFavorite", "isfavorite");
3810     }
3811 
3812 
3813     protected GraphExchangeSession.Contact buildGalfindContact(JSONObject response) {
3814         GraphExchangeSession.Contact contact = new GraphExchangeSession.Contact();
3815         contact.setName(response.optString("id"));
3816         contact.put("imapUid", response.optString("id"));
3817         contact.put("uid", response.optString("id"));
3818         Iterator keysIterator = response.keys();
3819         while (keysIterator.hasNext()) {
3820             String key = (String) keysIterator.next();
3821             String attributeName = key;
3822             // special handling for email addresses
3823             if ("emailAddresses".equals(key)) {
3824                 JSONArray emailAddresses = response.optJSONArray("emailAddresses");
3825                 if (emailAddresses != null) {
3826                     for (int i = 0; i < 3; i++) {
3827                         if (emailAddresses.length() > i) {
3828                             contact.put("smtpemail" + (i + 1), emailAddresses.optJSONObject(i).optString("address"));
3829                         }
3830                     }
3831                 }
3832                 // map phone numbers
3833             } else if ("phones".equals(key)) {
3834                 JSONArray phones = response.optJSONArray("phones");
3835                 if (phones != null) {
3836                     for (int i = 0; i < phones.length(); i++) {
3837                         String phoneType = phones.optJSONObject(i).optString("type");
3838                         String phoneNumber = phones.optJSONObject(i).optString("number");
3839                         if ("business".equals(phoneType)) {
3840                             contact.put("telephoneNumber", phoneNumber);
3841                         } else if ("mobile".equals(phoneType)) {
3842                             contact.put("mobile", phoneNumber);
3843                         } else if ("home".equals(phoneType)) {
3844                             contact.put("homePhone", phoneNumber);
3845                         } else {
3846                             LOGGER.debug("Unknown phoneType " + phoneType);
3847                             contact.put(phoneType + "Phone", phoneNumber);
3848                         }
3849                     }
3850                 }
3851             } else if ("sources".equals(key)) {
3852                 JSONArray sources = response.optJSONArray("sources");
3853                 if (sources != null && sources.length() > 0) {
3854                     String sourceType = sources.optJSONObject(0).optString("type");
3855                     contact.put("sourceType", sourceType);
3856                 }
3857             } else {
3858                 if (GALFIND_ATTRIBUTE_MAP.get(key) != null) {
3859                     attributeName = GALFIND_ATTRIBUTE_MAP.get(key);
3860                 } else {
3861                     LOGGER.debug("Unknown attribute " + attributeName);
3862                 }
3863 
3864                 String attributeValue = response.optString(key);
3865                 if (attributeValue != null) {
3866                     contact.put(attributeName, attributeValue);
3867                 }
3868             }
3869         }
3870         return contact;
3871     }
3872 
3873     @Override
3874     public Map<String, ExchangeSession.Contact> galFind(Condition condition, Set<String> returningAttributes, int sizeLimit) throws IOException {
3875         Map<String, ExchangeSession.Contact> contacts = new HashMap<>();
3876 
3877         // poor implementation of search filter based on people endpoint limitations
3878         String search = null;
3879         String id = null;
3880         if (condition instanceof AttributeCondition) {
3881             if ("imapUid".equals(((AttributeCondition) condition).getAttributeName())) {
3882                 id = ((AttributeCondition) condition).getValue();
3883             } else {
3884                 search = ((AttributeCondition) condition).getValue();
3885             }
3886         }
3887 
3888         if (id != null) {
3889 
3890             // lookup by id only if this is actually an id
3891             if (id.length() == 36) {
3892                 GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder()
3893                         .setMethod(HttpGet.METHOD_NAME)
3894                         .addHeader("X-PeopleQuery-QuerySources", "Mailbox,Directory")
3895                         .setObjectType("people")
3896                         .setObjectId(id);
3897                 JSONObject peopleObject = null;
3898 
3899                 try {
3900                     peopleObject = executeJsonRequest(httpRequestBuilder);
3901                 } catch (HttpNotFoundException e) {
3902                     LOGGER.warn("No person found for id " + id);
3903                 }
3904 
3905                 if (peopleObject != null) {
3906                     Contact contact = buildGalfindContact(peopleObject);
3907 
3908                     contacts.put(contact.getName().toLowerCase(), contact);
3909                     LOGGER.debug("found user " + contact.getName());
3910                 }
3911             }
3912 
3913         } else {
3914             GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder()
3915                     .setMethod(HttpGet.METHOD_NAME)
3916                     .addHeader("X-PeopleQuery-QuerySources", "Mailbox,Directory")
3917                     .setObjectType("people")
3918                     .setSearch(search);
3919             LOGGER.debug("search users");
3920             GraphIterator graphIterator = executeSearchRequest(httpRequestBuilder);
3921 
3922             while (graphIterator.hasNext() && contacts.size() < sizeLimit) {
3923                 Contact contact = buildGalfindContact(graphIterator.next());
3924                 contacts.put(contact.getName().toLowerCase(), contact);
3925                 LOGGER.debug("found user " + contact.getName());
3926             }
3927         }
3928 
3929         return contacts;
3930     }
3931 
3932     @Override
3933     protected String getFreeBusyData(String attendee, String start, String end, int interval) throws IOException {
3934         // https://learn.microsoft.com/en-us/graph/outlook-get-free-busy-schedule
3935         // POST /me/calendar/getschedule
3936         String fbdata = null;
3937         JSONObject jsonBody = new JSONObject();
3938         try {
3939             String timeZone = getVTimezone().getPropertyValue("TZID");
3940             jsonBody.put("Schedules", new JSONArray().put(attendee));
3941             jsonBody.put("StartTime", new JSONObject().put("dateTime", start).put("timeZone", timeZone));
3942             jsonBody.put("EndTime", new JSONObject().put("dateTime", end).put("timeZone", timeZone));
3943             jsonBody.put("availabilityViewInterval", interval);
3944 
3945             GraphObject graphResponse = executeGraphRequest(new GraphRequestBuilder()
3946                     .setMethod(HttpPost.METHOD_NAME)
3947                     .setObjectType("calendar")
3948                     .setAction("getschedule")
3949                     .setJsonBody(jsonBody));
3950             JSONArray value = graphResponse.optJSONArray("value");
3951             if (value != null && value.length() > 0) {
3952                 fbdata = value.getJSONObject(0).optString("availabilityView", null);
3953             }
3954         } catch (JSONException e) {
3955             throw new IOException(e.getMessage(), e);
3956         }
3957         return fbdata;
3958     }
3959 
3960     @Override
3961     protected void loadVtimezone() {
3962         try {
3963             // default from Davmail settings
3964             String timezoneId = Settings.getProperty("davmail.timezoneId", null);
3965             // use timezone from mailbox
3966             if (timezoneId == null) {
3967                 try {
3968                     timezoneId = getMailboxSettings().optString("timeZone", null);
3969                 } catch (HttpForbiddenException e) {
3970                     LOGGER.warn("token does not grant MailboxSettings.Read");
3971                 }
3972             }
3973             // last failover: use GMT
3974             if (timezoneId == null) {
3975                 LOGGER.warn("Unable to get user timezone, using GMT Standard Time. Set davmail.timezoneId setting to override this.");
3976                 timezoneId = "GMT Standard Time";
3977             }
3978             this.vTimezone = getVTimezone(timezoneId);
3979 
3980         } catch (IOException | MissingResourceException e) {
3981             LOGGER.warn("Unable to get VTIMEZONE info: " + e, e);
3982         }
3983     }
3984 
3985     private VObject getVTimezone(String timezoneId) {
3986         String vTimeZone = DateUtil.getVTimeZone(timezoneId);
3987         if (vTimeZone != null) {
3988             try {
3989                 return new VObject(vTimeZone);
3990             } catch (IOException e) {
3991                 LOGGER.warn("Unable to get VTIMEZONE: " + e, e);
3992             }
3993         }
3994         // unsupported timezone, return user default timezone
3995         return getVTimezone();
3996     }
3997 
3998     private JSONObject getMailboxSettings() throws IOException {
3999         return executeJsonRequest(new GraphRequestBuilder()
4000                 .setMethod(HttpGet.METHOD_NAME)
4001                 .setObjectType("mailboxSettings"));
4002     }
4003 
4004     class GraphIterator {
4005 
4006         private JSONObject jsonObject;
4007         private JSONArray values;
4008         private String nextLink;
4009         private int index;
4010 
4011         public GraphIterator(JSONObject jsonObject) throws JSONException {
4012             this.jsonObject = jsonObject;
4013             nextLink = jsonObject.optString("@odata.nextLink", null);
4014             values = jsonObject.optJSONArray("value");
4015         }
4016 
4017         public boolean hasNext() throws IOException {
4018             if (values != null && index < values.length()) {
4019                 return true;
4020             } else if (nextLink != null) {
4021                 fetchNextPage();
4022                 return values != null && values.length() > 0;
4023             } else {
4024                 return false;
4025             }
4026         }
4027 
4028         public JSONObject next() throws IOException {
4029             if (values == null || !hasNext()) {
4030                 throw new NoSuchElementException();
4031             }
4032             try {
4033                 if (index >= values.length() && nextLink != null) {
4034                     fetchNextPage();
4035                 }
4036                 return values.getJSONObject(index++);
4037             } catch (JSONException e) {
4038                 throw new IOException(e.getMessage(), e);
4039             }
4040         }
4041 
4042         private void fetchNextPage() throws IOException {
4043             HttpGet request = new HttpGet(nextLink);
4044             request.setHeader("Authorization", "Bearer " + token.getAccessToken());
4045             try (
4046                     CloseableHttpResponse response = httpClient.execute(request)
4047             ) {
4048                 jsonObject = new JsonResponseHandler().handleResponse(response);
4049                 nextLink = jsonObject.optString("@odata.nextLink", null);
4050                 // workaround for people search bug
4051                 if (nextLink != null && nextLink.endsWith("skip=0")) {
4052                     nextLink = null;
4053                 }
4054                 values = jsonObject.optJSONArray("value");
4055                 index = 0;
4056             }
4057         }
4058     }
4059 
4060     private GraphIterator executeSearchRequest(GraphRequestBuilder httpRequestBuilder) throws IOException {
4061         try {
4062             return new GraphIterator(executeJsonRequest(httpRequestBuilder));
4063         } catch (JSONException e) {
4064             throw new IOException(e.getMessage(), e);
4065         }
4066     }
4067 
4068     private JSONObject executeJsonRequest(GraphRequestBuilder httpRequestBuilder) throws IOException {
4069         JSONObject jsonResponse = null;
4070         boolean isThrottled;
4071         do {
4072             HttpRequestBase request = httpRequestBuilder
4073                     .setAccessToken(token.getAccessToken())
4074                     .build();
4075 
4076             // DEBUG only, disable gzip encoding
4077             //request.setHeader("Accept-Encoding", "");
4078             try (
4079                     CloseableHttpResponse response = httpClient.execute(request)
4080             ) {
4081                 if (response.getStatusLine().getStatusCode() == HttpStatus.SC_BAD_REQUEST) {
4082                     LOGGER.warn(response.getStatusLine());
4083                 }
4084                 isThrottled = handleThrottling(response);
4085                 if (!isThrottled) {
4086                     jsonResponse = new JsonResponseHandler().handleResponse(response);
4087                 }
4088             }
4089         } while (isThrottled);
4090         return jsonResponse;
4091     }
4092 
4093 
4094     /**
4095      * Execute graph request and wrap response in a graph object
4096      * @param httpRequestBuilder request builder
4097      * @return returned graph object
4098      * @throws IOException on error
4099      */
4100     private GraphObject executeGraphRequest(GraphRequestBuilder httpRequestBuilder) throws IOException {
4101         HttpRequestBase request = httpRequestBuilder
4102                 .setAccessToken(token.getAccessToken())
4103                 .build();
4104 
4105         GraphObject graphObject = null;
4106         boolean isThrottled;
4107         do {
4108             // DEBUG only, disable gzip encoding
4109             //request.setHeader("Accept-Encoding", "");
4110             try (
4111                     CloseableHttpResponse response = httpClient.execute(request)
4112             ) {
4113                 if (response.getStatusLine().getStatusCode() == HttpStatus.SC_BAD_REQUEST) {
4114                     LOGGER.warn("Request returned " + response.getStatusLine());
4115                 }
4116                 isThrottled = handleThrottling(response);
4117                 if (!isThrottled) {
4118                     graphObject = new GraphObject(new JsonResponseHandler().handleResponse(response));
4119                     graphObject.statusCode = response.getStatusLine().getStatusCode();
4120                 }
4121             }
4122         } while (isThrottled);
4123         return graphObject;
4124     }
4125 
4126     /**
4127      * Detect throttling and wait according to Retry-After header.
4128      * See <a href="https://learn.microsoft.com/en-us/graph/throttling">https://learn.microsoft.com/en-us/graph/throttling</a>
4129      * @param response HTTP response
4130      * @return true if throttled, false otherwise
4131      */
4132     private boolean handleThrottling(CloseableHttpResponse response) {
4133         long retryDelay = 0;
4134         if (response.getStatusLine().getStatusCode() == HttpStatus.SC_TOO_MANY_REQUESTS) {
4135             LOGGER.info("Detected throttling " + response.getStatusLine());
4136             Header retryAfter = response.getFirstHeader("Retry-After");
4137             if (retryAfter != null) {
4138                 retryDelay = Long.parseLong(retryAfter.getValue()) + 1;
4139                 waitRetryDelay(retryDelay);
4140             }
4141         } else if (response.getStatusLine().getStatusCode() == HttpStatus.SC_SERVICE_UNAVAILABLE) {
4142             LOGGER.info("Detected graph request error, waiting to retry " + response.getStatusLine());
4143             retryDelay = 5;
4144             waitRetryDelay(retryDelay);
4145         }
4146         return retryDelay > 0;
4147     }
4148 
4149     private void waitRetryDelay(long retryDelay) {
4150         LOGGER.debug("Waiting " + retryDelay + " seconds to retry request");
4151         try {
4152             Thread.sleep(retryDelay * 1000L);
4153         } catch (InterruptedException e) {
4154             Thread.currentThread().interrupt();
4155         }
4156     }
4157 
4158     /**
4159      * Check if itemName is long and base64 encoded.
4160      * User-generated item names are usually short
4161      *
4162      * @param itemName item name
4163      * @return true if itemName is an EWS item id
4164      */
4165     protected static boolean isItemId(String itemName) {
4166         // Length 72 for immutableId, 140 for classic id
4167         return (itemName.length() >= 140 || itemName.length() == 72)
4168                 // the item name is base64url
4169                 && itemName.matches("^([A-Za-z0-9-_]{4})*([A-Za-z0-9-_]{4}|[A-Za-z0-9-_]{3}=|[A-Za-z0-9-_]{2}==)\\.EML$")
4170                 && itemName.indexOf(' ') < 0;
4171     }
4172 
4173 }