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