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