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