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.exchange.ExchangeSession;
28  import davmail.exchange.VCalendar;
29  import davmail.exchange.VObject;
30  import davmail.exchange.VProperty;
31  import davmail.exchange.auth.O365Token;
32  import davmail.http.HttpClientAdapter;
33  import davmail.http.URIUtil;
34  import davmail.ui.tray.DavGatewayTray;
35  import davmail.util.IOUtil;
36  import davmail.util.StringUtil;
37  import org.apache.http.Header;
38  import org.apache.http.HttpStatus;
39  import org.apache.http.client.methods.CloseableHttpResponse;
40  import org.apache.http.client.methods.HttpDelete;
41  import org.apache.http.client.methods.HttpGet;
42  import org.apache.http.client.methods.HttpPatch;
43  import org.apache.http.client.methods.HttpPost;
44  import org.apache.http.client.methods.HttpPut;
45  import org.apache.http.client.methods.HttpRequestBase;
46  import org.codehaus.jettison.json.JSONArray;
47  import org.codehaus.jettison.json.JSONException;
48  import org.codehaus.jettison.json.JSONObject;
49  import org.htmlcleaner.HtmlCleaner;
50  import org.htmlcleaner.TagNode;
51  
52  import javax.mail.MessagingException;
53  import javax.mail.internet.MimeMessage;
54  import javax.mail.internet.MimeUtility;
55  import java.io.ByteArrayInputStream;
56  import java.io.FilterInputStream;
57  import java.io.IOException;
58  import java.io.InputStream;
59  import java.io.StringReader;
60  import java.net.URI;
61  import java.nio.charset.StandardCharsets;
62  import java.text.ParseException;
63  import java.text.SimpleDateFormat;
64  import java.util.ArrayList;
65  import java.util.Collections;
66  import java.util.Date;
67  import java.util.HashMap;
68  import java.util.HashSet;
69  import java.util.Iterator;
70  import java.util.List;
71  import java.util.Locale;
72  import java.util.Map;
73  import java.util.MissingResourceException;
74  import java.util.NoSuchElementException;
75  import java.util.ResourceBundle;
76  import java.util.Set;
77  import java.util.TimeZone;
78  import java.util.zip.GZIPInputStream;
79  
80  import static davmail.exchange.graph.GraphObject.convertTimezoneFromExchange;
81  
82  /**
83   * Implement ExchangeSession based on Microsoft Graph
84   */
85  public class GraphExchangeSession extends ExchangeSession {
86  
87      /**
88       * Graph folder is identified by mailbox and id
89       */
90      protected class Folder extends ExchangeSession.Folder {
91          public FolderId folderId;
92          protected String specialFlag = "";
93  
94          protected void setSpecialFlag(String specialFlag) {
95              this.specialFlag = "\\" + specialFlag + " ";
96          }
97  
98          /**
99           * Get IMAP folder flags.
100          *
101          * @return folder flags in IMAP format
102          */
103         @Override
104         public String getFlags() {
105             if (noInferiors) {
106                 return specialFlag + "\\NoInferiors";
107             } else if (hasChildren) {
108                 return specialFlag + "\\HasChildren";
109             } else {
110                 return specialFlag + "\\HasNoChildren";
111             }
112         }
113     }
114 
115     protected class Event extends ExchangeSession.Event {
116 
117         public FolderId folderId;
118 
119         public String id;
120 
121         protected GraphObject graphObject;
122 
123         public Event(String folderPath, FolderId folderId, GraphObject graphObject) {
124             this.folderPath = folderPath;
125             this.folderId = folderId;
126 
127             if ("IPF.Appointment".equals(folderId.folderClass) && graphObject.optString("taskstatus") != null) {
128                 // replace folder on task items requested as part of the default calendar
129                 try {
130                     this.folderId = getFolderId(TASKS);
131                 } catch (IOException e) {
132                     LOGGER.warn("Unable to replace folder with tasks");
133                 }
134             }
135 
136             this.graphObject = graphObject;
137 
138             id = graphObject.optString("id");
139             etag = graphObject.optString("changeKey");
140 
141             displayName = graphObject.optString("subject");
142             subject = graphObject.optString("subject");
143 
144             itemName = StringUtil.base64ToUrl(id) + ".EML";
145         }
146 
147         public Event(String folderPath, String itemName, String contentClass, String itemBody, String etag, String noneMatch) throws IOException {
148             super(folderPath, itemName, contentClass, itemBody, etag, noneMatch);
149             folderId = getFolderId(folderPath);
150         }
151 
152         @Override
153         public byte[] getEventContent() throws IOException {
154             byte[] content;
155             if (LOGGER.isDebugEnabled()) {
156                 LOGGER.debug("Get event: " + itemName);
157             }
158             try {
159                 if ("IPF.Task".equals(folderId.folderClass)) {
160                     VCalendar localVCalendar = new VCalendar();
161                     VObject vTodo = new VObject();
162                     vTodo.type = "VTODO";
163                     localVCalendar.setTimezone(getVTimezone());
164                     vTodo.setPropertyValue("LAST-MODIFIED", graphObject.optString("lastModifiedDateTime"));
165                     vTodo.setPropertyValue("CREATED", graphObject.optString("createdDateTime"));
166                     // use item id as uid
167                     vTodo.setPropertyValue("UID", graphObject.optString("id"));
168                     vTodo.setPropertyValue("TITLE", graphObject.optString("title"));
169                     vTodo.setPropertyValue("SUMMARY", graphObject.optString("title"));
170 
171                     vTodo.addProperty(convertBodyToVproperty("DESCRIPTION", graphObject));
172 
173                     // TODO refactor
174                     vTodo.setPropertyValue("PRIORITY", convertPriorityFromExchange(graphObject.optString("importance")));
175                     // not supported over graph
176                     //vTodo.setPropertyValue("PERCENT-COMPLETE", );
177                     vTodo.setPropertyValue("STATUS", taskTovTodoStatusMap.get(graphObject.optString("status")));
178 
179                     vTodo.setPropertyValue("DUE;VALUE=DATE", convertDateTimeTimeZoneToTaskDate(graphObject.optDateTimeTimeZone("dueDateTime")));
180                     vTodo.setPropertyValue("DTSTART;VALUE=DATE", convertDateTimeTimeZoneToTaskDate(graphObject.optDateTimeTimeZone("startDateTime")));
181                     vTodo.setPropertyValue("COMPLETED;VALUE=DATE", convertDateTimeTimeZoneToTaskDate(graphObject.optDateTimeTimeZone("completedDateTime")));
182 
183                     vTodo.setPropertyValue("CATEGORIES", graphObject.optString("categories"));
184 
185                     localVCalendar.addVObject(vTodo);
186                     content = localVCalendar.toString().getBytes(StandardCharsets.UTF_8);
187                 } else {
188                     // with graph API there is no way to directly retrieve the MIME content to access VCALENDAR object
189 
190                     VCalendar localVCalendar = new VCalendar();
191                     // TODO: set email?
192                     // TODO: set timezone based on start date timezone
193                     localVCalendar.setTimezone(getVTimezone());
194                     localVCalendar.addVObject(buildVEvent(graphObject));
195 
196                     handleException(localVCalendar, graphObject);
197 
198                     handleRecurrence(localVCalendar, graphObject);
199 
200                     content = localVCalendar.toString().getBytes(StandardCharsets.UTF_8);
201                 }
202             } catch (Exception e) {
203                 throw new IOException(e.getMessage(), e);
204             }
205             return content;
206         }
207 
208         private void handleException(VCalendar localVCalendar, GraphObject graphObject) throws DavMailException, JSONException {
209             JSONArray cancelledOccurrences = graphObject.optJSONArray("cancelledOccurrences");
210             if (cancelledOccurrences != null) {
211                 HashSet<String> exDateValues = new HashSet<>();
212                 VProperty startDate = localVCalendar.getFirstVevent().getProperty("DTSTART");
213                 for (int i = 0; i < cancelledOccurrences.length(); i++) {
214                     String cancelledOccurrence = null;
215                     try {
216                         cancelledOccurrence = cancelledOccurrences.getString(i);
217                         cancelledOccurrence = cancelledOccurrence.substring(cancelledOccurrence.lastIndexOf('.') + 1);
218                         String cancelledDate = convertDateFromExchange(cancelledOccurrence);
219 
220                         exDateValues.add(cancelledDate.substring(0, 8) + startDate.getValue().substring(8));
221 
222                     } catch (IndexOutOfBoundsException | JSONException e) {
223                         LOGGER.warn("Invalid cancelled occurrence: " + cancelledOccurrence);
224                     }
225                 }
226                 // add EXDATE values in a single property, will be converted back to multiple lines by fixICS
227                 VProperty exDate = new VProperty("EXDATE", StringUtil.join(exDateValues, ","));
228                 exDate.setParam("TZID", startDate.getParamValue("TZID"));
229                 localVCalendar.addFirstVeventProperty(exDate);
230             }
231 
232             JSONArray exceptionOccurrences = graphObject.optJSONArray("exceptionOccurrences");
233             if (exceptionOccurrences != null) {
234                 for (int i = 0; i < exceptionOccurrences.length(); i++) {
235                     GraphObject exceptionOccurrence = new GraphObject(exceptionOccurrences.optJSONObject(i)
236                             // need to override uid, iCalUid is different for each occurrence on server
237                             .put("iCalUId", graphObject.optString("iCalUId")));
238                     VObject vEvent = buildVEvent(exceptionOccurrence);
239                     vEvent.addProperty(exceptionOccurrence.getRecurrenceId());
240                     localVCalendar.addVObject(vEvent);
241                 }
242             }
243         }
244 
245         private VObject buildVEvent(GraphObject jsonEvent) throws DavMailException, JSONException {
246             VObject vEvent = new VObject();
247             vEvent.type = "VEVENT";
248             vEvent.setPropertyValue("UID", jsonEvent.optString("iCalUId"));
249             vEvent.setPropertyValue("SUMMARY", jsonEvent.optString("subject"));
250 
251             vEvent.addProperty(convertBodyToVproperty("DESCRIPTION", jsonEvent));
252 
253             vEvent.setPropertyValue("LAST-MODIFIED", jsonEvent.optString("lastModifiedDateTime"));
254             vEvent.setPropertyValue("DTSTAMP", jsonEvent.optString("lastModifiedDateTime"));
255             vEvent.addProperty(convertDateTimeTimeZoneToVproperty("DTSTART", jsonEvent.optJSONObject("start")));
256             vEvent.addProperty(convertDateTimeTimeZoneToVproperty("DTEND", jsonEvent.optJSONObject("end")));
257 
258             vEvent.setPropertyValue("LOCATION", jsonEvent.optString("location", "displayName"));
259             vEvent.setPropertyValue("CATEGORIES", jsonEvent.optString("categories"));
260 
261             vEvent.setPropertyValue("CLASS", convertClassFromExchange(jsonEvent.optString("sensitivity")));
262 
263             // custom microsoft properties
264             String showAs = jsonEvent.optString("showAs");
265             if (showAs != null) {
266                 vEvent.setPropertyValue("X-MICROSOFT-CDO-BUSYSTATUS", showAs.toUpperCase());
267             }
268             String isAllDay = jsonEvent.optString("isAllDay");
269             if (isAllDay != null) {
270                 vEvent.setPropertyValue("X-MICROSOFT-CDO-ALLDAYEVENT", isAllDay.toUpperCase());
271             }
272             String responseRequested = jsonEvent.optString("responseRequested");
273             if (responseRequested != null) {
274                 vEvent.setPropertyValue("X-MICROSOFT-CDO-ISRESPONSEREQUESTED", responseRequested.toUpperCase());
275             }
276 
277             if (graphObject.optBoolean("isReminderOn")) {
278                 VObject vAlarm = new VObject();
279                 vAlarm.type = "VALARM";
280                 vAlarm.addPropertyValue("ACTION", "DISPLAY");
281                 int reminderMinutesBeforeStart = jsonEvent.optInt("reminderMinutesBeforeStart");
282                 if (reminderMinutesBeforeStart > 0) {
283                     vAlarm.addPropertyValue("TRIGGER", "-PT" + reminderMinutesBeforeStart + "M");
284                 }
285                 vEvent.addVObject(vAlarm);
286             }
287 
288             vEvent.setPropertyValue("X-MOZ-SEND-INVITATIONS", jsonEvent.optString("xmozsendinvitations"));
289             vEvent.setPropertyValue("X-MOZ-LASTACK", jsonEvent.optString("xmozlastack"));
290             vEvent.setPropertyValue("X-MOZ-SNOOZE-TIME", jsonEvent.optString("xmozsnoozetime"));
291 
292             setAttendees(vEvent, jsonEvent);
293 
294             return vEvent;
295         }
296 
297         private void handleRecurrence(VCalendar localVCalendar, GraphObject graphObject) throws JSONException, DavMailException {
298 
299             JSONObject recurrence = graphObject.optJSONObject("recurrence");
300             if (recurrence != null) {
301                 StringBuilder rruleValue = new StringBuilder();
302                 JSONObject pattern = recurrence.getJSONObject("pattern");
303                 JSONObject range = recurrence.getJSONObject("range");
304                 // daily, weekly, absoluteMonthly, relativeMonthly, absoluteYearly, relativeYearly
305                 String patternType = pattern.getString("type");
306                 int interval = pattern.getInt("interval");
307                 //  first, second, third, fourth, last
308                 String index = pattern.optString("index", null);
309                 // convert index
310                 if ("first".equals(index)) {
311                     index = "1";
312                 } else if ("second".equals(index)) {
313                     index = "2";
314                 } else if ("third".equals(index)) {
315                     index = "3";
316                 } else if ("fourth".equals(index)) {
317                     index = "4";
318                 } else if ("last".equals(index)) {
319                     index = "-1";
320                 }
321                 // The month in which the event occurs
322                 String month = pattern.getString("month");
323                 if ("0".equals(month)) {
324                     month = null;
325                 }
326                 // The first day of the week
327                 String firstDayOfWeek = pattern.getString("firstDayOfWeek");
328                 // The day of the month on which the event occurs
329                 String dayOfMonth = pattern.getString("dayOfMonth");
330                 if ("0".equals(dayOfMonth)) {
331                     dayOfMonth = null;
332                 }
333                 // A collection of the days of the week on which the event occurs
334                 JSONArray daysOfWeek = pattern.optJSONArray("daysOfWeek");
335                 String rangeType = range.getString("type");
336 
337                 rruleValue.append("FREQ=");
338                 if (patternType.startsWith("absolute") || patternType.startsWith("relative")) {
339                     rruleValue.append(patternType.substring(8).toUpperCase());
340                 } else {
341                     rruleValue.append(patternType.toUpperCase());
342                 }
343                 if (rangeType.equals("endDate")) {
344                     String endDate = buildUntilDate(range.getString("endDate"), range.getString("recurrenceTimeZone"), graphObject.optJSONObject("start"));
345                     rruleValue.append(";UNTIL=").append(endDate);
346                 }
347                 if (interval > 0) {
348                     rruleValue.append(";INTERVAL=").append(interval);
349                 }
350                 if (dayOfMonth != null && !dayOfMonth.isEmpty()) {
351                     rruleValue.append(";BYMONTHDAY=").append(dayOfMonth);
352                 }
353                 if (month != null && !month.isEmpty()) {
354                     rruleValue.append(";BYMONTH=").append(month);
355                 }
356                 if (daysOfWeek != null && daysOfWeek.length() > 0) {
357                     ArrayList<String> days = new ArrayList<>();
358                     for (int i = 0; i < daysOfWeek.length(); i++) {
359                         StringBuilder byDay = new StringBuilder();
360                         if (index != null && !"weekly".equals(patternType)) {
361                             byDay.append(index);
362                         }
363                         byDay.append(daysOfWeek.getString(i).substring(0, 2).toUpperCase());
364                         days.add(byDay.toString());
365                     }
366                     rruleValue.append(";BYDAY=").append(String.join(",", days));
367                 }
368                 if ("weekly".equals(patternType) && firstDayOfWeek.length() >= 2) {
369                     rruleValue.append(";WKST=").append(firstDayOfWeek.substring(0, 2).toUpperCase());
370                 }
371 
372                 localVCalendar.addFirstVeventProperty(new VProperty("RRULE", rruleValue.toString()));
373             }
374         }
375 
376         private String buildUntilDate(String date, String timeZone, JSONObject startDate) throws DavMailException {
377             String result = null;
378             if (date != null && date.length() == 10) {
379                 String startDateTimeZone = startDate.optString("timeZone");
380                 String startDateDateTime = startDate.optString("dateTime");
381                 String untilDateTime = date + startDateDateTime.substring(10);
382 
383                 if (timeZone == null) {
384                     timeZone = startDateTimeZone;
385                 }
386 
387                 SimpleDateFormat parser = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
388                 SimpleDateFormat formatter = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
389                 formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
390                 parser.setTimeZone(TimeZone.getTimeZone(convertTimezoneFromExchange(timeZone)));
391                 try {
392                     result = formatter.format(parser.parse(untilDateTime));
393                 } catch (ParseException e) {
394                     throw new DavMailException("EXCEPTION_INVALID_DATE", date);
395                 }
396             }
397             return result;
398         }
399 
400         private String convertOriginalStartDate(String originalStart, String originalStartTimeZone) throws DavMailException {
401             String result = null;
402             if (originalStart != null) {
403                 SimpleDateFormat parser = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
404                 SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
405                 formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
406                 parser.setTimeZone(TimeZone.getTimeZone(convertTimezoneFromExchange(originalStartTimeZone)));
407                 try {
408                     result = formatter.format(parser.parse(originalStart));
409                 } catch (ParseException e) {
410                     throw new DavMailException("EXCEPTION_INVALID_DATE", originalStart);
411                 }
412             }
413             return result;
414         }
415 
416 
417         private void setAttendees(VObject vEvent, GraphObject jsonEvent) throws JSONException {
418             // handle organizer
419             JSONObject organizer = jsonEvent.optJSONObject("organizer");
420             if (organizer != null) {
421                 vEvent.addProperty(convertEmailAddressToVproperty("ORGANIZER", organizer.optJSONObject("emailAddress")));
422             }
423 
424             JSONArray attendees = jsonEvent.optJSONArray("attendees");
425             if (attendees != null) {
426                 for (int i = 0; i < attendees.length(); i++) {
427                     JSONObject attendee = attendees.getJSONObject(i);
428                     JSONObject emailAddress = attendee.getJSONObject("emailAddress");
429                     VProperty attendeeProperty = convertEmailAddressToVproperty("ATTENDEE", emailAddress);
430 
431                     // The response type. Possible values are: none, organizer, tentativelyAccepted, accepted, declined, notResponded.
432                     String responseType = attendee.getJSONObject("status").optString("response");
433                     String myResponseType = graphObject.optString("responseStatus", "response");
434 
435                     // TODO Test if applicable
436                     if (email.equalsIgnoreCase(emailAddress.optString("address")) && myResponseType != null) {
437                         attendeeProperty.addParam("PARTSTAT", responseTypeToPartstat(myResponseType));
438                     } else {
439                         attendeeProperty.addParam("PARTSTAT", responseTypeToPartstat(responseType));
440                     }
441                     // the attendee type: required, optional, resource.
442                     String type = attendee.optString("type");
443                     if ("required".equals(type)) {
444                         attendeeProperty.addParam("ROLE", "REQ-PARTICIPANT");
445                     } else if ("optional".equals(type)) {
446                         attendeeProperty.addParam("ROLE", "OPT-PARTICIPANT");
447                     }
448 
449                     vEvent.addProperty(attendeeProperty);
450                 }
451             }
452         }
453 
454         /**
455          * Convert response type to partstat value
456          *
457          * @param responseType response type
458          * @return partstat value
459          */
460         private String responseTypeToPartstat(String responseType) {
461             // The response type. Possible values are: none, organizer, tentativelyAccepted, accepted, declined, notResponded.
462             if ("accepted".equals(responseType) || "organizer".equals(responseType)) {
463                 return "ACCEPTED";
464             } else if ("tentativelyAccepted".equals(responseType)) {
465                 return "TENTATIVE";
466             } else if ("declined".equals(responseType)) {
467                 return "DECLINED";
468             } else {
469                 return "NEEDS-ACTION";
470             }
471         }
472 
473         @Override
474         public ItemResult createOrUpdate() throws IOException {
475 
476             String id = null;
477             String currentEtag = null;
478             JSONObject existingJsonEvent = getEventIfExists(folderId, itemName);
479             if (existingJsonEvent != null) {
480                 id = existingJsonEvent.optString("id", null);
481                 currentEtag = new GraphObject(existingJsonEvent).optString("changeKey");
482             }
483 
484             ItemResult itemResult = new ItemResult();
485             if ("*".equals(noneMatch)) {
486                 // create requested but already exists
487                 if (id != null) {
488                     itemResult.status = HttpStatus.SC_PRECONDITION_FAILED;
489                     return itemResult;
490                 }
491             } else if (etag != null) {
492                 // update requested
493                 if (id == null || !etag.equals(currentEtag)) {
494                     itemResult.status = HttpStatus.SC_PRECONDITION_FAILED;
495                     return itemResult;
496                 }
497             }
498 
499             VObject vEvent = vCalendar.getFirstVevent();
500             try {
501                 JSONObject jsonObject = new JSONObject();
502 
503                 // urlcompname is an extended property, wrap in GraphObject
504                 GraphObject localGraphObject = new GraphObject(jsonObject);
505                 localGraphObject.put("urlcompname", itemName);
506                 jsonObject.put("subject", vEvent.getPropertyValue("SUMMARY"));
507 
508                 VProperty dtStart = vEvent.getProperty("DTSTART");
509                 String dtStartTzid = dtStart.getParamValue("TZID");
510                 jsonObject.put("start", new JSONObject().put("dateTime", vCalendar.convertCalendarDateToGraph(dtStart.getValue(), dtStartTzid)).put("timeZone", dtStartTzid));
511 
512                 VProperty dtEnd = vEvent.getProperty("DTEND");
513                 String dtEndTzid = dtEnd.getParamValue("TZID");
514                 jsonObject.put("end", new JSONObject().put("dateTime", vCalendar.convertCalendarDateToGraph(dtEnd.getValue(), dtEndTzid)).put("timeZone", dtEndTzid));
515 
516                 VProperty descriptionProperty = vEvent.getProperty("DESCRIPTION");
517                 String description = null;
518                 if (descriptionProperty != null) {
519                     description = vEvent.getProperty("DESCRIPTION").getParamValue("ALTREP");
520                 }
521                 if (description != null && description.startsWith("data:text/html,")) {
522                     description = URIUtil.decode(description.replaceFirst("data:text/html,", ""));
523                     jsonObject.put("body", new JSONObject().put("content", description).put("contentType", "html"));
524                 } else {
525                     description = vEvent.getPropertyValue("DESCRIPTION");
526                     jsonObject.put("body", new JSONObject().put("content", description).put("contentType", "text"));
527                 }
528 
529                 String location = vEvent.getPropertyValue("LOCATION");
530                 jsonObject.put("location", new JSONObject().put("displayName", location));
531 
532                 localGraphObject.setCategories(vEvent.getPropertyValue("CATEGORIES"));
533                 // Collect categories on multiple lines
534                 List<VProperty> categories = vEvent.getProperties("CATEGORIES");
535                 if (categories != null) {
536                     HashSet<String> categoryValues = new HashSet<>();
537                     for (VProperty category : categories) {
538                         categoryValues.add(category.getValue());
539                     }
540                     localGraphObject.setCategories(StringUtil.join(categoryValues, ","));
541                 }
542 
543                 // handle reminder configuration
544                 jsonObject.put("isReminderOn", vCalendar.hasVAlarm());
545                 jsonObject.put("reminderMinutesBeforeStart", vCalendar.getReminderMinutesBeforeStart());
546 
547                 handleRrule(jsonObject, vEvent.getProperty("RRULE"));
548 
549                 if (id != null) {
550                     // assume we can delete occurrence only on new event
551                     List<VProperty> exdateProperty = vEvent.getProperties("EXDATE");
552                     if (exdateProperty != null && !exdateProperty.isEmpty()) {
553                         JSONArray cancelledOccurrences = new JSONArray();
554                         for (VProperty exdate : exdateProperty) {
555                             String exdateTzid = exdate.getParamValue("TZID");
556                             String exDateValue = vCalendar.convertCalendarDateToGraph(exdate.getValue(), exdateTzid);
557                             deleteEventOccurrence(id, exDateValue);
558                         }
559                         jsonObject.put("cancelledOccurrences", cancelledOccurrences);
560                     }
561 
562                     handleModifiedOccurrences(vCalendar, existingJsonEvent);
563                 }
564 
565                 GraphRequestBuilder graphRequestBuilder = new GraphRequestBuilder();
566                 if (id == null) {
567                     graphRequestBuilder.setMethod(HttpPost.METHOD_NAME)
568                             .setMailbox(folderId.mailbox)
569                             .setObjectType("calendars")
570                             .setObjectId(folderId.id)
571                             .setChildType("events")
572                             .setJsonBody(jsonObject);
573                 } else {
574                     graphRequestBuilder.setMethod(HttpPatch.METHOD_NAME)
575                             .setMailbox(folderId.mailbox)
576                             .setObjectType("events")
577                             .setObjectId(id)
578                             .setJsonBody(jsonObject);
579                 }
580 
581                 GraphObject graphResponse = executeGraphRequest(graphRequestBuilder);
582                 itemResult.status = graphResponse.statusCode;
583 
584                 // TODO review itemName logic
585                 itemResult.itemName = graphResponse.optString("id") + ".EML";
586                 itemResult.etag = graphResponse.optString("changeKey");
587 
588             } catch (JSONException e) {
589                 throw new IOException(e);
590             }
591 
592             return itemResult;
593         }
594 
595         private void handleRrule(JSONObject jsonEvent, VProperty rrule) throws JSONException, DavMailException {
596             if (rrule != null) {
597                 JSONObject start = jsonEvent.getJSONObject("start");
598                 String startDate = start.getString("dateTime").substring(0, 10);
599                 String dayOfWeek = getDayOfWeek(startDate);
600 
601                 Map<String, String> rrules = rrule.getValuesAsMap();
602                 String frequency = rrules.get("FREQ");
603                 if ("WEEKLY".equals(frequency)) {
604                     JSONObject pattern = new JSONObject().put("type", "weekly").put("interval", 1).put("daysOfWeek", new JSONArray().put(dayOfWeek));
605                     JSONObject range = new JSONObject().put("type", "noEnd").put("startDate", startDate).put("endDate", "0001-01-01");
606                     jsonEvent.put("recurrence", new JSONObject().put("pattern", pattern).put("range", range));
607                 }
608             }
609         }
610 
611         private String getDayOfWeek(String date) throws DavMailException {
612             if (date != null) {
613                 try {
614                     SimpleDateFormat parser = new SimpleDateFormat("yyyy-MM-dd");
615                     parser.setTimeZone(TimeZone.getTimeZone("UTC"));
616                     SimpleDateFormat formatter = new SimpleDateFormat("EEEE", Locale.ENGLISH);
617                     formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
618                     return formatter.format(parser.parse(date));
619                 } catch (ParseException e) {
620                     throw new DavMailException("EXCEPTION_INVALID_DATE", date);
621                 }
622             }
623             return null;
624         }
625 
626         private void handleModifiedOccurrences(VCalendar vCalendar, JSONObject existingJsonEvent) throws IOException, JSONException {
627             for (VObject modifiedOccurrence : vCalendar.getModifiedOccurrences()) {
628                 VProperty originalDateProperty = modifiedOccurrence.getProperty("RECURRENCE-ID");
629                 String originalDateZulu;
630                 try {
631                     originalDateZulu = vCalendar.convertCalendarDateToExchangeZulu(originalDateProperty.getValue(), originalDateProperty.getParamValue("TZID"));
632                 } catch (IOException e) {
633                     throw new DavMailException("EXCEPTION_INVALID_DATE", originalDateProperty.getValue());
634                 }
635                 LOGGER.debug("Looking for occurrence " + originalDateZulu);
636                 // try to find modified occurrence in existing event
637                 JSONArray exceptionOccurrences = existingJsonEvent.optJSONArray("exceptionOccurrences");
638                 boolean occurrenceFound = false;
639                 if (exceptionOccurrences != null) {
640                     for (int i = 0; i < exceptionOccurrences.length(); i++) {
641                         JSONObject exceptionOccurrence = exceptionOccurrences.optJSONObject(i);
642                         String exceptionOriginalStart = convertOriginalStartDate(exceptionOccurrence.optString("originalStart"), exceptionOccurrence.optString("originalStartTimeZone"));
643                         LOGGER.debug("Looking at occurrence " + exceptionOriginalStart + " for " + originalDateZulu);
644                         if (originalDateZulu.equals(exceptionOriginalStart)) {
645                             updateExceptionOccurrence(modifiedOccurrence, exceptionOccurrence.getString("id"));
646                             occurrenceFound = true;
647                             break;
648                         }
649                     }
650                 }
651                 if (!occurrenceFound) {
652                     createNewModifiedOccurrence(modifiedOccurrence, existingJsonEvent, originalDateZulu);
653                 }
654             }
655         }
656 
657         private void createNewModifiedOccurrence(VObject vEvent, JSONObject existingJsonEvent, String originalDateZulu) throws IOException, JSONException {
658             String startDateTime = originalDateZulu.substring(0, 10) + "T00:00:00.0000000";
659             String endDateTime = originalDateZulu.substring(0, 10) + "T23:59:59.9999999";
660             GraphObject graphResponse = executeGraphRequest(new GraphRequestBuilder().setMethod(HttpGet.METHOD_NAME)
661                     .setMailbox(folderId.mailbox)
662                     .setObjectType("events")
663                     .setObjectId(existingJsonEvent.optString("id"))
664                     .setChildType("instances")
665                     .setStartDateTime(startDateTime)
666                     .setEndDateTime(endDateTime));
667 
668             JSONArray occurrences = graphResponse.optJSONArray("value");
669             if (occurrences != null && occurrences.length() > 0) {
670                 for (int i = 0; i < occurrences.length(); i++) {
671                     JSONObject occurrence = occurrences.getJSONObject(i);
672                     String occurrenceId = occurrence.optString("id");
673                     if (occurrenceId != null) {
674                         updateExceptionOccurrence(vEvent, occurrenceId);
675                     }
676                 }
677             }
678         }
679 
680         private void updateExceptionOccurrence(VObject modifiedOccurrence, String exceptionOccurrenceId) throws IOException, JSONException {
681             LOGGER.debug("Updating occurrence " + modifiedOccurrence.getPropertyValue("SUMMARY") + " " + modifiedOccurrence.getPropertyValue("RECCURRENCE-ID"));
682 
683             JSONObject jsonObject = new JSONObject();
684             jsonObject.put("subject", modifiedOccurrence.getPropertyValue("SUMMARY"));
685 
686             VProperty dtStart = modifiedOccurrence.getProperty("DTSTART");
687             String dtStartTzid = dtStart.getParamValue("TZID");
688             jsonObject.put("start", new JSONObject().put("dateTime", vCalendar.convertCalendarDateToGraph(dtStart.getValue(), dtStartTzid)).put("timeZone", dtStartTzid));
689 
690             VProperty dtEnd = modifiedOccurrence.getProperty("DTEND");
691             String dtEndTzid = dtEnd.getParamValue("TZID");
692             jsonObject.put("end", new JSONObject().put("dateTime", vCalendar.convertCalendarDateToGraph(dtEnd.getValue(), dtEndTzid)).put("timeZone", dtEndTzid));
693 
694             GraphObject graphResponse = executeGraphRequest(new GraphRequestBuilder().setMethod(HttpPatch.METHOD_NAME)
695                     .setMailbox(folderId.mailbox)
696                     .setObjectType("events")
697                     .setObjectId(exceptionOccurrenceId)
698                     .setJsonBody(jsonObject));
699 
700             LOGGER.debug("Updated occurrence: " + graphResponse.jsonObject.toString());
701         }
702 
703         private void deleteEventOccurrence(String id, String exDateValue) throws IOException, JSONException {
704             String startDateTime = exDateValue.substring(0, 10) + "T00:00:00.0000000";
705             String endDateTime = exDateValue.substring(0, 10) + "T23:59:59.9999999";
706             GraphObject graphResponse = executeGraphRequest(new GraphRequestBuilder().setMethod(HttpGet.METHOD_NAME)
707                     .setMailbox(folderId.mailbox)
708                     .setObjectType("events")
709                     .setObjectId(id)
710                     .setChildType("instances")
711                     .setStartDateTime(startDateTime)
712                     .setEndDateTime(endDateTime));
713 
714             JSONArray occurrences = graphResponse.optJSONArray("value");
715             if (occurrences != null && occurrences.length() > 0) {
716                 for (int i = 0; i < occurrences.length(); i++) {
717                     JSONObject occurrence = occurrences.getJSONObject(i);
718                     String occurrenceId = occurrence.optString("id");
719                     if (occurrenceId != null) {
720                         executeJsonRequest(new GraphRequestBuilder().setMethod(HttpDelete.METHOD_NAME)
721                                 .setMailbox(folderId.mailbox)
722                                 .setObjectType("events")
723                                 .setObjectId(occurrenceId));
724                     }
725                 }
726             }
727         }
728 
729     }
730 
731     private String convertHtmlToText(String htmlText) {
732         StringBuilder builder = new StringBuilder();
733 
734         HtmlCleaner cleaner = new HtmlCleaner();
735         cleaner.getProperties().setDeserializeEntities(true);
736         try {
737             TagNode node = cleaner.clean(new StringReader(htmlText));
738             for (TagNode childNode : node.getAllElementsList(true)) {
739                 builder.append(childNode.getText());
740             }
741         } catch (IOException e) {
742             LOGGER.error("Error converting html to text", e);
743         }
744         return builder.toString();
745     }
746 
747     private VProperty convertBodyToVproperty(String propertyName, GraphObject graphObject) {
748         JSONObject jsonBody = graphObject.optJSONObject("body");
749 
750         if (jsonBody == null) {
751             return new VProperty(propertyName, "");
752         } else {
753             // body is html only over graph by default
754             String content = jsonBody.optString("content");
755             String contentType = jsonBody.optString("contentType");
756             VProperty vProperty;
757 
758             if ("text".equals(contentType)) {
759                 vProperty = new VProperty(propertyName, content);
760             } else {
761                 // html
762                 if (content != null) {
763                     vProperty = new VProperty(propertyName, convertHtmlToText(content));
764                     // remove CR LF from html content
765                     content = content.replace("\n", "").replace("\r", "");
766                     vProperty.addParam("ALTREP", "data:text/html," + URIUtil.encodeWithinQuery(content));
767                 } else {
768                     vProperty = new VProperty(propertyName, null);
769                 }
770 
771             }
772             return vProperty;
773         }
774     }
775 
776     private VProperty convertDateTimeTimeZoneToVproperty(String vPropertyName, JSONObject jsonDateTimeTimeZone) throws DavMailException {
777 
778         if (jsonDateTimeTimeZone != null) {
779             String timeZone = jsonDateTimeTimeZone.optString("timeZone");
780             String dateTime = jsonDateTimeTimeZone.optString("dateTime");
781             VProperty vProperty = new VProperty(vPropertyName, convertDateFromExchange(dateTime));
782             vProperty.addParam("TZID", timeZone);
783             return vProperty;
784         }
785         return new VProperty(vPropertyName, null);
786     }
787 
788     private VProperty convertEmailAddressToVproperty(String propertyName, JSONObject jsonEmailAddress) {
789         VProperty attendeeProperty = new VProperty(propertyName, "mailto:" + jsonEmailAddress.optString("address"));
790         attendeeProperty.addParam("CN", jsonEmailAddress.optString("name"));
791         return attendeeProperty;
792     }
793 
794     private String convertDateTimeTimeZoneToTaskDate(Date exchangeDateValue) {
795         String zuluDateValue = null;
796         if (exchangeDateValue != null) {
797             SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd", Locale.ENGLISH);
798             dateFormat.setTimeZone(GMT_TIMEZONE);
799             zuluDateValue = dateFormat.format(exchangeDateValue);
800         }
801         return zuluDateValue;
802 
803     }
804 
805     protected class Contact extends ExchangeSession.Contact {
806         // item id
807         FolderId folderId;
808         String id;
809 
810         protected Contact(GraphObject response) throws DavMailException {
811             id = response.optString("id");
812             etag = response.optString("@odata.etag");
813 
814             displayName = response.optString("displayname");
815             // prefer urlcompname (client provided item name) for contacts
816             itemName = StringUtil.decodeUrlcompname(response.optString("urlcompname"));
817             // if urlcompname is empty, contact was created on the server side
818             if (itemName == null) {
819                 itemName = StringUtil.base64ToUrl(id) + ".EML";
820             }
821             put("uid", response.optString("uid"));
822 
823             for (GraphField attribute : CONTACT_ATTRIBUTES) {
824                 String alias = attribute.getAlias();
825                 if (!alias.startsWith("smtpemail")) {
826                     String value = response.optString(attribute);
827                     if (value != null && !value.isEmpty()) {
828                         put(alias, value);
829                     }
830                 }
831             }
832 
833             JSONArray emailAddresses = response.optJSONArray("emailAddresses");
834             if (emailAddresses != null) {
835                 for (int i = 0; i < emailAddresses.length(); i++) {
836                     JSONObject emailAddress = emailAddresses.optJSONObject(i);
837                     if (emailAddress != null) {
838                         String email = emailAddress.optString("address");
839                         String type = emailAddress.optString("type");
840                         if (email != null && !email.isEmpty()) {
841                             if ("other".equals(type)) {
842                                 put("smtpemail3", email);
843                             } else if ("personal".equals(type)) {
844                                 put("smtpemail2", email);
845                             } else if ("work".equals(type)) {
846                                 put("smtpemail1", email);
847                             }
848                         }
849                     }
850                 }
851                 // iterate a second time to fill unknown email types
852                 for (int i = 0; i < emailAddresses.length(); i++) {
853                     JSONObject emailAddress = emailAddresses.optJSONObject(i);
854                     if (emailAddress != null) {
855                         String email = emailAddress.optString("address");
856                         String type = emailAddress.optString("type");
857                         if (email != null && !email.isEmpty()) {
858                             if ("unknown".equals(type)) {
859                                 if (get("smtpemail1") == null) {
860                                     put("smtpemail1", email);
861                                 } else if (get("smtpemail2") == null) {
862                                     put("smtpemail2", email);
863                                 } else if (get("smtpemail3") == null) {
864                                     put("smtpemail3", email);
865                                 }
866                             }
867                         }
868                     }
869                 }
870             }
871         }
872 
873         protected Contact(String folderPath, String itemName, Map<String, String> properties, String etag, String noneMatch) {
874             super(folderPath, itemName, properties, etag, noneMatch);
875         }
876 
877         /**
878          * Empty constructor for GalFind
879          */
880         protected Contact() {
881         }
882 
883         /**
884          * Create or update contact.
885          * <a href="https://learn.microsoft.com/en-us/graph/api/user-post-contacts">user-post-contacts</a>
886          *
887          * @return action result
888          * @throws IOException on error
889          */
890         @Override
891         public ItemResult createOrUpdate() throws IOException {
892 
893             FolderId folderId = getFolderId(folderPath);
894             String id = null;
895             String currentEtag = null;
896             JSONObject jsonContact = getContactIfExists(folderId, itemName);
897             if (jsonContact != null) {
898                 id = jsonContact.optString("id", null);
899                 currentEtag = new GraphObject(jsonContact).optString("changeKey");
900             }
901 
902             ItemResult itemResult = new ItemResult();
903             if ("*".equals(noneMatch)) {
904                 // create requested but already exists
905                 if (id != null) {
906                     itemResult.status = HttpStatus.SC_PRECONDITION_FAILED;
907                     return itemResult;
908                 }
909             } else if (etag != null) {
910                 // update requested
911                 if (id == null || !etag.equals(currentEtag)) {
912                     itemResult.status = HttpStatus.SC_PRECONDITION_FAILED;
913                     return itemResult;
914                 }
915             }
916 
917             try {
918                 JSONObject jsonObject = new JSONObject();
919                 GraphObject graphObject = new GraphObject(jsonObject);
920                 for (Map.Entry<String, String> entry : entrySet()) {
921                     if ("keywords".equals(entry.getKey())) {
922                         graphObject.setCategories(entry.getValue());
923                     } else if ("bday".equals(entry.getKey())) {
924                         graphObject.put(entry.getKey(), convertZuluToIso(entry.getValue()));
925                     } else if ("anniversary".equals(entry.getKey())) {
926                         graphObject.put(entry.getKey(), convertZuluToDate(entry.getValue()));
927                     } else if ("photo".equals(entry.getKey())) {
928                         LOGGER.debug("Contact has a photo");
929                     } else if (!entry.getKey().startsWith("email") && !entry.getKey().startsWith("smtpemail")
930                             && !"usersmimecertificate".equals(entry.getKey()) // not supported over Graph
931                             && !"msexchangecertificate".equals(entry.getKey()) // not supported over Graph
932                             && !"pager".equals(entry.getKey()) && !"otherTelephone".equals(entry.getKey()) // see below
933                             && !"fileas".equals(entry.getKey()) && !"outlookmessageclass".equals(entry.getKey())
934                             && !"subject".equals(entry.getKey())
935                     ) {
936                         graphObject.put(entry.getKey(), entry.getValue());
937                     }
938                 }
939 
940                 // pager and otherTelephone is a single field
941                 String pager = get("pager");
942                 if (pager == null) {
943                     pager = get("otherTelephone");
944                 }
945                 graphObject.put("pager", pager);
946 
947                 // force urlcompname
948                 graphObject.put("urlcompname", convertItemNameToEML(itemName));
949 
950                 // handle emails
951                 JSONArray emailAddresses = new JSONArray();
952                 String smtpemail1 = get("smtpemail1");
953                 if (smtpemail1 != null) {
954                     JSONObject emailAddress = new JSONObject();
955                     emailAddress.put("address", smtpemail1);
956                     emailAddress.put("type", "work");
957                     emailAddresses.put(emailAddress);
958                 }
959 
960                 String smtpemail2 = get("smtpemail2");
961                 if (smtpemail2 != null) {
962                     JSONObject emailAddress = new JSONObject();
963                     emailAddress.put("address", smtpemail2);
964                     emailAddress.put("type", "personal");
965                     emailAddresses.put(emailAddress);
966                 }
967 
968                 String smtpemail3 = get("smtpemail3");
969                 if (smtpemail3 != null) {
970                     JSONObject emailAddress = new JSONObject();
971                     emailAddress.put("address", smtpemail3);
972                     emailAddress.put("type", "other");
973                     emailAddresses.put(emailAddress);
974                 }
975                 graphObject.put("emailAddresses", emailAddresses);
976 
977                 GraphRequestBuilder graphRequestBuilder = new GraphRequestBuilder();
978                 if (id == null) {
979                     graphRequestBuilder.setMethod(HttpPost.METHOD_NAME)
980                             .setMailbox(folderId.mailbox)
981                             .setObjectType("contactFolders")
982                             .setObjectId(folderId.id)
983                             .setChildType("contacts")
984                             .setJsonBody(jsonObject);
985                 } else {
986                     graphRequestBuilder.setMethod(HttpPatch.METHOD_NAME)
987                             .setMailbox(folderId.mailbox)
988                             .setObjectType("contactFolders")
989                             .setObjectId(folderId.id)
990                             .setChildType("contacts")
991                             .setChildId(id)
992                             .setJsonBody(jsonObject);
993                 }
994 
995                 GraphObject graphResponse = executeGraphRequest(graphRequestBuilder);
996 
997                 if (LOGGER.isDebugEnabled()) {
998                     LOGGER.debug(graphResponse.toString(4));
999                 }
1000 
1001                 itemResult.status = graphResponse.statusCode;
1002 
1003                 updatePhoto(folderId, graphResponse.optString("id"));
1004 
1005                 // reload to get latest etag
1006                 graphResponse = new GraphObject(getContactIfExists(folderId, itemName));
1007 
1008                 itemResult.itemName = graphResponse.optString("id");
1009                 itemResult.etag = graphResponse.optString("etag");
1010 
1011             } catch (JSONException e) {
1012                 throw new IOException(e);
1013             }
1014             if (itemResult.status == HttpStatus.SC_CREATED) {
1015                 LOGGER.debug("Created contact " + getHref());
1016             } else {
1017                 LOGGER.debug("Updated contact " + getHref());
1018             }
1019 
1020             return itemResult;
1021         }
1022 
1023         private void updatePhoto(FolderId folderId, String contactId) throws IOException {
1024             String photo = get("photo");
1025             if (photo != null) {
1026                 // convert image to jpeg
1027                 byte[] resizedImageBytes = IOUtil.resizeImage(IOUtil.decodeBase64(photo), 90);
1028 
1029                 // Upload resized image to contact photo endpoint
1030                 JSONObject jsonResponse = executeJsonRequest(new GraphRequestBuilder()
1031                         .setMethod(HttpPut.METHOD_NAME)
1032                         .setMailbox(folderId.mailbox)
1033                         .setObjectType("contactFolders")
1034                         .setObjectId(folderId.id)
1035                         .setChildType("contacts")
1036                         .setChildId(contactId)
1037                         .setChildSuffix("photo/$value")
1038                         .setContentType("image/jpeg")
1039                         .setMimeContent(resizedImageBytes));
1040 
1041                 if (LOGGER.isDebugEnabled()) {
1042                     LOGGER.debug(jsonResponse);
1043                 }
1044             } else {
1045                 // Delete the contact photo
1046                 executeJsonRequest(new GraphRequestBuilder()
1047                         .setMethod(HttpDelete.METHOD_NAME)
1048                         .setMailbox(folderId.mailbox)
1049                         .setObjectType("contactFolders")
1050                         .setObjectId(folderId.id)
1051                         .setChildType("contacts")
1052                         .setChildId(contactId)
1053                         .setChildSuffix("photo"));
1054             }
1055         }
1056     }
1057 
1058     /**
1059      * Converts a Zulu date-time string to ISO format by removing unnecessary
1060      * precision in the fractional seconds if present.
1061      *
1062      * @param value a date-time string in Zulu format to be converted; may be null.
1063      * @return the converted date-time string in ISO format, or the original
1064      *         value if it was null.
1065      */
1066     private String convertZuluToIso(String value) {
1067         if (value != null) {
1068             return value.replace(".000Z", "Z");
1069         } else {
1070             return null;
1071         }
1072     }
1073 
1074     /**
1075      * Converts a Zulu date-time string to a basic day format by extracting the
1076      * date portion of the string before the "T" character.
1077      *
1078      * @param value a date-time string in Zulu format to be converted; may be null.
1079      *              The string is expected to contain a "T" character separating
1080      *              the date and time portions.
1081      * @return the extracted date portion as a string if the input contains "T",
1082      *         or the original input string if the "T" is not present or the input is null.
1083      */
1084     private String convertZuluToDate(String value) {
1085         if (value != null && value.contains("T")) {
1086             return value.substring(0, value.indexOf("T"));
1087         } else {
1088             return value;
1089         }
1090     }
1091 
1092     // special folders https://learn.microsoft.com/en-us/graph/api/resources/mailfolder
1093     @SuppressWarnings("SpellCheckingInspection")
1094     public enum WellKnownFolderName {
1095         archive,
1096         deleteditems,
1097         calendar, contacts, tasks,
1098         drafts, inbox, outbox, sentitems, junkemail,
1099         msgfolderroot,
1100         searchfolders
1101     }
1102 
1103     // https://www.rfc-editor.org/rfc/rfc6154.html map well-known names to special flags
1104     protected static HashMap<String, String> wellKnownFolderMap = new HashMap<>();
1105 
1106     static {
1107         wellKnownFolderMap.put(WellKnownFolderName.inbox.name(), ExchangeSession.INBOX);
1108         wellKnownFolderMap.put(WellKnownFolderName.archive.name(), ExchangeSession.ARCHIVE);
1109         wellKnownFolderMap.put(WellKnownFolderName.drafts.name(), ExchangeSession.DRAFTS);
1110         wellKnownFolderMap.put(WellKnownFolderName.junkemail.name(), ExchangeSession.JUNK);
1111         wellKnownFolderMap.put(WellKnownFolderName.sentitems.name(), ExchangeSession.SENT);
1112         wellKnownFolderMap.put(WellKnownFolderName.deleteditems.name(), ExchangeSession.TRASH);
1113     }
1114 
1115     protected static final HashSet<GraphField> IMAP_MESSAGE_ATTRIBUTES = new HashSet<>();
1116 
1117     static {
1118         // TODO: review, permanenturl is no longer relevant
1119         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("permanenturl"));
1120         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("changeKey"));
1121         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("isDraft"));
1122         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("isRead"));
1123         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("receivedDateTime"));
1124         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("lastModifiedDateTime"));
1125 
1126         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("urlcompname"));
1127         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("uid"));
1128         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("messageSize"));
1129         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("imapUid"));
1130         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("junk"));
1131         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("flagStatus"));
1132         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("messageFlags"));
1133         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("lastVerbExecuted"));
1134         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("read"));
1135         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("deleted"));
1136         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("date"));
1137         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("lastmodified"));
1138         // OSX IMAP requests content-class
1139         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("contentclass"));
1140         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("keywords"));
1141 
1142         // experimental, retrieve message headers (TODO remove)
1143         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("to"));
1144         IMAP_MESSAGE_ATTRIBUTES.add(GraphField.get("messageheaders"));
1145     }
1146 
1147     protected static final HashSet<GraphField> CONTACT_ATTRIBUTES = new HashSet<>();
1148 
1149     static {
1150         CONTACT_ATTRIBUTES.add(GraphField.get("uid"));
1151 
1152         CONTACT_ATTRIBUTES.add(GraphField.get("imapUid"));
1153         // CONTACT_ATTRIBUTES.add(GraphField.get("etag")); replace with @odata.etag
1154         CONTACT_ATTRIBUTES.add(GraphField.get("urlcompname"));
1155         CONTACT_ATTRIBUTES.add(GraphField.get("keywords"));
1156 
1157         CONTACT_ATTRIBUTES.add(GraphField.get("extensionattribute1"));
1158         CONTACT_ATTRIBUTES.add(GraphField.get("extensionattribute2"));
1159         CONTACT_ATTRIBUTES.add(GraphField.get("extensionattribute3"));
1160         CONTACT_ATTRIBUTES.add(GraphField.get("extensionattribute4"));
1161         CONTACT_ATTRIBUTES.add(GraphField.get("bday"));
1162         CONTACT_ATTRIBUTES.add(GraphField.get("anniversary"));
1163         CONTACT_ATTRIBUTES.add(GraphField.get("businesshomepage"));
1164         CONTACT_ATTRIBUTES.add(GraphField.get("personalHomePage"));
1165         CONTACT_ATTRIBUTES.add(GraphField.get("cn"));
1166         CONTACT_ATTRIBUTES.add(GraphField.get("co"));
1167         CONTACT_ATTRIBUTES.add(GraphField.get("department"));
1168         CONTACT_ATTRIBUTES.add(GraphField.get("smtpemail1"));
1169         CONTACT_ATTRIBUTES.add(GraphField.get("smtpemail2"));
1170         CONTACT_ATTRIBUTES.add(GraphField.get("smtpemail3"));
1171         CONTACT_ATTRIBUTES.add(GraphField.get("facsimiletelephonenumber"));
1172         CONTACT_ATTRIBUTES.add(GraphField.get("givenName"));
1173         CONTACT_ATTRIBUTES.add(GraphField.get("homeCity"));
1174         CONTACT_ATTRIBUTES.add(GraphField.get("homeCountry"));
1175         CONTACT_ATTRIBUTES.add(GraphField.get("homePhone"));
1176         CONTACT_ATTRIBUTES.add(GraphField.get("homePostalCode"));
1177         CONTACT_ATTRIBUTES.add(GraphField.get("homeState"));
1178         CONTACT_ATTRIBUTES.add(GraphField.get("homeStreet"));
1179         CONTACT_ATTRIBUTES.add(GraphField.get("homepostofficebox"));
1180         CONTACT_ATTRIBUTES.add(GraphField.get("l"));
1181         CONTACT_ATTRIBUTES.add(GraphField.get("manager"));
1182         CONTACT_ATTRIBUTES.add(GraphField.get("mobile"));
1183         CONTACT_ATTRIBUTES.add(GraphField.get("namesuffix"));
1184         CONTACT_ATTRIBUTES.add(GraphField.get("nickname"));
1185         CONTACT_ATTRIBUTES.add(GraphField.get("o"));
1186         CONTACT_ATTRIBUTES.add(GraphField.get("pager"));
1187         CONTACT_ATTRIBUTES.add(GraphField.get("personaltitle"));
1188         CONTACT_ATTRIBUTES.add(GraphField.get("postalcode"));
1189         CONTACT_ATTRIBUTES.add(GraphField.get("postofficebox"));
1190         CONTACT_ATTRIBUTES.add(GraphField.get("profession"));
1191         CONTACT_ATTRIBUTES.add(GraphField.get("roomnumber"));
1192         CONTACT_ATTRIBUTES.add(GraphField.get("secretarycn"));
1193         CONTACT_ATTRIBUTES.add(GraphField.get("sn"));
1194         CONTACT_ATTRIBUTES.add(GraphField.get("spousecn"));
1195         CONTACT_ATTRIBUTES.add(GraphField.get("st"));
1196         CONTACT_ATTRIBUTES.add(GraphField.get("street"));
1197         CONTACT_ATTRIBUTES.add(GraphField.get("telephoneNumber"));
1198         CONTACT_ATTRIBUTES.add(GraphField.get("title"));
1199         CONTACT_ATTRIBUTES.add(GraphField.get("description"));
1200         CONTACT_ATTRIBUTES.add(GraphField.get("im"));
1201         CONTACT_ATTRIBUTES.add(GraphField.get("middlename"));
1202         CONTACT_ATTRIBUTES.add(GraphField.get("lastmodified"));
1203         CONTACT_ATTRIBUTES.add(GraphField.get("otherstreet"));
1204         CONTACT_ATTRIBUTES.add(GraphField.get("otherstate"));
1205         CONTACT_ATTRIBUTES.add(GraphField.get("otherpostofficebox"));
1206         CONTACT_ATTRIBUTES.add(GraphField.get("otherpostalcode"));
1207         CONTACT_ATTRIBUTES.add(GraphField.get("othercountry"));
1208         CONTACT_ATTRIBUTES.add(GraphField.get("othercity"));
1209         CONTACT_ATTRIBUTES.add(GraphField.get("haspicture"));
1210         CONTACT_ATTRIBUTES.add(GraphField.get("keywords"));
1211         CONTACT_ATTRIBUTES.add(GraphField.get("othermobile"));
1212         CONTACT_ATTRIBUTES.add(GraphField.get("otherTelephone"));
1213         CONTACT_ATTRIBUTES.add(GraphField.get("gender"));
1214         CONTACT_ATTRIBUTES.add(GraphField.get("private"));
1215         CONTACT_ATTRIBUTES.add(GraphField.get("sensitivity"));
1216         CONTACT_ATTRIBUTES.add(GraphField.get("fburl"));
1217         // certificates not supported over graph
1218         // CONTACT_ATTRIBUTES.add(GraphField.get("msexchangecertificate"));
1219         // CONTACT_ATTRIBUTES.add(GraphField.get("usersmimecertificate"));
1220     }
1221 
1222     private static final Set<GraphField> TODO_PROPERTIES = new HashSet<>();
1223 
1224     static {
1225         // TODO review new todo properties https://learn.microsoft.com/en-us/graph/api/resources/todotask
1226         /*TODO_PROPERTIES.add(Field.get("importance"));
1227 
1228         TODO_PROPERTIES.add(Field.get("subject"));
1229         TODO_PROPERTIES.add(Field.get("created"));
1230         TODO_PROPERTIES.add(Field.get("lastmodified"));
1231         TODO_PROPERTIES.add(Field.get("calendaruid"));
1232         TODO_PROPERTIES.add(Field.get("description"));
1233         TODO_PROPERTIES.add(Field.get("textbody"));
1234         TODO_PROPERTIES.add(Field.get("percentcomplete"));
1235         TODO_PROPERTIES.add(Field.get("taskstatus"));
1236         TODO_PROPERTIES.add(Field.get("startdate"));
1237         TODO_PROPERTIES.add(Field.get("duedate"));
1238         TODO_PROPERTIES.add(Field.get("datecompleted"));
1239         TODO_PROPERTIES.add(Field.get("keywords"));*/
1240     }
1241 
1242     /**
1243      * Must set select to retrieve cancelled and exception occurrences, so we must specify all properties
1244      */
1245     protected static final String EVENT_SELECT = "allowNewTimeProposals,attendees,body,bodyPreview,cancelledOccurrences,categories,changeKey,createdDateTime,end,exceptionOccurrences,hasAttachments,iCalUId,id,importance,isAllDay,isOnlineMeeting,isOrganizer,isReminderOn,lastModifiedDateTime,location,organizer,originalStart,originalStartTimeZone,recurrence,reminderMinutesBeforeStart,responseRequested,sensitivity,showAs,start,subject,type";
1246     protected static final HashSet<GraphField> EVENT_ATTRIBUTES = new HashSet<>();
1247 
1248     static {
1249         EVENT_ATTRIBUTES.add(GraphField.get("allowNewTimeProposals"));
1250         EVENT_ATTRIBUTES.add(GraphField.get("attendees"));
1251         EVENT_ATTRIBUTES.add(GraphField.get("bodyPreview"));
1252         EVENT_ATTRIBUTES.add(GraphField.get("body"));
1253         EVENT_ATTRIBUTES.add(GraphField.get("cancelledOccurrences"));
1254         EVENT_ATTRIBUTES.add(GraphField.get("categories"));
1255         EVENT_ATTRIBUTES.add(GraphField.get("changeKey"));
1256         EVENT_ATTRIBUTES.add(GraphField.get("createdDateTime"));
1257         EVENT_ATTRIBUTES.add(GraphField.get("end"));
1258         EVENT_ATTRIBUTES.add(GraphField.get("exceptionOccurrences"));
1259         EVENT_ATTRIBUTES.add(GraphField.get("hasAttachments"));
1260         EVENT_ATTRIBUTES.add(GraphField.get("iCalUId"));
1261         EVENT_ATTRIBUTES.add(GraphField.get("id"));
1262         EVENT_ATTRIBUTES.add(GraphField.get("importance"));
1263         EVENT_ATTRIBUTES.add(GraphField.get("isAllDay"));
1264         EVENT_ATTRIBUTES.add(GraphField.get("isOnlineMeeting"));
1265         EVENT_ATTRIBUTES.add(GraphField.get("isOrganizer"));
1266         EVENT_ATTRIBUTES.add(GraphField.get("isReminderOn"));
1267         EVENT_ATTRIBUTES.add(GraphField.get("lastModifiedDateTime"));
1268         EVENT_ATTRIBUTES.add(GraphField.get("location"));
1269         EVENT_ATTRIBUTES.add(GraphField.get("organizer"));
1270         EVENT_ATTRIBUTES.add(GraphField.get("originalStartTimeZone"));
1271         EVENT_ATTRIBUTES.add(GraphField.get("originalStart"));
1272         EVENT_ATTRIBUTES.add(GraphField.get("recurrence"));
1273         EVENT_ATTRIBUTES.add(GraphField.get("reminderMinutesBeforeStart"));
1274         EVENT_ATTRIBUTES.add(GraphField.get("responseRequested"));
1275         EVENT_ATTRIBUTES.add(GraphField.get("sensitivity"));
1276         EVENT_ATTRIBUTES.add(GraphField.get("showAs"));
1277         EVENT_ATTRIBUTES.add(GraphField.get("start"));
1278         EVENT_ATTRIBUTES.add(GraphField.get("subject"));
1279         EVENT_ATTRIBUTES.add(GraphField.get("type"));
1280     }
1281 
1282     protected static class FolderId {
1283         protected String mailbox;
1284         protected String id;
1285         protected String parentFolderId;
1286         protected String folderClass;
1287 
1288         public FolderId() {
1289         }
1290 
1291         public FolderId(String mailbox, String id) {
1292             this.mailbox = mailbox;
1293             this.id = id;
1294         }
1295 
1296         public FolderId(String mailbox, String id, String folderClass) {
1297             this.mailbox = mailbox;
1298             this.id = id;
1299             this.folderClass = folderClass;
1300         }
1301 
1302         public FolderId(String mailbox, WellKnownFolderName wellKnownFolderName) {
1303             this.mailbox = mailbox;
1304             this.id = wellKnownFolderName.name();
1305         }
1306 
1307         public FolderId(String mailbox, WellKnownFolderName wellKnownFolderName, String folderClass) {
1308             this.mailbox = mailbox;
1309             this.id = wellKnownFolderName.name();
1310             this.folderClass = folderClass;
1311         }
1312 
1313         public String getMailboxName() {
1314             if (mailbox == null) {
1315                 return "me";
1316             } else {
1317                 return mailbox;
1318             }
1319         }
1320     }
1321 
1322     HttpClientAdapter httpClient;
1323     O365Token token;
1324 
1325     /**
1326      * Default folder properties list
1327      */
1328     protected static final HashSet<GraphField> FOLDER_PROPERTIES = new HashSet<>();
1329 
1330     static {
1331         // reference at https://learn.microsoft.com/en-us/graph/api/resources/mailfolder
1332         FOLDER_PROPERTIES.add(GraphField.get("folderlastmodified"));
1333         FOLDER_PROPERTIES.add(GraphField.get("folderclass"));
1334         FOLDER_PROPERTIES.add(GraphField.get("ctag"));
1335         FOLDER_PROPERTIES.add(GraphField.get("uidNext"));
1336     }
1337 
1338     public GraphExchangeSession(HttpClientAdapter httpClient, O365Token token, String userName) throws IOException {
1339         this.httpClient = httpClient;
1340         this.token = token;
1341         this.userName = userName;
1342 
1343         buildSessionInfo(httpClient.getUri());
1344     }
1345 
1346     @Override
1347     public void close() {
1348         httpClient.close();
1349     }
1350 
1351     /**
1352      * Format date to exchange search format.
1353      * TODO: review
1354      *
1355      * @param date date object
1356      * @return formatted search date
1357      */
1358     @Override
1359     public String formatSearchDate(Date date) {
1360         SimpleDateFormat dateFormatter = new SimpleDateFormat(YYYY_MM_DD_T_HHMMSS_Z, Locale.ENGLISH);
1361         dateFormatter.setTimeZone(GMT_TIMEZONE);
1362         return dateFormatter.format(date);
1363     }
1364 
1365     @Override
1366     protected void buildSessionInfo(URI uri) throws IOException {
1367         currentMailboxPath = "/users/" + userName.toLowerCase();
1368 
1369         // assume email is username
1370         email = userName;
1371         alias = userName.substring(0, email.indexOf("@"));
1372 
1373         LOGGER.debug("Current user email is " + email + ", alias is " + alias);
1374     }
1375 
1376     @Override
1377     public ExchangeSession.Message createMessage(String folderPath, String messageName, HashMap<String, String> properties, MimeMessage mimeMessage) throws IOException {
1378         byte[] mimeContent = IOUtil.encodeBase64(mimeMessage);
1379 
1380         // do we want created message to have draft flag?
1381         // draft is set for mapi PR_MESSAGE_FLAGS property combined with read flag by IMAPConnection, 8 means draft and 1 means read
1382         boolean isDraft = properties != null && ("8".equals(properties.get("draft")) || "9".equals(properties.get("draft")));
1383 
1384         // https://learn.microsoft.com/en-us/graph/api/user-post-messages
1385 
1386         FolderId folderId = getFolderId(folderPath);
1387 
1388         // create message in default drafts folder first
1389         GraphObject graphResponse = executeGraphRequest(new GraphRequestBuilder()
1390                 .setMethod(HttpPost.METHOD_NAME)
1391                 .setContentType("text/plain")
1392                 .setMimeContent(mimeContent)
1393                 .setChildType("messages"));
1394         if (isDraft) {
1395             try {
1396                 // we have the message in the right folder, apply flags
1397                 applyMessageProperties(graphResponse, properties);
1398                 graphResponse = executeGraphRequest(new GraphRequestBuilder()
1399                         .setMethod(HttpPatch.METHOD_NAME)
1400                         .setMailbox(folderId.mailbox)
1401                         .setObjectType("messages")
1402                         .setObjectId(graphResponse.optString("id"))
1403                         .setJsonBody(graphResponse.jsonObject));
1404 
1405                 graphResponse = executeGraphRequest(new GraphRequestBuilder().setMethod(HttpPost.METHOD_NAME)
1406                         .setMailbox(folderId.mailbox)
1407                         .setObjectType("messages")
1408                         .setObjectId(graphResponse.optString("id"))
1409                         .setChildType("move")
1410                         .setJsonBody(new JSONObject().put("destinationId", folderId.id)));
1411             } catch (JSONException e) {
1412                 throw new IOException(e);
1413             }
1414         } else {
1415             String draftMessageId = null;
1416             try {
1417                 // save draft message id
1418                 draftMessageId = graphResponse.getString("id");
1419 
1420                 // unset draft flag on returned draft message properties
1421                 graphResponse.put("messageFlags", "4");
1422                 applyMessageProperties(graphResponse, properties);
1423 
1424                 // now use this to recreate message in the right folder
1425                 graphResponse = executeGraphRequest(new GraphRequestBuilder()
1426                         .setMethod(HttpPost.METHOD_NAME)
1427                         .setMailbox(folderId.mailbox)
1428                         .setObjectType("mailFolders")
1429                         .setObjectId(folderId.id)
1430                         .setJsonBody(graphResponse.jsonObject)
1431                         .setChildType("messages"));
1432 
1433             } catch (JSONException e) {
1434                 throw new IOException(e);
1435             } finally {
1436                 // delete draft message
1437                 if (draftMessageId != null) {
1438                     executeJsonRequest(new GraphRequestBuilder()
1439                             .setMethod(HttpDelete.METHOD_NAME)
1440                             .setObjectType("messages")
1441                             .setObjectId(draftMessageId));
1442                 }
1443             }
1444 
1445         }
1446         return buildMessage(executeJsonRequest(new GraphRequestBuilder()
1447                 .setMethod(HttpGet.METHOD_NAME)
1448                 .setObjectType("messages")
1449                 .setMailbox(folderId.mailbox)
1450                 .setObjectId(graphResponse.optString("id"))
1451                 .setSelectFields(IMAP_MESSAGE_ATTRIBUTES)));
1452     }
1453 
1454     private void applyMessageProperties(GraphObject graphResponse, Map<String, String> properties) throws JSONException {
1455         if (properties != null) {
1456             for (Map.Entry<String, String> entry : properties.entrySet()) {
1457                 // TODO
1458                 if ("read".equals(entry.getKey())) {
1459                     graphResponse.put(entry.getKey(), "1".equals(entry.getValue()));
1460                 } else if ("junk".equals(entry.getKey())) {
1461                     graphResponse.put(entry.getKey(), entry.getValue());
1462                 } else if ("flagged".equals(entry.getKey())) {
1463                     graphResponse.put("flagStatus", entry.getValue());
1464                 } else if ("answered".equals(entry.getKey())) {
1465                     graphResponse.put("lastVerbExecuted", entry.getValue());
1466                     if ("102".equals(entry.getValue())) {
1467                         graphResponse.put("iconIndex", "261");
1468                     }
1469                 } else if ("forwarded".equals(entry.getKey())) {
1470                     graphResponse.put("lastVerbExecuted", entry.getValue());
1471                     if ("104".equals(entry.getValue())) {
1472                         graphResponse.put("iconIndex", "262");
1473                     }
1474                 } else if ("deleted".equals(entry.getKey())) {
1475                     graphResponse.put(entry.getKey(), entry.getValue());
1476                 } else if ("datereceived".equals(entry.getKey())) {
1477                     graphResponse.put(entry.getKey(), entry.getValue());
1478                 } else if ("keywords".equals(entry.getKey())) {
1479                     graphResponse.setCategories(entry.getValue());
1480                 }
1481             }
1482         }
1483     }
1484 
1485     class Message extends ExchangeSession.Message {
1486         protected FolderId folderId;
1487         protected String id;
1488         protected String changeKey;
1489 
1490         @Override
1491         public String getPermanentId() {
1492             return id;
1493         }
1494 
1495         @Override
1496         protected InputStream getMimeHeaders() {
1497             InputStream result = null;
1498             try {
1499                 HashSet<GraphField> selectFields = new HashSet<>();
1500                 selectFields.add(GraphField.get("from"));
1501                 selectFields.add(GraphField.get("messageheaders"));
1502 
1503                 GraphObject graphResponse = new GraphObject(executeJsonRequest(new GraphRequestBuilder()
1504                         .setMethod(HttpGet.METHOD_NAME)
1505                         .setMailbox(folderId.mailbox)
1506                         .setObjectType("messages")
1507                         .setObjectId(id)
1508                         .setSelectFields(selectFields)));
1509 
1510                 String messageHeaders = graphResponse.optString("messageheaders");
1511 
1512                 // alternative: use parsed headers response.optJSONArray("internetMessageHeaders");
1513                 if (messageHeaders != null
1514                         // workaround for broken message headers on Exchange 2010
1515                         && messageHeaders.toLowerCase().contains("message-id:")) {
1516                     // workaround for messages in Sent folder
1517                     if (!messageHeaders.contains("From:")) {
1518                         String from = graphResponse.optString("from");
1519                         messageHeaders = "From: " + MimeUtility.encodeText(from, "UTF-8", null) + '\r' + '\n' + messageHeaders;
1520                     }
1521 
1522                     result = new ByteArrayInputStream(messageHeaders.getBytes(StandardCharsets.UTF_8));
1523                 }
1524             } catch (Exception e) {
1525                 LOGGER.warn(e.getMessage());
1526             }
1527 
1528             return result;
1529 
1530         }
1531     }
1532 
1533     private Message buildMessage(JSONObject response) {
1534         Message message = new Message();
1535         GraphObject graphResponse = new GraphObject(response);
1536 
1537         try {
1538             // get item id
1539             message.id = graphResponse.getString("id");
1540             message.changeKey = graphResponse.getString("changeKey");
1541 
1542             message.read = graphResponse.getBoolean("isRead");
1543             message.draft = graphResponse.getBoolean("isDraft");
1544             message.date = graphResponse.getString("receivedDateTime");
1545 
1546             String lastmodified = graphResponse.optString("lastModifiedDateTime");
1547             message.recent = !message.read && lastmodified != null && lastmodified.equals(message.date);
1548 
1549             message.keywords = graphResponse.optString("keywords");
1550 
1551             graphResponse.optString("messageheaders");
1552 
1553         } catch (JSONException e) {
1554             LOGGER.warn("Error parsing message " + e.getMessage(), e);
1555         }
1556 
1557         JSONArray singleValueExtendedProperties = response.optJSONArray("singleValueExtendedProperties");
1558         if (singleValueExtendedProperties != null) {
1559             for (int i = 0; i < singleValueExtendedProperties.length(); i++) {
1560                 try {
1561                     JSONObject responseValue = singleValueExtendedProperties.getJSONObject(i);
1562                     String responseId = responseValue.optString("id");
1563                     if (GraphField.getGraphId("imapUid").equals(responseId)) {
1564                         message.imapUid = responseValue.getLong("value");
1565                     } else if (GraphField.getGraphId("messageSize").equals(responseId)) {
1566                         message.size = responseValue.getInt("value");
1567                     } else if (GraphField.getGraphId("uid").equals(responseId)) {
1568                         message.uid = responseValue.getString("value");
1569                     } else if (GraphField.getGraphId("permanenturl").equals(responseId)) {
1570                         message.permanentUrl = responseValue.getString("value"); // always null
1571                     } else if (GraphField.getGraphId("lastVerbExecuted").equals(responseId)) {
1572                         String lastVerbExecuted = responseValue.getString("value");
1573                         message.answered = "102".equals(lastVerbExecuted) || "103".equals(lastVerbExecuted);
1574                         message.forwarded = "104".equals(lastVerbExecuted);
1575                     } else if (GraphField.getGraphId("contentclass").equals(responseId)) {
1576                         message.contentClass = responseValue.getString("value");
1577                     } else if (GraphField.getGraphId("junk").equals(responseId)) {
1578                         message.junk = "1".equals(responseValue.getString("value"));
1579                     } else if (GraphField.getGraphId("flagStatus").equals(responseId)) {
1580                         message.flagged = "2".equals(responseValue.getString("value"));
1581                     } else if (GraphField.getGraphId("deleted").equals(responseId)) {
1582                         message.deleted = "1".equals(responseValue.getString("value"));
1583                     }
1584 
1585                 } catch (JSONException e) {
1586                     LOGGER.warn("Error parsing json response value");
1587                 }
1588             }
1589         }
1590 
1591         JSONArray multiValueExtendedProperties = response.optJSONArray("multiValueExtendedProperties");
1592         if (multiValueExtendedProperties != null) {
1593             for (int i = 0; i < multiValueExtendedProperties.length(); i++) {
1594                 try {
1595                     JSONObject responseValue = multiValueExtendedProperties.getJSONObject(i);
1596                     String responseId = responseValue.optString("id");
1597                     if (GraphField.get("keywords").getGraphId().equals(responseId)) {
1598                         JSONArray keywordsJsonArray = responseValue.getJSONArray("value");
1599                         HashSet<String> keywords = new HashSet<>();
1600                         for (int j = 0; j < keywordsJsonArray.length(); j++) {
1601                             keywords.add(keywordsJsonArray.getString(j));
1602                         }
1603                         message.keywords = StringUtil.join(keywords, ",");
1604                     }
1605 
1606                 } catch (JSONException e) {
1607                     LOGGER.warn("Error parsing json response value");
1608                 }
1609             }
1610         }
1611 
1612         if (LOGGER.isDebugEnabled()) {
1613             StringBuilder buffer = new StringBuilder();
1614             buffer.append("Message");
1615             if (message.imapUid != 0) {
1616                 buffer.append(" IMAP uid: ").append(message.imapUid);
1617             }
1618             if (message.uid != null) {
1619                 buffer.append(" uid: ").append(message.uid);
1620             }
1621             buffer.append(" ItemId: ").append(message.id);
1622             buffer.append(" ChangeKey: ").append(message.changeKey);
1623             LOGGER.debug(buffer.toString());
1624         }
1625 
1626         return message;
1627 
1628     }
1629 
1630     /**
1631      * Lightweight conversion method to avoid full string to date and back conversions.
1632      * Note: Duplicate from EWSEchangeSession, added nanosecond handling
1633      * @param exchangeDateValue date returned from O365
1634      * @return converted date
1635      * @throws DavMailException on error
1636      */
1637     protected static String convertDateFromExchange(String exchangeDateValue) throws DavMailException {
1638         // yyyy-MM-dd'T'HH:mm:ss'Z' to yyyyMMdd'T'HHmmss'Z'
1639         if (exchangeDateValue == null) {
1640             return null;
1641         } else {
1642             StringBuilder buffer = new StringBuilder();
1643             if (exchangeDateValue.length() >= 25 || exchangeDateValue.length() == 20 || exchangeDateValue.length() == 10) {
1644                 for (int i = 0; i < exchangeDateValue.length(); i++) {
1645                     if (i == 4 || i == 7 || i == 13 || i == 16) {
1646                         i++;
1647                     } else if (exchangeDateValue.length() >= 25 && i == 19) {
1648                         i = exchangeDateValue.length() - 1;
1649                     }
1650                     buffer.append(exchangeDateValue.charAt(i));
1651                 }
1652                 if (exchangeDateValue.length() == 10) {
1653                     buffer.append("T000000Z");
1654                 }
1655             } else {
1656                 throw new DavMailException("EXCEPTION_INVALID_DATE", exchangeDateValue);
1657             }
1658             return buffer.toString();
1659         }
1660     }
1661 
1662     @Override
1663     public void updateMessage(ExchangeSession.Message message, Map<String, String> properties) throws IOException {
1664         try {
1665             GraphObject graphObject = new GraphObject(new JSONObject());
1666             // we have the message in the right folder, apply flags
1667             applyMessageProperties(graphObject, properties);
1668             executeJsonRequest(new GraphRequestBuilder()
1669                     .setMethod(HttpPatch.METHOD_NAME)
1670                     .setMailbox(((Message) message).folderId.mailbox)
1671                     .setObjectType("messages")
1672                     .setObjectId(((Message) message).id)
1673                     .setJsonBody(graphObject.jsonObject));
1674         } catch (JSONException e) {
1675             throw new IOException(e);
1676         }
1677     }
1678 
1679     @Override
1680     public void deleteMessage(ExchangeSession.Message message) throws IOException {
1681         executeJsonRequest(new GraphRequestBuilder()
1682                 .setMethod(HttpDelete.METHOD_NAME)
1683                 .setMailbox(((Message) message).folderId.mailbox)
1684                 .setObjectType("messages")
1685                 .setObjectId(((Message) message).id));
1686     }
1687 
1688     @Override
1689     protected byte[] getContent(ExchangeSession.Message message) throws IOException {
1690         GraphRequestBuilder graphRequestBuilder = new GraphRequestBuilder()
1691                 .setMethod(HttpGet.METHOD_NAME)
1692                 .setMailbox(((Message) message).folderId.mailbox)
1693                 .setObjectType("messages")
1694                 .setObjectId(message.getPermanentId())
1695                 .setChildType("$value")
1696                 .setAccessToken(token.getAccessToken());
1697 
1698         // TODO review mime content handling
1699         byte[] mimeContent;
1700         try (
1701                 CloseableHttpResponse response = httpClient.execute(graphRequestBuilder.build());
1702                 InputStream inputStream = response.getEntity().getContent()
1703         ) {
1704             // wrap inputstream to log progress
1705             FilterInputStream filterInputStream = new FilterInputStream(inputStream) {
1706                 int totalCount;
1707                 int lastLogCount;
1708 
1709                 @Override
1710                 public int read(byte[] buffer, int offset, int length) throws IOException {
1711                     int count = super.read(buffer, offset, length);
1712                     totalCount += count;
1713                     if (totalCount - lastLogCount > 1024 * 128) {
1714                         DavGatewayTray.debug(new BundleMessage("LOG_DOWNLOAD_PROGRESS", String.valueOf(totalCount / 1024), message.getPermanentId()));
1715                         DavGatewayTray.switchIcon();
1716                         lastLogCount = totalCount;
1717                     }
1718                     /*if (count > 0 && LOGGER.isDebugEnabled()) {
1719                         LOGGER.debug(new String(buffer, offset, count, "UTF-8"));
1720                     }*/
1721                     return count;
1722                 }
1723             };
1724             if (HttpClientAdapter.isGzipEncoded(response)) {
1725                 mimeContent = IOUtil.readFully(new GZIPInputStream(filterInputStream));
1726             } else {
1727                 mimeContent = IOUtil.readFully(filterInputStream);
1728             }
1729         }
1730         return mimeContent;
1731     }
1732 
1733     @Override
1734     public MessageList searchMessages(String folderName, Set<String> attributes, Condition condition) throws IOException {
1735         MessageList messageList = new MessageList();
1736         FolderId folderId = getFolderId(folderName);
1737 
1738         GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder()
1739                 .setMethod(HttpGet.METHOD_NAME)
1740                 .setMailbox(folderId.mailbox)
1741                 .setObjectType("mailFolders")
1742                 .setObjectId(folderId.id)
1743                 .setChildType("messages")
1744                 .setSelectFields(IMAP_MESSAGE_ATTRIBUTES)
1745                 .setFilter(condition);
1746         LOGGER.debug("searchMessages " + folderId.getMailboxName() + " " + folderName);
1747         GraphIterator graphIterator = executeSearchRequest(httpRequestBuilder);
1748 
1749         while (graphIterator.hasNext()) {
1750             Message message = buildMessage(graphIterator.next());
1751             message.messageList = messageList;
1752             message.folderId = folderId;
1753             messageList.add(message);
1754         }
1755         Collections.sort(messageList);
1756         return messageList;
1757     }
1758 
1759     static class AttributeCondition extends ExchangeSession.AttributeCondition {
1760 
1761         protected AttributeCondition(String attributeName, Operator operator, String value) {
1762             super(attributeName, operator, value);
1763         }
1764 
1765         protected Operator getOperator() {
1766             return operator;
1767         }
1768 
1769         protected GraphField getField() {
1770             GraphField fieldURI = GraphField.get(attributeName);
1771             // check to detect broken field mapping
1772             //noinspection ConstantConditions
1773             if (fieldURI == null) {
1774                 throw new IllegalArgumentException("Unknown field: " + attributeName);
1775             }
1776             return fieldURI;
1777         }
1778 
1779         private String convertOperator(Operator operator) {
1780             if (Operator.IsEqualTo.equals(operator)) {
1781                 return "eq";
1782             } else if (Operator.IsGreaterThan.equals(operator)) {
1783                 return "gt";
1784             } else if (Operator.IsGreaterThanOrEqualTo.equals(operator)) {
1785                 return "ge";
1786             } else if (Operator.IsLessThan.equals(operator)) {
1787                 return "lt";
1788             } else if (Operator.IsLessThanOrEqualTo.equals(operator)) {
1789                 return "le";
1790             } else {
1791                 LOGGER.warn("Unsupported operator: " + operator + ", switch to equals");
1792                 return "eq";
1793             }
1794         }
1795 
1796         @Override
1797         public void appendTo(StringBuilder buffer) {
1798             GraphField field = getField();
1799             String graphId = field.getGraphId();
1800             if (field.isExtended()) {
1801                 if (field.isInternetHeaders()) {
1802                     // header search does not work over graph, try to match full internet headers
1803                     buffer.append("singleValueExtendedProperties/any(ep:ep/id eq 'String 0x007D' and contains(ep/value, '")
1804                             .append(attributeName).append(": ").append(StringUtil.escapeQuotes(value)).append("'))");
1805                 } else if (field.isNumber()) {
1806                     // check value
1807                     int intValue = 0;
1808                     try {
1809                         intValue = Integer.parseInt(value);
1810                     } catch (NumberFormatException e) {
1811                         // invalid value, replace with 0
1812                         LOGGER.warn("Invalid integer value for "+graphId+" "+value);
1813                     }
1814                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphId)
1815                             .append("' and cast(ep/value, Edm.Int32) ").append(convertOperator(operator)).append(" ").append(intValue).append(")");
1816                 } else if (Operator.Contains.equals(operator)) {
1817                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphId)
1818                             .append("' and contains(ep/value,'").append(StringUtil.escapeQuotes(value)).append("'))");
1819                 } else if (Operator.StartsWith.equals(operator)) {
1820                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphId)
1821                             .append("' and startswith(ep/value,'").append(StringUtil.escapeQuotes(value)).append("'))");
1822                 } else if (field.isBinary()) {
1823                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphId)
1824                             .append("' and cast(ep/value,Edm.Binary) ").append(convertOperator(operator)).append(" binary'").append(StringUtil.escapeQuotes(value)).append("')");
1825                 } else if (field.isDate()) {
1826                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphId)
1827                             .append("' and cast(ep/value,Edm.DateTimeOffset) ").append(convertOperator(operator)).append(" datetimeoffset'").append(StringUtil.escapeQuotes(value)).append("')");
1828                 } else {
1829                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphId)
1830                             .append("' and ep/value ").append(convertOperator(operator)).append(" '").append(StringUtil.escapeQuotes(value)).append("')");
1831                 }
1832             } else if (field.isMultiValued()) {
1833                 buffer.append(graphId).append("/any(a:a ").append(convertOperator(operator)).append(" '").append(StringUtil.escapeQuotes(value)).append("')");
1834             } else if ("body".equals(graphId)) {
1835                 // only contains supported for body
1836                 buffer.append("contains(").append(graphId).append("/content,'").append(StringUtil.escapeQuotes(value)).append("')");
1837             } else if (Operator.Contains.equals(operator)) {
1838                 // search graph property
1839                 buffer.append("contains(").append(graphId).append(",'").append(StringUtil.escapeQuotes(value)).append("')");
1840             } else if (Operator.StartsWith.equals(operator)) {
1841                 buffer.append("startswith(").append(graphId).append(",'").append(StringUtil.escapeQuotes(value)).append("')");
1842             } else if (field.isDate() || field.isBoolean()) {
1843                 buffer.append(graphId).append(" ").append(convertOperator(operator)).append(" ").append(value);
1844             } else if ("start".equals(graphId) || "end".equals(graphId)) { // TODO check date value
1845                 buffer.append(graphId).append("/dateTime ").append(convertOperator(operator)).append(" '").append(StringUtil.escapeQuotes(value)).append("'");
1846             } else {
1847                 buffer.append(graphId).append(" ").append(convertOperator(operator)).append(" '").append(StringUtil.escapeQuotes(value)).append("'");
1848             }
1849         }
1850 
1851         @Override
1852         public boolean isMatch(ExchangeSession.Contact contact) {
1853             return false;
1854         }
1855     }
1856 
1857     protected static class HeaderCondition extends AttributeCondition {
1858 
1859         protected HeaderCondition(String attributeName, String value) {
1860             super(attributeName, Operator.Contains, value);
1861         }
1862 
1863         @Override
1864         protected GraphField getField() {
1865             return new GraphField(attributeName, GraphField.DistinguishedPropertySetType.InternetHeaders, attributeName);
1866         }
1867 
1868         /**
1869          * Graph field internetMessageHeader is not searchable, use MAPI property PR_TRANSPORT_MESSAGE_HEADERS directly instead.
1870          * @param buffer search filter buffer
1871          */
1872         public void appendTo(StringBuilder buffer) {
1873             buffer.append("singleValueExtendedProperties/any(ep:ep/id eq 'String 0x007D' and contains(ep/value, '")
1874                     .append(attributeName).append(": ").append(StringUtil.escapeQuotes(value)).append("'))");
1875         }
1876     }
1877 
1878     protected static class IsNullCondition implements ExchangeSession.Condition, SearchExpression {
1879         protected final String attributeName;
1880 
1881         protected IsNullCondition(String attributeName) {
1882             this.attributeName = attributeName;
1883         }
1884 
1885         public void appendTo(StringBuilder buffer) {
1886             GraphField graphField = GraphField.get(attributeName);
1887             if (graphField.isExtended()) {
1888                 if (graphField.isNumber()) {
1889                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphField.getGraphId())
1890                             .append("' and cast(ep/value, Edm.Int32) eq null)");
1891                 } else {
1892                     buffer.append("singleValueExtendedProperties/Any(ep: ep/id eq '").append(graphField.getGraphId())
1893                             .append("' and ep/value eq null)");
1894                 }
1895             } else {
1896                 buffer.append(graphField.getGraphId()).append(" eq null");
1897             }
1898         }
1899 
1900         public boolean isEmpty() {
1901             return false;
1902         }
1903 
1904         public boolean isMatch(ExchangeSession.Contact contact) {
1905             String actualValue = contact.get(attributeName);
1906             return actualValue == null;
1907         }
1908 
1909     }
1910 
1911     protected static class ExistsCondition implements ExchangeSession.Condition, SearchExpression {
1912         protected final String attributeName;
1913 
1914         protected ExistsCondition(String attributeName) {
1915             this.attributeName = attributeName;
1916         }
1917 
1918         public void appendTo(StringBuilder buffer) {
1919             buffer.append(GraphField.get(attributeName).getGraphId()).append(" ne null");
1920         }
1921 
1922         public boolean isEmpty() {
1923             return false;
1924         }
1925 
1926         public boolean isMatch(ExchangeSession.Contact contact) {
1927             String actualValue = contact.get(attributeName);
1928             return actualValue == null;
1929         }
1930 
1931     }
1932 
1933 
1934     static class MultiCondition extends ExchangeSession.MultiCondition {
1935 
1936         protected MultiCondition(Operator operator, Condition... conditions) {
1937             super(operator, conditions);
1938         }
1939 
1940         @Override
1941         public void appendTo(StringBuilder buffer) {
1942             int actualConditionCount = 0;
1943             for (Condition condition : conditions) {
1944                 if (!condition.isEmpty()) {
1945                     actualConditionCount++;
1946                 }
1947             }
1948             if (actualConditionCount > 0) {
1949                 boolean isFirst = true;
1950 
1951                 for (Condition condition : conditions) {
1952                     if (isFirst) {
1953                         isFirst = false;
1954 
1955                     } else {
1956                         buffer.append(" ").append(operator.toString()).append(" ");
1957                     }
1958                     condition.appendTo(buffer);
1959                 }
1960             }
1961         }
1962     }
1963 
1964     static class NotCondition extends ExchangeSession.NotCondition {
1965 
1966         protected NotCondition(Condition condition) {
1967             super(condition);
1968         }
1969 
1970         @Override
1971         public void appendTo(StringBuilder buffer) {
1972             buffer.append("not (");
1973             condition.appendTo(buffer);
1974             buffer.append(")");
1975         }
1976     }
1977 
1978     @Override
1979     public ExchangeSession.MultiCondition and(Condition... conditions) {
1980         return new MultiCondition(Operator.And, conditions);
1981     }
1982 
1983     @Override
1984     public ExchangeSession.MultiCondition or(Condition... conditions) {
1985         return new MultiCondition(Operator.Or, conditions);
1986     }
1987 
1988     @Override
1989     public Condition not(Condition condition) {
1990         return new NotCondition(condition);
1991     }
1992 
1993     @Override
1994     public Condition isEqualTo(String attributeName, String value) {
1995         return new AttributeCondition(attributeName, Operator.IsEqualTo, value);
1996     }
1997 
1998     @Override
1999     public Condition isEqualTo(String attributeName, int value) {
2000         return new AttributeCondition(attributeName, Operator.IsEqualTo, String.valueOf(value));
2001     }
2002 
2003     @Override
2004     public Condition headerIsEqualTo(String headerName, String value) {
2005         return new HeaderCondition(headerName, value);
2006     }
2007 
2008     @Override
2009     public Condition gte(String attributeName, String value) {
2010         return new AttributeCondition(attributeName, Operator.IsGreaterThanOrEqualTo, value);
2011     }
2012 
2013     @Override
2014     public Condition gt(String attributeName, String value) {
2015         return new AttributeCondition(attributeName, Operator.IsGreaterThan, value);
2016     }
2017 
2018     @Override
2019     public Condition lt(String attributeName, String value) {
2020         return new AttributeCondition(attributeName, Operator.IsLessThan, value);
2021     }
2022 
2023     @Override
2024     public Condition lte(String attributeName, String value) {
2025         return new AttributeCondition(attributeName, Operator.IsLessThanOrEqualTo, value);
2026     }
2027 
2028     @Override
2029     public Condition contains(String attributeName, String value) {
2030         return new AttributeCondition(attributeName, Operator.Contains, value);
2031     }
2032 
2033     @Override
2034     public Condition startsWith(String attributeName, String value) {
2035         return new AttributeCondition(attributeName, Operator.StartsWith, value);
2036     }
2037 
2038     @Override
2039     public Condition isNull(String attributeName) {
2040         return new IsNullCondition(attributeName);
2041     }
2042 
2043     @Override
2044     public Condition exists(String attributeName) {
2045         return new ExistsCondition(attributeName);
2046     }
2047 
2048     @Override
2049     public Condition isTrue(String attributeName) {
2050         return new AttributeCondition(attributeName, Operator.IsEqualTo, "true");
2051     }
2052 
2053     @Override
2054     public Condition isFalse(String attributeName) {
2055         return new AttributeCondition(attributeName, Operator.IsEqualTo, "false");
2056     }
2057 
2058     @Override
2059     public List<ExchangeSession.Folder> getSubFolders(String folderPath, Condition condition, boolean recursive) throws IOException {
2060         String baseFolderPath = folderPath;
2061         if (baseFolderPath.startsWith("/users/")) {
2062             int index = baseFolderPath.indexOf('/', "/users/".length());
2063             if (index >= 0) {
2064                 baseFolderPath = baseFolderPath.substring(index + 1);
2065             }
2066         }
2067         List<ExchangeSession.Folder> folders = new ArrayList<>();
2068         appendSubFolders(folders, baseFolderPath, getFolderId(folderPath), condition, recursive);
2069         return folders;
2070     }
2071 
2072     protected void appendSubFolders(List<ExchangeSession.Folder> folders,
2073                                     String parentFolderPath, FolderId parentFolderId,
2074                                     Condition condition, boolean recursive) throws IOException {
2075 
2076         GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder()
2077                 .setMethod(HttpGet.METHOD_NAME)
2078                 .setObjectType("mailFolders")
2079                 .setMailbox(parentFolderId.mailbox)
2080                 .setObjectId(parentFolderId.id)
2081                 .setChildType("childFolders")
2082                 .setSelectFields(FOLDER_PROPERTIES)
2083                 .setFilter(condition);
2084         LOGGER.debug("appendSubFolders " + (parentFolderId.mailbox != null ? parentFolderId.mailbox : "me") + " " + parentFolderPath);
2085 
2086         GraphIterator graphIterator = executeSearchRequest(httpRequestBuilder);
2087 
2088         while (graphIterator.hasNext()) {
2089             Folder folder = buildFolder(graphIterator.next());
2090             folder.folderId.mailbox = parentFolderId.mailbox;
2091             // check parentFolder
2092             if (parentFolderId.id.equals(folder.folderId.parentFolderId)) {
2093                 if (!parentFolderPath.isEmpty()) {
2094                     if (parentFolderPath.endsWith("/")) {
2095                         folder.folderPath = parentFolderPath + folder.displayName;
2096                     } else {
2097                         folder.folderPath = parentFolderPath + '/' + folder.displayName;
2098                     }
2099                     // TODO folderIdMap?
2100                 } else {
2101                     folder.folderPath = folder.displayName;
2102                 }
2103                 folders.add(folder);
2104                 if (recursive && folder.hasChildren) {
2105                     appendSubFolders(folders, folder.folderPath, folder.folderId, condition, true);
2106                 }
2107             } else {
2108                 LOGGER.debug("appendSubFolders skip " + folder.folderId.mailbox + " " + folder.folderId.id + " " + folder.displayName + " not a child of " + parentFolderPath);
2109             }
2110         }
2111 
2112     }
2113 
2114 
2115     @Override
2116     public void sendMessage(MimeMessage mimeMessage) throws IOException, MessagingException {
2117         // https://learn.microsoft.com/en-us/graph/api/user-sendmail
2118         executeJsonRequest(new GraphRequestBuilder()
2119                 .setMethod(HttpPost.METHOD_NAME)
2120                 .setObjectType("sendMail")
2121                 .setContentType("text/plain")
2122                 .setMimeContent(IOUtil.encodeBase64(mimeMessage)));
2123 
2124     }
2125 
2126     @Override
2127     protected Folder internalGetFolder(String folderPath) throws IOException {
2128         FolderId folderId = getFolderId(folderPath);
2129 
2130         // base folder get https://graph.microsoft.com/v1.0/me/mailFolders/inbox
2131         GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder()
2132                 .setMethod(HttpGet.METHOD_NAME)
2133                 .setMailbox(folderId.mailbox)
2134                 .setObjectId(folderId.id);
2135         if ("IPF.Appointment".equals(folderId.folderClass)) {
2136             httpRequestBuilder
2137                     .setSelectFields(FOLDER_PROPERTIES)
2138                     .setObjectType("calendars");
2139         } else if ("IPF.Task".equals(folderId.folderClass)) {
2140             httpRequestBuilder.setObjectType("todo/lists");
2141         } else if ("IPF.Contact".equals(folderId.folderClass)) {
2142             httpRequestBuilder
2143                     .setSelectFields(FOLDER_PROPERTIES)
2144                     .setObjectType("contactFolders");
2145         } else {
2146             httpRequestBuilder
2147                     .setSelectFields(FOLDER_PROPERTIES)
2148                     .setObjectType("mailFolders");
2149         }
2150 
2151         JSONObject jsonResponse = executeJsonRequest(httpRequestBuilder);
2152 
2153         Folder folder = buildFolder(jsonResponse);
2154         folder.folderPath = folderPath;
2155 
2156         return folder;
2157     }
2158 
2159     private Folder buildFolder(JSONObject jsonResponse) throws IOException {
2160         try {
2161             Folder folder = new Folder();
2162             folder.folderId = new FolderId();
2163             folder.folderId.id = jsonResponse.getString("id");
2164             folder.folderId.parentFolderId = jsonResponse.optString("parentFolderId", null);
2165             if (folder.folderId.parentFolderId == null) {
2166                 // calendar
2167                 folder.displayName = StringUtil.encodeFolderName(jsonResponse.optString("name"));
2168             } else {
2169                 String wellKnownName = wellKnownFolderMap.get(jsonResponse.optString("wellKnownName"));
2170                 if (ExchangeSession.INBOX.equals(wellKnownName)) {
2171                     folder.displayName = wellKnownName;
2172                 } else {
2173                     if (wellKnownName != null) {
2174                         folder.setSpecialFlag(wellKnownName);
2175                     }
2176 
2177                     // TODO: reevaluate folder name encoding over graph
2178                     folder.displayName = StringUtil.encodeFolderName(jsonResponse.getString("displayName"));
2179                 }
2180 
2181                 folder.messageCount = jsonResponse.optInt("totalItemCount");
2182                 folder.unreadCount = jsonResponse.optInt("unreadItemCount");
2183                 // fake recent value
2184                 folder.recent = folder.unreadCount;
2185                 // hassubs computed from childFolderCount
2186                 folder.hasChildren = jsonResponse.optInt("childFolderCount") > 0;
2187             }
2188 
2189             // retrieve property values
2190             JSONArray singleValueExtendedProperties = jsonResponse.optJSONArray("singleValueExtendedProperties");
2191             if (singleValueExtendedProperties != null) {
2192                 for (int i = 0; i < singleValueExtendedProperties.length(); i++) {
2193                     JSONObject singleValueProperty = singleValueExtendedProperties.getJSONObject(i);
2194                     String singleValueId = singleValueProperty.getString("id");
2195                     String singleValue = singleValueProperty.getString("value");
2196                     if (GraphField.get("folderlastmodified").getGraphId().equals(singleValueId)) {
2197                         folder.etag = singleValue;
2198                     } else if (GraphField.get("folderclass").getGraphId().equals(singleValueId)) {
2199                         folder.folderClass = singleValue;
2200                         folder.folderId.folderClass = folder.folderClass;
2201                     } else if (GraphField.get("uidNext").getGraphId().equals(singleValueId)) {
2202                         folder.uidNext = Long.parseLong(singleValue);
2203                     } else if (GraphField.get("ctag").getGraphId().equals(singleValueId)) {
2204                         folder.ctag = singleValue;
2205                     }
2206 
2207                 }
2208             }
2209 
2210             return folder;
2211         } catch (JSONException e) {
2212             throw new IOException(e.getMessage(), e);
2213         }
2214     }
2215 
2216     /**
2217      * Compute folderId from folderName
2218      * @param folderPath folder name (path)
2219      * @return folder id
2220      */
2221     private FolderId getFolderId(String folderPath) throws IOException {
2222         FolderId folderId = getFolderIdIfExists(folderPath);
2223         if (folderId == null) {
2224             throw new HttpNotFoundException("Folder '" + folderPath + "' not found");
2225         }
2226         return folderId;
2227     }
2228 
2229     protected static final String USERS_ROOT = "/users/";
2230     protected static final String ARCHIVE_ROOT = "/archive/";
2231 
2232 
2233     private FolderId getFolderIdIfExists(String folderPath) throws IOException {
2234         String lowerCaseFolderPath = folderPath.toLowerCase();
2235         if (lowerCaseFolderPath.equals(currentMailboxPath)) {
2236             return getSubFolderIdIfExists(null, "");
2237         } else if (lowerCaseFolderPath.startsWith(currentMailboxPath + '/')) {
2238             return getSubFolderIdIfExists(null, folderPath.substring(currentMailboxPath.length() + 1));
2239         } else if (folderPath.startsWith(USERS_ROOT)) {
2240             int slashIndex = folderPath.indexOf('/', USERS_ROOT.length());
2241             String mailbox;
2242             String subFolderPath;
2243             if (slashIndex >= 0) {
2244                 mailbox = folderPath.substring(USERS_ROOT.length(), slashIndex);
2245                 subFolderPath = folderPath.substring(slashIndex + 1);
2246             } else {
2247                 mailbox = folderPath.substring(USERS_ROOT.length());
2248                 subFolderPath = "";
2249             }
2250             return getSubFolderIdIfExists(mailbox, subFolderPath);
2251         } else {
2252             return getSubFolderIdIfExists(null, folderPath);
2253         }
2254     }
2255 
2256     private FolderId getSubFolderIdIfExists(String mailbox, String folderPath) throws IOException {
2257         String[] folderNames;
2258         FolderId currentFolderId;
2259 
2260         // TODO test various use cases
2261         if ("/public".equals(folderPath)) {
2262             throw new UnsupportedOperationException("public folders not supported on Graph");
2263         } else if ("/archive".equals(folderPath)) {
2264             return getWellKnownFolderId(mailbox, WellKnownFolderName.archive);
2265         } else if (isSubFolderOf(folderPath, PUBLIC_ROOT)) {
2266             throw new UnsupportedOperationException("public folders not supported on Graph");
2267         } else if (isSubFolderOf(folderPath, ARCHIVE_ROOT)) {
2268             currentFolderId = getWellKnownFolderId(mailbox, WellKnownFolderName.archive);
2269             folderNames = folderPath.substring(ARCHIVE_ROOT.length()).split("/");
2270         } else if (isSubFolderOf(folderPath, INBOX) ||
2271                 isSubFolderOf(folderPath, LOWER_CASE_INBOX) ||
2272                 isSubFolderOf(folderPath, MIXED_CASE_INBOX)) {
2273             currentFolderId = getWellKnownFolderId(mailbox, WellKnownFolderName.inbox);
2274             folderNames = folderPath.substring(INBOX.length()).split("/");
2275         } else if (isSubFolderOf(folderPath, CALENDAR)) {
2276             currentFolderId = new FolderId(mailbox, WellKnownFolderName.calendar, "IPF.Appointment");
2277             // TODO subfolders not supported with graph
2278             folderNames = folderPath.substring(CALENDAR.length()).split("/");
2279         } else if (isSubFolderOf(folderPath, TASKS)) {
2280             currentFolderId = getWellKnownFolderId(mailbox, WellKnownFolderName.tasks);
2281             folderNames = folderPath.substring(TASKS.length()).split("/");
2282         } else if (isSubFolderOf(folderPath, CONTACTS)) {
2283             currentFolderId = new FolderId(mailbox, WellKnownFolderName.contacts, "IPF.Contact");
2284             folderNames = folderPath.substring(CONTACTS.length()).split("/");
2285         } else if (isSubFolderOf(folderPath, SENT)) {
2286             currentFolderId = new FolderId(mailbox, WellKnownFolderName.sentitems);
2287             folderNames = folderPath.substring(SENT.length()).split("/");
2288         } else if (isSubFolderOf(folderPath, DRAFTS)) {
2289             currentFolderId = new FolderId(mailbox, WellKnownFolderName.drafts);
2290             folderNames = folderPath.substring(DRAFTS.length()).split("/");
2291         } else if (isSubFolderOf(folderPath, TRASH)) {
2292             currentFolderId = new FolderId(mailbox, WellKnownFolderName.deleteditems);
2293             folderNames = folderPath.substring(TRASH.length()).split("/");
2294         } else if (isSubFolderOf(folderPath, JUNK)) {
2295             currentFolderId = new FolderId(mailbox, WellKnownFolderName.junkemail);
2296             folderNames = folderPath.substring(JUNK.length()).split("/");
2297         } else if (isSubFolderOf(folderPath, UNSENT)) {
2298             currentFolderId = new FolderId(mailbox, WellKnownFolderName.outbox);
2299             folderNames = folderPath.substring(UNSENT.length()).split("/");
2300         } else {
2301             currentFolderId = getWellKnownFolderId(mailbox, WellKnownFolderName.msgfolderroot);
2302             folderNames = folderPath.split("/");
2303         }
2304         String folderClass = currentFolderId.folderClass;
2305         for (String folderName : folderNames) {
2306             if (!folderName.isEmpty()) {
2307                 currentFolderId = getSubFolderByName(currentFolderId, folderName);
2308                 if (currentFolderId == null) {
2309                     break;
2310                 }
2311                 currentFolderId.folderClass = folderClass;
2312             }
2313         }
2314         return currentFolderId;
2315     }
2316 
2317     /**
2318      * Build folderId for well-known folders.
2319      * 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>
2320      * @param mailbox user mailbox
2321      * @param wellKnownFolderName well-known value
2322      * @return folderId
2323      * @throws IOException on error
2324      */
2325     private FolderId getWellKnownFolderId(String mailbox, WellKnownFolderName wellKnownFolderName) throws IOException {
2326         if (wellKnownFolderName == WellKnownFolderName.tasks) {
2327             // retrieve folder id from todo endpoint
2328             GraphIterator graphIterator = executeSearchRequest(new GraphRequestBuilder()
2329                     .setMethod(HttpGet.METHOD_NAME)
2330                     .setMailbox(mailbox)
2331                     .setObjectType("todo/lists"));
2332             while (graphIterator.hasNext()) {
2333                 JSONObject jsonResponse = graphIterator.next();
2334                 if (jsonResponse.optString("wellknownListName").equals("defaultList")) {
2335                     return new FolderId(mailbox, jsonResponse.optString("id"), "IPF.Task");
2336                 }
2337             }
2338             // should not happen
2339             throw new HttpNotFoundException("Folder '" + wellKnownFolderName.name() + "' not found");
2340 
2341         } else {
2342             JSONObject jsonResponse = executeJsonRequest(new GraphRequestBuilder()
2343                     .setMethod(HttpGet.METHOD_NAME)
2344                     .setMailbox(mailbox)
2345                     .setObjectType("mailFolders")
2346                     .setObjectId(wellKnownFolderName.name())
2347                     .setSelectFields(FOLDER_PROPERTIES));
2348             return new FolderId(mailbox, jsonResponse.optString("id"), "IPF.Note");
2349         }
2350     }
2351 
2352     /**
2353      * Search subfolder by name, return null when no folders found
2354      * @param currentFolderId parent folder id
2355      * @param folderName child folder name
2356      * @return child folder id if exists
2357      * @throws IOException on error
2358      */
2359     protected FolderId getSubFolderByName(FolderId currentFolderId, String folderName) throws IOException {
2360         GraphRequestBuilder httpRequestBuilder;
2361         if ("IPF.Appointment".equals(currentFolderId.folderClass)) {
2362             httpRequestBuilder = new GraphRequestBuilder()
2363                     .setMethod(HttpGet.METHOD_NAME)
2364                     .setMailbox(currentFolderId.mailbox)
2365                     .setObjectType("calendars")
2366                     .setSelectFields(FOLDER_PROPERTIES)
2367                     .setFilter("name eq '" + StringUtil.escapeQuotes(StringUtil.decodeFolderName(folderName)) + "'");
2368         } else if ("IPF.Task".equals(currentFolderId.folderClass)) {
2369             httpRequestBuilder = new GraphRequestBuilder()
2370                     .setMethod(HttpGet.METHOD_NAME)
2371                     .setMailbox(currentFolderId.mailbox)
2372                     .setObjectType("todo/lists")
2373                     .setSelectFields(FOLDER_PROPERTIES)
2374                     .setFilter("displayName eq '" + StringUtil.escapeQuotes(StringUtil.decodeFolderName(folderName)) + "'");
2375         } else {
2376             String objectType = "mailFolders";
2377             if ("IPF.Contact".equals(currentFolderId.folderClass)) {
2378                 objectType = "contactFolders";
2379             }
2380             httpRequestBuilder = new GraphRequestBuilder()
2381                     .setMethod(HttpGet.METHOD_NAME)
2382                     .setMailbox(currentFolderId.mailbox)
2383                     .setObjectType(objectType)
2384                     .setObjectId(currentFolderId.id)
2385                     .setChildType("childFolders")
2386                     .setSelectFields(FOLDER_PROPERTIES)
2387                     .setFilter("displayName eq '" + StringUtil.escapeQuotes(StringUtil.decodeFolderName(folderName)) + "'");
2388         }
2389 
2390         JSONObject jsonResponse = executeJsonRequest(httpRequestBuilder);
2391 
2392         FolderId folderId = null;
2393         try {
2394             JSONArray values = jsonResponse.getJSONArray("value");
2395             if (values.length() > 0) {
2396                 folderId = new FolderId(currentFolderId.mailbox, values.getJSONObject(0).getString("id"), currentFolderId.folderClass);
2397                 folderId.parentFolderId = currentFolderId.id;
2398             }
2399         } catch (JSONException e) {
2400             throw new IOException(e.getMessage(), e);
2401         }
2402 
2403         return folderId;
2404     }
2405 
2406     private boolean isSubFolderOf(String folderPath, String baseFolder) {
2407         if (PUBLIC_ROOT.equals(baseFolder) || ARCHIVE_ROOT.equals(baseFolder)) {
2408             return folderPath.startsWith(baseFolder);
2409         } else {
2410             return folderPath.startsWith(baseFolder)
2411                     && (folderPath.length() == baseFolder.length() || folderPath.charAt(baseFolder.length()) == '/');
2412         }
2413     }
2414 
2415     @Override
2416     public int createFolder(String folderPath, String folderClass, Map<String, String> properties) throws IOException {
2417         if ("IPF.Appointment".equals(folderClass) && folderPath.startsWith("calendar/")) {
2418             // calendars/calendarName
2419             String calendarName = folderPath.substring(folderPath.indexOf('/') + 1);
2420             // create calendar
2421             try {
2422                 executeJsonRequest(new GraphRequestBuilder()
2423                         .setMethod(HttpPost.METHOD_NAME)
2424                         // TODO mailbox?
2425                         //.setMailbox("")
2426                         .setObjectType("calendars")
2427                         .setJsonBody(new JSONObject().put("name", calendarName)));
2428 
2429             } catch (JSONException e) {
2430                 throw new IOException(e);
2431             }
2432         } else {
2433             FolderId parentFolderId;
2434             String folderName;
2435             if (folderPath.contains("/")) {
2436                 String parentFolderPath = folderPath.substring(0, folderPath.lastIndexOf('/'));
2437                 parentFolderId = getFolderId(parentFolderPath);
2438                 folderName = StringUtil.decodeFolderName(folderPath.substring(folderPath.lastIndexOf('/') + 1));
2439             } else {
2440                 parentFolderId = getFolderId("");
2441                 folderName = StringUtil.decodeFolderName(folderPath);
2442             }
2443 
2444             try {
2445                 String objectType = "mailFolders";
2446                 if ("IPF.Contact".equals(folderClass)) {
2447                     objectType = "contactFolders";
2448                 }
2449                 executeJsonRequest(new GraphRequestBuilder()
2450                         .setMethod(HttpPost.METHOD_NAME)
2451                         .setMailbox(parentFolderId.mailbox)
2452                         .setObjectType(objectType)
2453                         .setObjectId(parentFolderId.id)
2454                         .setChildType("childFolders")
2455                         .setJsonBody(new JSONObject().put("displayName", folderName)));
2456 
2457             } catch (JSONException e) {
2458                 throw new IOException(e);
2459             }
2460         }
2461 
2462         return HttpStatus.SC_CREATED;
2463 
2464     }
2465 
2466     @Override
2467     public int updateFolder(String folderName, Map<String, String> properties) throws IOException {
2468         return 0;
2469     }
2470 
2471     @Override
2472     public void deleteFolder(String folderPath) throws IOException {
2473         FolderId folderId = getFolderIdIfExists(folderPath);
2474         if (folderPath.startsWith("calendar/")) {
2475             // TODO shared mailboxes
2476             if (folderId != null) {
2477                 executeJsonRequest(new GraphRequestBuilder()
2478                         .setMethod(HttpDelete.METHOD_NAME)
2479                         //.setMailbox()
2480                         .setObjectType("calendars")
2481                         .setObjectId(folderId.id));
2482             }
2483         } else {
2484             if (folderId != null) {
2485                 String objectType = "mailFolders";
2486                 if ("IPF.Contact".equals(folderId.folderClass)) {
2487                     objectType = "contactFolders";
2488                 }
2489                 executeJsonRequest(new GraphRequestBuilder()
2490                         .setMethod(HttpDelete.METHOD_NAME)
2491                         .setMailbox(folderId.mailbox)
2492                         .setObjectType(objectType)
2493                         .setObjectId(folderId.id));
2494             }
2495         }
2496 
2497     }
2498 
2499     @Override
2500     public void copyMessage(ExchangeSession.Message message, String targetFolder) throws IOException {
2501         try {
2502             FolderId targetFolderId = getFolderId(targetFolder);
2503 
2504             executeJsonRequest(new GraphRequestBuilder().setMethod(HttpPost.METHOD_NAME)
2505                     .setMailbox(((Message) message).folderId.mailbox)
2506                     .setObjectType("messages")
2507                     .setObjectId(((Message) message).id)
2508                     .setChildType("copy")
2509                     .setJsonBody(new JSONObject().put("destinationId", targetFolderId.id)));
2510 
2511         } catch (JSONException e) {
2512             throw new IOException(e);
2513         }
2514     }
2515 
2516     @Override
2517     public void moveMessage(ExchangeSession.Message message, String targetFolder) throws IOException {
2518         try {
2519             FolderId targetFolderId = getFolderId(targetFolder);
2520 
2521             executeJsonRequest(new GraphRequestBuilder().setMethod(HttpPost.METHOD_NAME)
2522                     .setMailbox(((Message) message).folderId.mailbox)
2523                     .setObjectType("messages")
2524                     .setObjectId(((Message) message).id)
2525                     .setChildType("move")
2526                     .setJsonBody(new JSONObject().put("destinationId", targetFolderId.id)));
2527         } catch (JSONException e) {
2528             throw new IOException(e);
2529         }
2530     }
2531 
2532     @Override
2533     public void moveFolder(String folderPath, String targetFolderPath) throws IOException {
2534         FolderId folderId = getFolderId(folderPath);
2535         String targetFolderName;
2536         String targetFolderParentPath;
2537         if (targetFolderPath.contains("/")) {
2538             targetFolderParentPath = targetFolderPath.substring(0, targetFolderPath.lastIndexOf('/'));
2539             targetFolderName = StringUtil.decodeFolderName(targetFolderPath.substring(targetFolderPath.lastIndexOf('/') + 1));
2540         } else {
2541             targetFolderParentPath = "";
2542             targetFolderName = StringUtil.decodeFolderName(targetFolderPath);
2543         }
2544         FolderId targetFolderId = getFolderId(targetFolderParentPath);
2545 
2546         // rename
2547         try {
2548             executeJsonRequest(new GraphRequestBuilder().setMethod(HttpPatch.METHOD_NAME)
2549                     .setMailbox(folderId.mailbox)
2550                     .setObjectType("mailFolders")
2551                     .setObjectId(folderId.id)
2552                     .setJsonBody(new JSONObject().put("displayName", targetFolderName)));
2553         } catch (JSONException e) {
2554             throw new IOException(e);
2555         }
2556 
2557         try {
2558             executeJsonRequest(new GraphRequestBuilder().setMethod(HttpPost.METHOD_NAME)
2559                     .setMailbox(folderId.mailbox)
2560                     .setObjectType("mailFolders")
2561                     .setObjectId(folderId.id)
2562                     .setChildType("move")
2563                     .setJsonBody(new JSONObject().put("destinationId", targetFolderId.id)));
2564         } catch (JSONException e) {
2565             throw new IOException(e);
2566         }
2567     }
2568 
2569     @Override
2570     public void moveItem(String sourcePath, String targetPath) throws IOException {
2571 
2572     }
2573 
2574     @Override
2575     protected void moveToTrash(ExchangeSession.Message message) throws IOException {
2576         moveMessage(message, WellKnownFolderName.deleteditems.name());
2577     }
2578 
2579 
2580     /**
2581      * Common item properties
2582      */
2583     protected static final Set<String> ITEM_PROPERTIES = new HashSet<>();
2584 
2585     static {
2586         //ITEM_PROPERTIES.add("etag");
2587         //ITEM_PROPERTIES.add("displayname");
2588         // calendar CdoInstanceType
2589         //ITEM_PROPERTIES.add("instancetype");
2590         //ITEM_PROPERTIES.add("urlcompname");
2591         //ITEM_PROPERTIES.add("subject");
2592     }
2593 
2594     protected static final HashSet<String> EVENT_REQUEST_PROPERTIES = new HashSet<>();
2595 
2596     static {
2597         EVENT_REQUEST_PROPERTIES.add("permanenturl");
2598         EVENT_REQUEST_PROPERTIES.add("etag");
2599         EVENT_REQUEST_PROPERTIES.add("displayname");
2600         EVENT_REQUEST_PROPERTIES.add("subject");
2601         EVENT_REQUEST_PROPERTIES.add("urlcompname");
2602         EVENT_REQUEST_PROPERTIES.add("displayto");
2603         EVENT_REQUEST_PROPERTIES.add("displaycc");
2604 
2605         EVENT_REQUEST_PROPERTIES.add("xmozlastack");
2606         EVENT_REQUEST_PROPERTIES.add("xmozsnoozetime");
2607     }
2608 
2609     protected static final HashSet<String> CALENDAR_ITEM_REQUEST_PROPERTIES = new HashSet<>();
2610 
2611     static {
2612         CALENDAR_ITEM_REQUEST_PROPERTIES.addAll(EVENT_REQUEST_PROPERTIES);
2613         CALENDAR_ITEM_REQUEST_PROPERTIES.add("ismeeting");
2614         CALENDAR_ITEM_REQUEST_PROPERTIES.add("myresponsetype");
2615     }
2616 
2617     @Override
2618     protected Set<String> getItemProperties() {
2619         return ITEM_PROPERTIES;
2620     }
2621 
2622     @Override
2623     public List<ExchangeSession.Contact> searchContacts(String folderPath, Set<String> attributes, Condition condition, int maxCount) throws IOException {
2624         ArrayList<ExchangeSession.Contact> contactList = new ArrayList<>();
2625         FolderId folderId = getFolderId(folderPath);
2626 
2627         GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder()
2628                 .setMethod(HttpGet.METHOD_NAME)
2629                 .setMailbox(folderId.mailbox)
2630                 .setObjectType("contactFolders")
2631                 .setObjectId(folderId.id)
2632                 .setChildType("contacts")
2633                 .setSelectFields(CONTACT_ATTRIBUTES)
2634                 .setFilter(condition);
2635         LOGGER.debug("searchContacts " + folderId.getMailboxName() + "/" + folderPath + " " + httpRequestBuilder.select);
2636 
2637         GraphIterator graphIterator = executeSearchRequest(httpRequestBuilder);
2638 
2639         while (graphIterator.hasNext() && (maxCount == 0 || contactList.size() < maxCount)) {
2640             Contact contact = new Contact(new GraphObject(graphIterator.next()));
2641             contact.folderPath = folderPath;
2642             contact.folderId = folderId;
2643             contactList.add(contact);
2644         }
2645 
2646         return contactList;
2647     }
2648 
2649     @Override
2650     public List<ExchangeSession.Event> getEventMessages(String folderPath) throws IOException {
2651         return null;
2652     }
2653 
2654     @Override
2655     protected Condition getCalendarItemCondition(Condition dateCondition) {
2656         // no specific condition for calendar over graph
2657         return dateCondition;
2658     }
2659 
2660     /**
2661      * Tasks folders are no longer supported, reroute to todos.
2662      * @param folderPath Exchange folder path
2663      * @return todos as events
2664      * @throws IOException on error
2665      */
2666     @Override
2667     public List<ExchangeSession.Event> searchTasksOnly(String folderPath) throws IOException {
2668         ArrayList<ExchangeSession.Event> eventList = new ArrayList<>();
2669         FolderId folderId = getFolderId(folderPath);
2670 
2671         // GET /me/todo/lists/{todoTaskListId}/tasks
2672         GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder()
2673                 .setMethod(HttpGet.METHOD_NAME)
2674                 .setMailbox(folderId.mailbox)
2675                 .setObjectType("todo/lists")
2676                 .setObjectId(folderId.id)
2677                 .setChildType("tasks")
2678                 .setSelectFields(TODO_PROPERTIES);
2679         LOGGER.debug("searchTasksOnly " + folderId.getMailboxName() + " " + folderPath);
2680 
2681         GraphIterator graphIterator = executeSearchRequest(httpRequestBuilder);
2682 
2683         while (graphIterator.hasNext()) {
2684             Event event = new Event(folderPath, folderId, new GraphObject(graphIterator.next()));
2685             eventList.add(event);
2686         }
2687 
2688         return eventList;
2689     }
2690 
2691     @Override
2692     public List<ExchangeSession.Event> searchEvents(String folderPath, Set<String> attributes, Condition condition) throws IOException {
2693         ArrayList<ExchangeSession.Event> eventList = new ArrayList<>();
2694         FolderId folderId = getFolderId(folderPath);
2695 
2696         // /users/{id | userPrincipalName}/calendars/{id}/events
2697         GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder()
2698                 .setMethod(HttpGet.METHOD_NAME)
2699                 .setMailbox(folderId.mailbox)
2700                 .setObjectType("calendars")
2701                 .setObjectId(folderId.id)
2702                 .setChildType("events")
2703                 .setSelectFields(EVENT_ATTRIBUTES)
2704                 .setTimezone(getVTimezone().getPropertyValue("TZID"))
2705                 .setFilter(condition);
2706         LOGGER.debug("searchEvents " + folderId.getMailboxName() + " " + folderPath);
2707 
2708         GraphIterator graphIterator = executeSearchRequest(httpRequestBuilder);
2709 
2710         while (graphIterator.hasNext()) {
2711             Event event = new Event(folderPath, folderId, new GraphObject(graphIterator.next()));
2712             eventList.add(event);
2713         }
2714 
2715         return eventList;
2716 
2717     }
2718 
2719     @Override
2720     public Item getItem(String folderPath, String itemName) throws IOException {
2721         FolderId folderId = getFolderId(folderPath);
2722 
2723         if ("IPF.Contact".equals(folderId.folderClass)) {
2724             JSONObject jsonResponse = getContactIfExists(folderId, itemName);
2725             if (jsonResponse != null) {
2726                 Contact contact = new Contact(new GraphObject(jsonResponse));
2727                 contact.folderPath = folderPath;
2728                 contact.folderId = folderId;
2729                 return contact;
2730             } else {
2731                 throw new IOException("Item " + folderPath + " " + itemName + " not found");
2732             }
2733         } else if ("IPF.Appointment".equals(folderId.folderClass)) {
2734             JSONObject jsonResponse = getEventIfExists(folderId, itemName);
2735             if (jsonResponse != null) {
2736                 return new Event(folderPath, folderId, new GraphObject(jsonResponse));
2737             } else {
2738                 throw new IOException("Item " + folderPath + " " + itemName + " not found");
2739             }
2740         } else {
2741             throw new UnsupportedOperationException("Item type " + folderId.folderClass + " not supported");
2742         }
2743     }
2744 
2745     private JSONObject getEventIfExists(FolderId folderId, String itemName) throws IOException {
2746         String urlcompname = convertItemNameToEML(itemName);
2747         if (isItemId(urlcompname)) {
2748             String itemId = itemName.substring(0, itemName.length() - 4);
2749             try {
2750                 return executeJsonRequest(new GraphRequestBuilder()
2751                         .setMethod(HttpGet.METHOD_NAME)
2752                         .setMailbox(folderId.mailbox)
2753                         .setObjectType("events")
2754                         .setObjectId(itemId)
2755                         .setSelectFields(EVENT_ATTRIBUTES)
2756                         .setTimezone(getVTimezone().getPropertyValue("TZID"))
2757                 );
2758             } catch (HttpNotFoundException e) {
2759                 // this may be a task item
2760                 FolderId taskFolderId = getFolderId(TASKS);
2761                 return executeJsonRequest(new GraphRequestBuilder()
2762                                 .setMethod(HttpGet.METHOD_NAME)
2763                                 .setMailbox(folderId.mailbox)
2764                                 .setObjectType("todo/lists")
2765                                 .setObjectId(taskFolderId.id)
2766                                 .setChildType("tasks")
2767                                 .setChildId(itemId)
2768                         //.setSelectFields(TODO_PROPERTIES) // TODO set fields for todo items
2769                 );
2770             }
2771         } else {
2772             // try to retrieve by urlcompname
2773             JSONObject jsonResponse;
2774             try {
2775                 jsonResponse = executeJsonRequest(new GraphRequestBuilder()
2776                         .setMethod(HttpGet.METHOD_NAME)
2777                         .setMailbox(folderId.mailbox)
2778                         .setObjectType("calendars")
2779                         .setObjectId(folderId.id)
2780                         .setChildType("events")
2781                         .setFilter(isEqualTo("urlcompname", urlcompname))
2782                         .setSelectFields(EVENT_ATTRIBUTES)
2783                         .setTimezone(getVTimezone().getPropertyValue("TZID"))
2784                 );
2785             } catch (HttpNotFoundException e) {
2786                 // this may be a task item
2787                 FolderId taskFolderId = getFolderId(TASKS);
2788                 jsonResponse = executeJsonRequest(new GraphRequestBuilder()
2789                                 .setMethod(HttpGet.METHOD_NAME)
2790                                 .setMailbox(folderId.mailbox)
2791                                 .setObjectType("todo/lists")
2792                                 .setObjectId(taskFolderId.id)
2793                                 .setChildType("tasks")
2794                                 .setFilter(isEqualTo("urlcompname", urlcompname))
2795                         //.setSelectFields(TODO_PROPERTIES) // TODO set actual properties
2796                 );
2797             }
2798 
2799             JSONArray values = jsonResponse.optJSONArray("value");
2800             if (values != null && values.length() > 0) {
2801                 if (LOGGER.isDebugEnabled()) {
2802                     LOGGER.debug("Found event " + values.optJSONObject(0));
2803                 }
2804                 return values.optJSONObject(0);
2805             }
2806             return null;
2807         }
2808 
2809     }
2810 
2811     private JSONObject getContactIfExists(FolderId folderId, String itemName) throws IOException {
2812         String urlcompname = convertItemNameToEML(itemName);
2813         if (isItemId(urlcompname)) {
2814             // lookup item directly
2815             return executeJsonRequest(new GraphRequestBuilder()
2816                     .setMethod(HttpGet.METHOD_NAME)
2817                     .setMailbox(folderId.mailbox)
2818                     .setObjectType("contactFolders")
2819                     .setObjectId(folderId.id)
2820                     .setChildType("contacts")
2821                     .setChildId(itemName.substring(0, itemName.length() - ".EML".length()))
2822                     .setSelectFields(CONTACT_ATTRIBUTES)
2823             );
2824 
2825         } else {
2826             JSONObject jsonResponse = executeJsonRequest(new GraphRequestBuilder()
2827                     .setMethod(HttpGet.METHOD_NAME)
2828                     .setMailbox(folderId.mailbox)
2829                     .setObjectType("contactFolders")
2830                     .setObjectId(folderId.id)
2831                     .setChildType("contacts")
2832                     .setFilter(isEqualTo("urlcompname", urlcompname))
2833                     .setSelectFields(CONTACT_ATTRIBUTES)
2834             );
2835             // need at least one value
2836             JSONArray values = jsonResponse.optJSONArray("value");
2837             if (values != null && values.length() > 0) {
2838                 if (LOGGER.isDebugEnabled()) {
2839                     LOGGER.debug("Contact " + values.optJSONObject(0));
2840                 }
2841                 return values.optJSONObject(0);
2842             }
2843         }
2844         return null;
2845     }
2846 
2847     @Override
2848     public ContactPhoto getContactPhoto(ExchangeSession.Contact contact) throws IOException {
2849         // don't fetch if haspicture flag is false
2850         if ("false".equals(contact.get("haspicture"))) {
2851             return null;
2852         }
2853         GraphRequestBuilder graphRequestBuilder = new GraphRequestBuilder()
2854                 .setMethod(HttpGet.METHOD_NAME)
2855                 .setMailbox(((Contact) contact).folderId.mailbox)
2856                 .setObjectType("contactFolders")
2857                 .setObjectId(((Contact) contact).folderId.id)
2858                 .setChildType("contacts")
2859                 .setChildId(((Contact) contact).id)
2860                 .setChildSuffix("photo/$value")
2861                 .setAccessToken(token.getAccessToken());
2862 
2863         byte[] contactPhotoBytes;
2864         try (
2865                 CloseableHttpResponse response = httpClient.execute(graphRequestBuilder.build());
2866                 InputStream inputStream = response.getEntity().getContent()
2867         ) {
2868             if (response.getStatusLine().getStatusCode() == HttpStatus.SC_BAD_REQUEST) {
2869                 throw new IOException("Unable to fetch photo" + response.getStatusLine().getReasonPhrase());
2870             }
2871             if (HttpClientAdapter.isGzipEncoded(response)) {
2872                 contactPhotoBytes = IOUtil.readFully(new GZIPInputStream(inputStream));
2873             } else {
2874                 contactPhotoBytes = IOUtil.readFully(inputStream);
2875             }
2876         }
2877         ContactPhoto contactPhoto = new ContactPhoto();
2878         contactPhoto.contentType = "image/jpeg";
2879         contactPhoto.content = IOUtil.encodeBase64AsString(contactPhotoBytes);
2880 
2881         return contactPhoto;
2882     }
2883 
2884     @Override
2885     public void deleteItem(String folderPath, String itemName) throws IOException {
2886         Item item = getItem(folderPath, itemName);
2887         if (item instanceof GraphExchangeSession.Contact) {
2888             FolderId folderId = ((Contact) item).folderId;
2889             executeJsonRequest(new GraphRequestBuilder()
2890                     .setMethod(HttpDelete.METHOD_NAME)
2891                     .setMailbox(folderId.mailbox)
2892                     .setObjectType("contactFolders")
2893                     .setObjectId(folderId.id)
2894                     .setChildType("contacts")
2895                     .setChildId(((Contact) item).id)
2896             );
2897         } else if (item instanceof GraphExchangeSession.Event) {
2898             FolderId folderId = ((Event) item).folderId;
2899 
2900             if (folderId.folderClass.equals("IPF.Appointment")) {
2901                 executeJsonRequest(new GraphRequestBuilder()
2902                         .setMethod(HttpDelete.METHOD_NAME)
2903                         .setMailbox(folderId.mailbox)
2904                         .setObjectType("events")
2905                         .setObjectId(((Event) item).id));
2906             } else {
2907                 executeJsonRequest(new GraphRequestBuilder()
2908                         .setMethod(HttpDelete.METHOD_NAME)
2909                         .setMailbox(folderId.mailbox)
2910                         .setObjectType("todo/lists")
2911                         .setObjectId(folderId.id)
2912                         .setChildType("tasks")
2913                         .setChildId(((Event) item).id)
2914                 );
2915             }
2916         }
2917     }
2918 
2919     @Override
2920     public void processItem(String folderPath, String itemName) throws IOException {
2921 
2922     }
2923 
2924     @Override
2925     public int sendEvent(String icsBody) throws IOException {
2926         return 0;
2927     }
2928 
2929     @Override
2930     protected Contact buildContact(String folderPath, String itemName, Map<String, String> properties, String etag, String noneMatch) throws IOException {
2931         return new Contact(folderPath, itemName, properties, StringUtil.removeQuotes(etag), noneMatch);
2932     }
2933 
2934     @Override
2935     protected ItemResult internalCreateOrUpdateEvent(String folderPath, String itemName, String contentClass, String icsBody, String etag, String noneMatch) throws IOException {
2936         return new Event(folderPath, itemName, contentClass, icsBody, StringUtil.removeQuotes(etag), noneMatch).createOrUpdate();
2937     }
2938 
2939     @Override
2940     public boolean isSharedFolder(String folderPath) {
2941         return folderPath.startsWith("/") && !folderPath.toLowerCase().startsWith(currentMailboxPath);
2942     }
2943 
2944     @Override
2945     public boolean isMainCalendar(String folderPath) throws IOException {
2946         FolderId folderId = getFolderIdIfExists(folderPath);
2947         return folderId.parentFolderId == null && WellKnownFolderName.calendar.name().equals(folderId.id);
2948     }
2949 
2950     @Override
2951     protected String getCalendarEmail(String folderPath) throws IOException {
2952         FolderId folderId = getFolderId(folderPath);
2953 
2954         return folderId.mailbox;
2955     }
2956 
2957     /**
2958      * Map people attributes to LDAP
2959      */
2960     public static final HashMap<String, String> GALFIND_ATTRIBUTE_MAP = new HashMap<>();
2961 
2962     static {
2963         GALFIND_ATTRIBUTE_MAP.put("id", "uid"); // use id as uid
2964 
2965         GALFIND_ATTRIBUTE_MAP.put("displayName", "cn"); // common name
2966         GALFIND_ATTRIBUTE_MAP.put("surname", "sn");
2967         GALFIND_ATTRIBUTE_MAP.put("givenName", "givenname");
2968         GALFIND_ATTRIBUTE_MAP.put("personNotes", "description"); // map personNotes to description
2969 
2970         GALFIND_ATTRIBUTE_MAP.put("companyName", "company"); // company or o
2971         GALFIND_ATTRIBUTE_MAP.put("profession", "profession");
2972         GALFIND_ATTRIBUTE_MAP.put("title", "title");
2973         GALFIND_ATTRIBUTE_MAP.put("department", "department");
2974         GALFIND_ATTRIBUTE_MAP.put("officeLocation", "location");
2975 
2976         GALFIND_ATTRIBUTE_MAP.put("birthday", "birthday"); // TODO may have to convert value
2977 
2978         GALFIND_ATTRIBUTE_MAP.put("yomiCompany", "yomicompany");
2979 
2980         GALFIND_ATTRIBUTE_MAP.put("mailboxType", "mailboxtype");
2981         GALFIND_ATTRIBUTE_MAP.put("personType", "persontype");
2982         GALFIND_ATTRIBUTE_MAP.put("userPrincipalName", "userprincipalname"); // for Active Directory / EntraID entries
2983         GALFIND_ATTRIBUTE_MAP.put("isFavorite", "isfavorite");
2984     }
2985 
2986 
2987     protected GraphExchangeSession.Contact buildGalfindContact(JSONObject response) {
2988         GraphExchangeSession.Contact contact = new GraphExchangeSession.Contact();
2989         contact.setName(response.optString("id"));
2990         contact.put("imapUid", response.optString("id"));
2991         contact.put("uid", response.optString("id"));
2992         Iterator keysIterator = response.keys();
2993         while (keysIterator.hasNext()) {
2994             String key = (String) keysIterator.next();
2995             String attributeName = key;
2996             // special handling for email addresses
2997             if ("emailAddresses".equals(key)) {
2998                 JSONArray emailAddresses = response.optJSONArray("emailAddresses");
2999                 if (emailAddresses != null) {
3000                     for (int i = 0; i < 3; i++) {
3001                         if (emailAddresses.length() > i) {
3002                             contact.put("smtpemail" + (i + 1), emailAddresses.optJSONObject(i).optString("address"));
3003                         }
3004                     }
3005                 }
3006             // map phone numbers
3007             } else if ("phones".equals(key)) {
3008                 JSONArray phones = response.optJSONArray("phones");
3009                 if (phones != null) {
3010                     for (int i=0;i<phones.length();i++) {
3011                         String phoneType = phones.optJSONObject(i).optString("type");
3012                         String phoneNumber = phones.optJSONObject(i).optString("number");
3013                         if ("business".equals(phoneType)) {
3014                             contact.put("telephoneNumber", phoneNumber);
3015                         } else if ("mobile".equals(phoneType)) {
3016                             contact.put("mobile", phoneNumber);
3017                         } else if ("home".equals(phoneType)) {
3018                             contact.put("homePhone", phoneNumber);
3019                         } else {
3020                             LOGGER.debug("Unknown phoneType "+phoneType);
3021                             contact.put(phoneType+"Phone", phoneNumber);
3022                         }
3023                     }
3024                 }
3025             } else if ("sources".equals(key)) {
3026                 JSONArray sources = response.optJSONArray("sources");
3027                 if (sources != null && sources.length() > 0) {
3028                     String sourceType = sources.optJSONObject(0).optString("type");
3029                     contact.put("sourceType", sourceType);
3030                 }
3031             } else {
3032                 if (GALFIND_ATTRIBUTE_MAP.get(key) != null) {
3033                     attributeName = GALFIND_ATTRIBUTE_MAP.get(key);
3034                 } else {
3035                     LOGGER.debug("Unknown attribute " + attributeName);
3036                 }
3037 
3038                 String attributeValue = response.optString(key);
3039                 if (attributeValue != null) {
3040                     contact.put(attributeName, attributeValue);
3041                 }
3042             }
3043         }
3044         return contact;
3045     }
3046 
3047     @Override
3048     public Map<String, ExchangeSession.Contact> galFind(Condition condition, Set<String> returningAttributes, int sizeLimit) throws IOException {
3049         Map<String, ExchangeSession.Contact> contacts = new HashMap<>();
3050 
3051         // poor implementation of search filter based on people endpoint limitations
3052         String search = null;
3053         String id = null;
3054         if (condition instanceof AttributeCondition) {
3055             if ("imapUid".equals(((AttributeCondition) condition).getAttributeName())) {
3056                 id = ((AttributeCondition) condition).getValue();
3057             } else {
3058                 search = ((AttributeCondition) condition).getValue();
3059             }
3060         }
3061 
3062         if (id != null) {
3063 
3064             // lookup by id only if this is actually an id
3065             if (id.length() == 36) {
3066                 GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder()
3067                         .setMethod(HttpGet.METHOD_NAME)
3068                         .addHeader("X-PeopleQuery-QuerySources", "Mailbox,Directory")
3069                         .setObjectType("people")
3070                         .setObjectId(id)
3071                         ;
3072                 JSONObject peopleObject = null;
3073 
3074                 try {
3075                     peopleObject = executeJsonRequest(httpRequestBuilder);
3076                 } catch (HttpNotFoundException e) {
3077                     LOGGER.warn("No person found for id "+id);
3078                 }
3079 
3080                 if (peopleObject != null) {
3081                     Contact contact = buildGalfindContact(peopleObject);
3082 
3083                     contacts.put(contact.getName().toLowerCase(), contact);
3084                     LOGGER.debug("found user " + contact.getName());
3085                 }
3086             }
3087 
3088         } else {
3089             GraphRequestBuilder httpRequestBuilder = new GraphRequestBuilder()
3090                     .setMethod(HttpGet.METHOD_NAME)
3091                     .addHeader("X-PeopleQuery-QuerySources", "Mailbox,Directory")
3092                     .setObjectType("people")
3093                     .setSearch(search)
3094                     .setSizeLimit(sizeLimit);
3095             LOGGER.debug("search users");
3096             GraphIterator graphIterator = executeSearchRequest(httpRequestBuilder);
3097 
3098             while (graphIterator.hasNext() && contacts.size() < sizeLimit) {
3099                 Contact contact = buildGalfindContact(graphIterator.next());
3100                 contacts.put(contact.getName().toLowerCase(), contact);
3101                 LOGGER.debug("found user " + contact.getName());
3102             }
3103         }
3104 
3105         return contacts;
3106     }
3107 
3108     @Override
3109     protected String getFreeBusyData(String attendee, String start, String end, int interval) throws IOException {
3110         // https://learn.microsoft.com/en-us/graph/outlook-get-free-busy-schedule
3111         // POST /me/calendar/getschedule
3112         return null;
3113     }
3114 
3115     @Override
3116     protected void loadVtimezone() {
3117         try {
3118             // default from Davmail settings
3119             String timezoneId = Settings.getProperty("davmail.timezoneId", null);
3120             // use timezone from mailbox
3121             if (timezoneId == null) {
3122                 try {
3123                     timezoneId = getMailboxSettings().optString("timeZone", null);
3124                 } catch (HttpForbiddenException e) {
3125                     LOGGER.warn("token does not grant MailboxSettings.Read");
3126                 }
3127             }
3128             // last failover: use GMT
3129             if (timezoneId == null) {
3130                 LOGGER.warn("Unable to get user timezone, using GMT Standard Time. Set davmail.timezoneId setting to override this.");
3131                 timezoneId = "GMT Standard Time";
3132             }
3133             this.vTimezone = new VObject(ResourceBundle.getBundle("vtimezones").getString(timezoneId));
3134 
3135         } catch (IOException | MissingResourceException e) {
3136             LOGGER.warn("Unable to get VTIMEZONE info: " + e, e);
3137         }
3138     }
3139 
3140     private JSONObject getMailboxSettings() throws IOException {
3141         return executeJsonRequest(new GraphRequestBuilder()
3142                 .setMethod(HttpGet.METHOD_NAME)
3143                 .setObjectType("mailboxSettings"));
3144     }
3145 
3146     class GraphIterator {
3147 
3148         private JSONObject jsonObject;
3149         private JSONArray values;
3150         private String nextLink;
3151         private int index;
3152 
3153         public GraphIterator(JSONObject jsonObject) throws JSONException {
3154             this.jsonObject = jsonObject;
3155             nextLink = jsonObject.optString("@odata.nextLink", null);
3156             values = jsonObject.getJSONArray("value");
3157         }
3158 
3159         public boolean hasNext() throws IOException {
3160             if (index < values.length()) {
3161                 return true;
3162             } else if (nextLink != null) {
3163                 fetchNextPage();
3164                 return values.length() > 0;
3165             } else {
3166                 return false;
3167             }
3168         }
3169 
3170         public JSONObject next() throws IOException {
3171             if (!hasNext()) {
3172                 throw new NoSuchElementException();
3173             }
3174             try {
3175                 if (index >= values.length() && nextLink != null) {
3176                     fetchNextPage();
3177                 }
3178                 return values.getJSONObject(index++);
3179             } catch (JSONException e) {
3180                 throw new IOException(e.getMessage(), e);
3181             }
3182         }
3183 
3184         private void fetchNextPage() throws IOException {
3185             HttpGet request = new HttpGet(nextLink);
3186             request.setHeader("Authorization", "Bearer " + token.getAccessToken());
3187             try (
3188                     CloseableHttpResponse response = httpClient.execute(request)
3189             ) {
3190                 jsonObject = new JsonResponseHandler().handleResponse(response);
3191                 nextLink = jsonObject.optString("@odata.nextLink", null);
3192                 // workaround for people search bug
3193                 if (nextLink != null && nextLink.endsWith("skip=0")) {
3194                     nextLink = null;
3195                 }
3196                 values = jsonObject.getJSONArray("value");
3197                 index = 0;
3198             } catch (JSONException e) {
3199                 throw new IOException(e.getMessage(), e);
3200             }
3201         }
3202     }
3203 
3204     private GraphIterator executeSearchRequest(GraphRequestBuilder httpRequestBuilder) throws IOException {
3205         try {
3206             return new GraphIterator(executeJsonRequest(httpRequestBuilder));
3207         } catch (JSONException e) {
3208             throw new IOException(e.getMessage(), e);
3209         }
3210     }
3211 
3212     private JSONObject executeJsonRequest(GraphRequestBuilder httpRequestBuilder) throws IOException {
3213         JSONObject jsonResponse = null;
3214         boolean isThrottled;
3215         do {
3216             HttpRequestBase request = httpRequestBuilder
3217                     .setAccessToken(token.getAccessToken())
3218                     .build();
3219 
3220             // DEBUG only, disable gzip encoding
3221             //request.setHeader("Accept-Encoding", "");
3222             try (
3223                     CloseableHttpResponse response = httpClient.execute(request)
3224             ) {
3225                 if (response.getStatusLine().getStatusCode() == HttpStatus.SC_BAD_REQUEST) {
3226                     LOGGER.warn(response.getStatusLine());
3227                 }
3228                 isThrottled = handleThrottling(response);
3229                 if (!isThrottled) {
3230                     jsonResponse = new JsonResponseHandler().handleResponse(response);
3231                 }
3232             }
3233         } while (isThrottled);
3234         return jsonResponse;
3235     }
3236 
3237 
3238     /**
3239      * Execute graph request and wrap response in a graph object
3240      * @param httpRequestBuilder request builder
3241      * @return returned graph object
3242      * @throws IOException on error
3243      */
3244     private GraphObject executeGraphRequest(GraphRequestBuilder httpRequestBuilder) throws IOException {
3245         HttpRequestBase request = httpRequestBuilder
3246                 .setAccessToken(token.getAccessToken())
3247                 .build();
3248 
3249         GraphObject graphObject = null;
3250         boolean isThrottled;
3251         do {
3252             // DEBUG only, disable gzip encoding
3253             //request.setHeader("Accept-Encoding", "");
3254             try (
3255                     CloseableHttpResponse response = httpClient.execute(request)
3256             ) {
3257                 if (response.getStatusLine().getStatusCode() == HttpStatus.SC_BAD_REQUEST) {
3258                     LOGGER.warn("Request returned " + response.getStatusLine());
3259                 }
3260                 isThrottled = handleThrottling(response);
3261                 if (!isThrottled) {
3262                     graphObject = new GraphObject(new JsonResponseHandler().handleResponse(response));
3263                     graphObject.statusCode = response.getStatusLine().getStatusCode();
3264                 }
3265             }
3266         } while (isThrottled);
3267         return graphObject;
3268     }
3269 
3270     /**
3271      * Detect throttling and wait according to Retry-After header.
3272      * See <a href="https://learn.microsoft.com/en-us/graph/throttling">https://learn.microsoft.com/en-us/graph/throttling</a>
3273      * @param response HTTP response
3274      * @return true if throttled, false otherwise
3275      */
3276     private boolean handleThrottling(CloseableHttpResponse response) {
3277         long retryDelay = 0;
3278         if (response.getStatusLine().getStatusCode() == HttpStatus.SC_TOO_MANY_REQUESTS) {
3279             LOGGER.info("Detected throttling " + response.getStatusLine());
3280             Header retryAfter = response.getFirstHeader("Retry-After");
3281             if (retryAfter != null) {
3282                 retryDelay = Long.parseLong(retryAfter.getValue()) + 1;
3283                 LOGGER.debug("Waiting " + retryDelay + " seconds to retry request");
3284                 try {
3285                     Thread.sleep(retryDelay * 1000L);
3286                 } catch (InterruptedException e) {
3287                     Thread.currentThread().interrupt();
3288                 }
3289             }
3290         }
3291         return retryDelay > 0;
3292     }
3293 
3294     /**
3295      * Check if itemName is long and base64 encoded.
3296      * User-generated item names are usually short
3297      *
3298      * @param itemName item name
3299      * @return true if itemName is an EWS item id
3300      */
3301     protected static boolean isItemId(String itemName) {
3302         return itemName.length() >= 140
3303                 // the item name is base64url
3304                 && itemName.matches("^([A-Za-z0-9-_]{4})*([A-Za-z0-9-_]{4}|[A-Za-z0-9-_]{3}=|[A-Za-z0-9-_]{2}==)\\.EML$")
3305                 && itemName.indexOf(' ') < 0;
3306     }
3307 
3308 }