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