View Javadoc
1   /*
2    * DavMail POP/IMAP/SMTP/CalDav/LDAP Exchange Gateway
3    * Copyright (C) 2009  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  package davmail.caldav;
20  
21  import davmail.AbstractConnection;
22  import davmail.BundleMessage;
23  import davmail.DavGateway;
24  import davmail.Settings;
25  import davmail.exception.DavMailAuthenticationException;
26  import davmail.exception.DavMailException;
27  import davmail.exception.HttpNotFoundException;
28  import davmail.exception.HttpServerErrorException;
29  import davmail.exchange.ExchangeSession;
30  import davmail.exchange.ExchangeSessionFactory;
31  import davmail.exchange.ICSBufferedReader;
32  import davmail.exchange.NetworkDownException;
33  import davmail.exchange.XMLStreamUtil;
34  import davmail.exchange.dav.DavExchangeSession;
35  import davmail.http.URIUtil;
36  import davmail.ui.tray.DavGatewayTray;
37  import davmail.util.IOUtil;
38  import davmail.util.StringUtil;
39  import org.apache.http.HttpStatus;
40  import org.apache.http.client.HttpResponseException;
41  import org.apache.http.impl.EnglishReasonPhraseCatalog;
42  import org.apache.log4j.Logger;
43  
44  import javax.net.ssl.SSLException;
45  import javax.xml.stream.XMLStreamException;
46  import javax.xml.stream.XMLStreamReader;
47  import java.io.BufferedOutputStream;
48  import java.io.IOException;
49  import java.io.OutputStream;
50  import java.io.OutputStreamWriter;
51  import java.io.StringReader;
52  import java.io.Writer;
53  import java.net.Socket;
54  import java.net.SocketException;
55  import java.net.SocketTimeoutException;
56  import java.net.URI;
57  import java.net.UnknownHostException;
58  import java.nio.charset.StandardCharsets;
59  import java.text.SimpleDateFormat;
60  import java.util.ArrayList;
61  import java.util.BitSet;
62  import java.util.Date;
63  import java.util.HashMap;
64  import java.util.HashSet;
65  import java.util.List;
66  import java.util.Locale;
67  import java.util.Map;
68  import java.util.Set;
69  import java.util.StringTokenizer;
70  
71  /**
72   * Handle a caldav connection.
73   */
74  public class CaldavConnection extends AbstractConnection {
75      /**
76       * Maximum keep alive time in seconds
77       */
78      protected static final int MAX_KEEP_ALIVE_TIME = 300;
79      protected final Logger wireLogger = Logger.getLogger(this.getClass());
80  
81      protected boolean closed;
82  
83      /**
84       * custom url encode path set for iCal 5
85       */
86      public static final BitSet ical_allowed_abs_path = new BitSet(256);
87  
88      static {
89          ical_allowed_abs_path.or(URIUtil.allowed_abs_path);
90          ical_allowed_abs_path.clear('@');
91      }
92  
93      static String encodePath(CaldavRequest request, String path) {
94          if (request.isIcal5()) {
95              return URIUtil.encode(path, ical_allowed_abs_path);
96          } else {
97              return URIUtil.encodePath(path);
98          }
99      }
100 
101     /**
102      * Initialize the streams and start the thread.
103      *
104      * @param clientSocket Caldav client socket
105      */
106     public CaldavConnection(Socket clientSocket) {
107         super(CaldavConnection.class.getSimpleName(), clientSocket, "UTF-8");
108         // set caldav logging to davmail logging level
109         wireLogger.setLevel(Settings.getLoggingLevel("davmail"));
110     }
111 
112     protected Map<String, String> parseHeaders() throws IOException {
113         HashMap<String, String> headers = new HashMap<>();
114         String line;
115         while ((line = readClient()) != null && !line.isEmpty()) {
116             int index = line.indexOf(':');
117             if (index <= 0) {
118                 wireLogger.warn("Invalid header: " + line);
119                 throw new DavMailException("EXCEPTION_INVALID_HEADER");
120             }
121             headers.put(line.substring(0, index).toLowerCase(), line.substring(index + 1).trim());
122         }
123         return headers;
124     }
125 
126     protected String getContent(String contentLength) throws IOException {
127         if (contentLength == null || contentLength.isEmpty()) {
128             return null;
129         } else {
130             int size;
131             try {
132                 size = Integer.parseInt(contentLength);
133             } catch (NumberFormatException e) {
134                 throw new DavMailException("EXCEPTION_INVALID_CONTENT_LENGTH", contentLength);
135             }
136             String content = in.readContentAsString(size);
137             if (wireLogger.isDebugEnabled()) {
138                 wireLogger.debug("< " + content);
139             }
140             return content;
141         }
142     }
143 
144     protected void setSocketTimeout(String keepAliveValue) throws IOException {
145         if (keepAliveValue != null && !keepAliveValue.isEmpty()) {
146             int keepAlive;
147             try {
148                 keepAlive = Integer.parseInt(keepAliveValue);
149             } catch (NumberFormatException e) {
150                 throw new DavMailException("EXCEPTION_INVALID_KEEPALIVE", keepAliveValue);
151             }
152             if (keepAlive > MAX_KEEP_ALIVE_TIME) {
153                 keepAlive = MAX_KEEP_ALIVE_TIME;
154             }
155             client.setSoTimeout(keepAlive * 1000);
156             DavGatewayTray.debug(new BundleMessage("LOG_SET_SOCKET_TIMEOUT", keepAlive));
157         }
158     }
159 
160     @Override
161     public void run() {
162         String line;
163         StringTokenizer tokens;
164 
165         try {
166             while (!closed) {
167                 line = readClient();
168                 // unable to read line, connection closed?
169                 if (line == null) {
170                     break;
171                 }
172                 tokens = new StringTokenizer(line);
173                 if (!tokens.hasMoreTokens()) {
174                     sendErr(HttpStatus.SC_BAD_REQUEST, "Empty request line");
175                     break;
176                 }
177                 String command = tokens.nextToken();
178                 if (!tokens.hasMoreTokens()) {
179                     sendErr(HttpStatus.SC_BAD_REQUEST, "Missing request path");
180                     break;
181                 }
182                 Map<String, String> headers = parseHeaders();
183                 String encodedPath = StringUtil.encodePlusSign(tokens.nextToken());
184                 String path = URIUtil.decode(encodedPath);
185                 String content = getContent(headers.get("content-length"));
186                 setSocketTimeout(headers.get("keep-alive"));
187                 // client requested connection close
188                 closed = "close".equals(headers.get("connection"));
189                 if ("OPTIONS".equals(command)) {
190                     sendOptions();
191                 } else if (!headers.containsKey("authorization")) {
192                     sendUnauthorized();
193                 } else {
194                     // need to check session on each request, credentials may have changed, or session expired
195                     try {
196                         decodeCredentials(headers.get("authorization"));
197                         session = ExchangeSessionFactory.getInstance(userName, password);
198                         logConnection("LOGON", userName);
199                         handleRequest(command, path, headers, content);
200                     } catch (DavMailAuthenticationException e) {
201                         logConnection("FAILED", userName);
202                         if (Settings.getBooleanProperty("davmail.enableKerberos")) {
203                             // authentication failed in Kerberos mode => not available
204                             throw new HttpServerErrorException("Kerberos authentication failed");
205                         } else {
206                             sendUnauthorized();
207                         }
208                     }
209                 }
210 
211                 os.flush();
212                 DavGatewayTray.resetIcon();
213             }
214         } catch (SocketTimeoutException e) {
215             DavGatewayTray.debug(new BundleMessage("LOG_CLOSE_CONNECTION_ON_TIMEOUT"));
216         } catch (SocketException e) {
217             DavGatewayTray.debug(new BundleMessage("LOG_CONNECTION_CLOSED"));
218         } catch (Exception e) {
219             if (!(e instanceof HttpResponseException)) {
220                 DavGatewayTray.log(e);
221             }
222             try {
223                 sendErr(e);
224             } catch (IOException e2) {
225                 DavGatewayTray.debug(new BundleMessage("LOG_EXCEPTION_SENDING_ERROR_TO_CLIENT"));
226             }
227         } finally {
228             close();
229         }
230         DavGatewayTray.resetIcon();
231     }
232 
233     /**
234      * Handle caldav request.
235      *
236      * @param command Http command
237      * @param path    request path
238      * @param headers Http headers map
239      * @param body    request body
240      * @throws IOException on error
241      */
242     public void handleRequest(String command, String path, Map<String, String> headers, String body) throws IOException {
243         CaldavRequest request = new CaldavRequest(command, path, headers, body);
244         if (request.isOptions()) {
245             sendOptions();
246         } else if (request.isHead() && request.isRoot()) {
247             sendChunkedHttpResponse(HttpStatus.SC_OK, null);
248         } else if (request.isPropFind() && request.isRoot()) {
249             sendRoot(request);
250         } else if (request.isGet() && request.isRoot()) {
251             sendGetRoot();
252         } else if (request.isPath(1, "principals")) {
253             handlePrincipals(request);
254         } else if (request.isPath(1, "users")) {
255             if (request.isPropFind() && request.isPathLength(3)) {
256                 sendUserRoot(request);
257             } else {
258                 handleFolderOrItem(request);
259             }
260         } else if (request.isPath(1, "public")) {
261             handleFolderOrItem(request);
262         } else if (request.isPath(1, "directory")) {
263             sendDirectory(request);
264         } else if (request.isPath(1, ".well-known")) {
265             sendWellKnown();
266         } else {
267             sendNotFound(request);
268         }
269     }
270 
271     protected void handlePrincipals(CaldavRequest request) throws IOException {
272         if (request.isPath(2, "users")) {
273             if (request.isPropFind() && request.isPathLength(4)) {
274                 sendPrincipal(request, "users", URIUtil.decode(request.getPathElement(3)));
275                 // send back principal on search
276             } else if (request.isReport() && request.isPathLength(3)) {
277                 sendPrincipal(request, "users", session.getEmail());
278                 // iCal current-user-principal request
279             } else if (request.isPropFind() && request.isPathLength(3)) {
280                 sendPrincipalsFolder(request);
281             } else {
282                 sendNotFound(request);
283             }
284         } else if (request.isPath(2, "public")) {
285             StringBuilder prefixBuffer = new StringBuilder("public");
286             for (int i = 3; i < request.getPathLength() - 1; i++) {
287                 prefixBuffer.append('/').append(request.getPathElement(i));
288             }
289             sendPrincipal(request, URIUtil.decode(prefixBuffer.toString()), URIUtil.decode(request.getLastPath()));
290         } else {
291             sendNotFound(request);
292         }
293     }
294 
295     protected void handleFolderOrItem(CaldavRequest request) throws IOException {
296         String lastPath = StringUtil.xmlDecode(request.getLastPath());
297         // folder requests
298         if (request.isPropFind() && "inbox".equals(lastPath)) {
299             sendInbox(request);
300         } else if (request.isPropFind() && "outbox".equals(lastPath)) {
301             sendOutbox(request);
302         } else if (request.isPost() && "outbox".equals(lastPath)) {
303             if (request.isFreeBusy()) {
304                 sendFreeBusy(request.getBody());
305             } else {
306                 int status = session.sendEvent(request.getBody());
307                 // TODO: implement Itip response body
308                 sendHttpResponse(status);
309             }
310         } else if (request.isPropFind()) {
311             sendFolderOrItem(request);
312         } else if (request.isPropPatch()) {
313             patchCalendar(request);
314         } else if (request.isReport()) {
315             reportItems(request);
316             // event requests
317         } else if (request.isPut()) {
318             String etag = request.getHeader("if-match");
319             String noneMatch = request.getHeader("if-none-match");
320             if (request.getBody() == null || request.getBody().isEmpty()) {
321                 // thunderbird check, ignore
322                 sendHttpResponse(HttpStatus.SC_OK, null);
323             } else {
324                 ExchangeSession.ItemResult itemResult = session.createOrUpdateItem(request.getFolderPath(), lastPath, request.getBody(), etag, noneMatch);
325                 sendHttpResponse(itemResult.status, buildEtagHeader(request, itemResult), null, "", true);
326             }
327         } else if (request.isDelete()) {
328             if (request.getFolderPath().endsWith("inbox")) {
329                 session.processItem(request.getFolderPath(), lastPath);
330             } else {
331                 session.deleteItem(request.getFolderPath(), lastPath);
332             }
333             sendHttpResponse(HttpStatus.SC_OK);
334         } else if (request.isGet()) {
335             if (request.path.endsWith("/")) {
336                 // GET request on a folder => build ics content of all folder events
337                 String folderPath = request.getFolderPath();
338                 ExchangeSession.Folder folder = session.getFolder(folderPath);
339                 if (folder.isContact()) {
340                     List<ExchangeSession.Contact> contacts = session.getAllContacts(folderPath, !isOldCardavClient(request));
341                     ChunkedResponse response = new ChunkedResponse(HttpStatus.SC_OK, "text/vcard;charset=UTF-8");
342 
343                     for (ExchangeSession.Contact contact : contacts) {
344                         contact.setVCardVersion(getVCardVersion(request));
345                         String contactBody = contact.getBody();
346                         if (contactBody != null) {
347                             response.append(contactBody);
348                             response.append("\n");
349                         }
350                     }
351                     response.close();
352 
353                 } else if (folder.isCalendar() || folder.isTask()) {
354                     List<ExchangeSession.Event> events = session.getAllEvents(folderPath);
355                     ChunkedResponse response = new ChunkedResponse(HttpStatus.SC_OK, "text/calendar;charset=UTF-8");
356                     response.append("BEGIN:VCALENDAR\r\n");
357                     response.append("VERSION:2.0\r\n");
358                     response.append("PRODID:-//davmail.sf.net/NONSGML DavMail Calendar V1.1//EN\r\n");
359                     response.append("METHOD:PUBLISH\r\n");
360 
361                     for (ExchangeSession.Event event : events) {
362                         String icsContent = StringUtil.getToken(event.getBody(), "BEGIN:VTIMEZONE", "END:VCALENDAR");
363                         if (icsContent != null) {
364                             response.append("BEGIN:VTIMEZONE");
365                             response.append(icsContent);
366                         } else {
367                             icsContent = StringUtil.getToken(event.getBody(), "BEGIN:VEVENT", "END:VCALENDAR");
368                             if (icsContent != null) {
369                                 response.append("BEGIN:VEVENT");
370                                 response.append(icsContent);
371                             }
372                         }
373                     }
374                     response.append("END:VCALENDAR");
375                     response.close();
376                 } else {
377                     sendHttpResponse(HttpStatus.SC_OK, buildEtagHeader(folder.etag), "text/html", (byte[]) null, true);
378                 }
379             } else {
380                 ExchangeSession.Item item = session.getItem(request.getFolderPath(), lastPath);
381                 if (item instanceof ExchangeSession.Contact) {
382                     ((ExchangeSession.Contact) item).setVCardVersion(getVCardVersion(request));
383                 }
384                 sendHttpResponse(HttpStatus.SC_OK, buildEtagHeader(item.getEtag()), item.getContentType(), item.getBody(), true);
385             }
386         } else if (request.isHead()) {
387             // test event
388             ExchangeSession.Item item = session.getItem(request.getFolderPath(), lastPath);
389             sendHttpResponse(HttpStatus.SC_OK, buildEtagHeader(item.getEtag()), item.getContentType(), (byte[]) null, true);
390         } else if (request.isMkCalendar()) {
391             HashMap<String, String> properties = new HashMap<>();
392             //properties.put("displayname", request.getProperty("displayname"));
393             int status = session.createCalendarFolder(request.getFolderPath(), properties);
394             sendHttpResponse(status, null);
395         } else if (request.isMove()) {
396             String destinationUrl = request.getHeader("destination");
397             session.moveItem(request.path, URIUtil.decode(URI.create(destinationUrl).toURL().getPath()));
398             sendHttpResponse(HttpStatus.SC_CREATED, null);
399         } else {
400             sendNotFound(request);
401         }
402 
403     }
404 
405     private boolean isOldCardavClient(CaldavRequest request) {
406         return request.isUserAgent("iOS/") || request.isUserAgent("Mac OS X/") || request.isUserAgent("macOS/");
407     }
408 
409     private String getVCardVersion(CaldavRequest request) {
410         if (isOldCardavClient(request)) {
411             return "3.0";
412         } else {
413             return "4.0";
414         }
415     }
416 
417     protected HashMap<String, String> buildEtagHeader(CaldavRequest request, ExchangeSession.ItemResult itemResult) {
418         HashMap<String, String> headers = null;
419         if (itemResult.etag != null) {
420             headers = new HashMap<>();
421             headers.put("ETag", itemResult.etag);
422         }
423         if (itemResult.itemName != null) {
424             if (headers == null) {
425                 headers = new HashMap<>();
426             }
427             headers.put("Location", buildEventPath(request, itemResult.itemName));
428         }
429         return headers;
430     }
431 
432 
433     protected HashMap<String, String> buildEtagHeader(String etag) {
434         if (etag != null) {
435             HashMap<String, String> etagHeader = new HashMap<>();
436             etagHeader.put("ETag", etag);
437             return etagHeader;
438         } else {
439             return null;
440         }
441     }
442 
443     private void appendContactsResponses(CaldavResponse response, CaldavRequest request, List<ExchangeSession.Contact> contacts) throws IOException {
444         if (contacts != null) {
445             int count = 0;
446             for (ExchangeSession.Contact contact : contacts) {
447                 DavGatewayTray.debug(new BundleMessage("LOG_LISTING_ITEM", ++count, contacts.size()));
448                 DavGatewayTray.switchIcon();
449                 appendItemResponse(response, request, contact);
450             }
451         }
452     }
453 
454     protected void appendEventsResponses(CaldavResponse response, CaldavRequest request, List<ExchangeSession.Event> events) throws IOException {
455         if (events != null) {
456             int size = events.size();
457             int count = 0;
458             for (ExchangeSession.Event event : events) {
459                 DavGatewayTray.debug(new BundleMessage("LOG_LISTING_ITEM", ++count, size));
460                 DavGatewayTray.switchIcon();
461                 appendItemResponse(response, request, event);
462             }
463         }
464     }
465 
466     protected String buildEventPath(CaldavRequest request, String itemName) {
467         StringBuilder eventPath = new StringBuilder();
468         eventPath.append(encodePath(request, request.getFolderPath()));
469         if (!(eventPath.charAt(eventPath.length() - 1) == '/')) {
470             eventPath.append('/');
471         }
472         eventPath.append(URIUtil.encodeWithinQuery(StringUtil.xmlEncode(itemName)));
473         return eventPath.toString();
474     }
475 
476     protected void appendItemResponse(CaldavResponse response, CaldavRequest request, ExchangeSession.Item item) throws IOException {
477         response.startResponse(buildEventPath(request, item.getName()));
478         response.startPropstat();
479         if (request.hasProperty("calendar-data") && item instanceof ExchangeSession.Event) {
480             response.appendCalendarData(item.getBody());
481         }
482         if (request.hasProperty("address-data") && item instanceof ExchangeSession.Contact) {
483             ((ExchangeSession.Contact) item).setVCardVersion(getVCardVersion(request));
484             response.appendContactData(item.getBody());
485         }
486         if (request.hasProperty("getcontenttype")) {
487             if (item instanceof ExchangeSession.Event) {
488                 response.appendProperty("D:getcontenttype", "text/calendar; component=vevent");
489             } else if (item instanceof ExchangeSession.Contact) {
490                 response.appendProperty("D:getcontenttype", "text/vcard");
491             }
492         }
493         if (request.hasProperty("getetag")) {
494             response.appendProperty("D:getetag", item.getEtag());
495         }
496         if (request.hasProperty("resourcetype")) {
497             response.appendProperty("D:resourcetype");
498         }
499         if (request.hasProperty("displayname")) {
500             response.appendProperty("D:displayname", StringUtil.xmlEncode(item.getName()));
501         }
502         response.endPropStatOK();
503         response.endResponse();
504     }
505 
506     /**
507      * Append folder object to Caldav response.
508      *
509      * @param response  Caldav response
510      * @param request   Caldav request
511      * @param folder    folder object
512      * @param subFolder calendar folder path relative to request path
513      * @throws IOException on error
514      */
515     public void appendFolderOrItem(CaldavResponse response, CaldavRequest request, ExchangeSession.Folder folder, String subFolder) throws IOException {
516         appendFolderOrItem(response, request, folder, subFolder, false);
517     }
518 
519     /**
520      * Append folder object to Caldav response.
521      *
522      * @param response Caldav response
523      * @param request Caldav request
524      * @param folder folder object
525      * @param subFolder calendar folder path relative to request path
526      * @param nestedCalendar folder is listed as a child of another calendar
527      * @throws IOException on error
528      */
529     public void appendFolderOrItem(CaldavResponse response, CaldavRequest request, ExchangeSession.Folder folder, String subFolder,
530                                    boolean nestedCalendar) throws IOException {
531         response.startResponse(encodePath(request, request.getPath(subFolder)));
532         response.startPropstat();
533 
534         if (request.hasProperty("resourcetype")) {
535             if (folder.isContact()) {
536                 response.appendProperty("D:resourcetype", "<D:collection/>" +
537                         "<E:addressbook/>");
538             } else if (folder.isCalendar() || folder.isTask()) {
539                 response.appendProperty("D:resourcetype", "<D:collection/>" + "<C:calendar/>");
540             } else {
541                 response.appendProperty("D:resourcetype", "<D:collection/>");
542             }
543 
544         }
545         if (request.hasProperty("owner")) {
546             if ("users".equals(request.getPathElement(1))) {
547                 response.appendHrefProperty("D:owner", "/principals/users/" + request.getPathElement(2));
548             } else {
549                 response.appendHrefProperty("D:owner", "/principals" + request.getPath());
550             }
551         }
552         if (request.hasProperty("getcontenttype")) {
553             if (nestedCalendar) {
554                 // clients listing calendar items must not mistake a sub calendar for an event
555                 response.appendProperty("D:getcontenttype", "httpd/unix-directory");
556             } else if (folder.isContact()) {
557                 response.appendProperty("D:getcontenttype", "text/x-vcard");
558             } else if (folder.isCalendar()) {
559                 response.appendProperty("D:getcontenttype", "text/calendar; component=vevent");
560             } else if (folder.isTask()) {
561                 response.appendProperty("D:getcontenttype", "text/calendar; component=vtodo");
562             }
563         }
564         if (request.hasProperty("getetag")) {
565             response.appendProperty("D:getetag", folder.etag);
566         }
567         if (request.hasProperty("getctag")) {
568             response.appendProperty("CS:getctag", "CS=\"http://calendarserver.org/ns/\"",
569                     IOUtil.encodeBase64AsString(folder.ctag));
570         }
571         if (request.hasProperty("displayname")) {
572             if (subFolder == null || subFolder.isEmpty()) {
573                 // use i18n calendar name as display name
574                 String displayname = request.getLastPath();
575                 if ("calendar".equals(displayname)) {
576                     displayname = folder.displayName;
577                 }
578                 response.appendProperty("D:displayname", StringUtil.xmlEncode(displayname));
579             } else {
580                 response.appendProperty("D:displayname", StringUtil.xmlEncode(subFolder));
581             }
582         }
583         if (request.hasProperty("calendar-description")) {
584             response.appendProperty("C:calendar-description", "");
585         }
586         if (request.hasProperty("supported-calendar-component-set")) {
587             if (folder.isCalendar()) {
588                 response.appendProperty("C:supported-calendar-component-set", "<C:comp name=\"VEVENT\"/><C:comp name=\"VTODO\"/>");
589             } else if (folder.isTask()) {
590                 response.appendProperty("C:supported-calendar-component-set", "<C:comp name=\"VTODO\"/>");
591             }
592         }
593 
594         if (request.hasProperty("current-user-privilege-set")) {
595             response.appendProperty("D:current-user-privilege-set", "<D:privilege><D:read/><D:write/></D:privilege>");
596         }
597 
598         response.endPropStatOK();
599         response.endResponse();
600     }
601 
602     /**
603      * Append calendar inbox object to Caldav response.
604      *
605      * @param response  Caldav response
606      * @param request   Caldav request
607      * @param subFolder inbox folder path relative to request path
608      * @throws IOException on error
609      */
610     public void appendInbox(CaldavResponse response, CaldavRequest request, String subFolder) throws IOException {
611         String ctag = "0";
612         String etag = "0";
613         String folderPath = request.getFolderPath(subFolder);
614         // do not try to access inbox on shared calendar
615         if (!session.isSharedFolder(folderPath)) {
616             try {
617                 ExchangeSession.Folder folder = session.getFolder(folderPath);
618                 ctag = IOUtil.encodeBase64AsString(folder.ctag);
619                 etag = IOUtil.encodeBase64AsString(folder.etag);
620             } catch (HttpResponseException e) {
621                 // unauthorized access, probably an inbox on shared calendar
622                 DavGatewayTray.debug(new BundleMessage("LOG_ACCESS_FORBIDDEN", folderPath, e.getMessage()));
623             }
624         }
625         response.startResponse(encodePath(request, request.getPath(subFolder)));
626         response.startPropstat();
627 
628         if (request.hasProperty("resourcetype")) {
629             response.appendProperty("D:resourcetype", "<D:collection/>" +
630                     "<C:schedule-inbox xmlns:C=\"urn:ietf:params:xml:ns:caldav\"/>");
631         }
632         if (request.hasProperty("getcontenttype")) {
633             response.appendProperty("D:getcontenttype", "text/calendar; component=vevent");
634         }
635         if (request.hasProperty("getctag")) {
636             response.appendProperty("CS:getctag", "CS=\"http://calendarserver.org/ns/\"", ctag);
637         }
638         if (request.hasProperty("getetag")) {
639             response.appendProperty("D:getetag", etag);
640         }
641         if (request.hasProperty("displayname")) {
642             response.appendProperty("D:displayname", "inbox");
643         }
644         response.endPropStatOK();
645         response.endResponse();
646     }
647 
648     /**
649      * Append calendar outbox object to Caldav response.
650      *
651      * @param response  Caldav response
652      * @param request   Caldav request
653      * @param subFolder outbox folder path relative to request path
654      * @throws IOException on error
655      */
656     public void appendOutbox(CaldavResponse response, CaldavRequest request, String subFolder) throws IOException {
657         response.startResponse(encodePath(request, request.getPath(subFolder)));
658         response.startPropstat();
659 
660         if (request.hasProperty("resourcetype")) {
661             response.appendProperty("D:resourcetype", "<D:collection/>" +
662                     "<C:schedule-outbox xmlns:C=\"urn:ietf:params:xml:ns:caldav\"/>");
663         }
664         if (request.hasProperty("getctag")) {
665             response.appendProperty("CS:getctag", "CS=\"http://calendarserver.org/ns/\"",
666                     "0");
667         }
668         if (request.hasProperty("getetag")) {
669             response.appendProperty("D:getetag", "0");
670         }
671         if (request.hasProperty("displayname")) {
672             response.appendProperty("D:displayname", "outbox");
673         }
674         response.endPropStatOK();
675         response.endResponse();
676     }
677 
678     /**
679      * Send simple HTML response to GET /.
680      *
681      * @throws IOException on error
682      */
683     public void sendGetRoot() throws IOException {
684         String buffer = "Connected to DavMail" + DavGateway.getCurrentVersion() + "<br/>" +
685                 "UserName: " + StringUtil.xmlEncode(userName) + "<br/>" +
686                 "Email: " + StringUtil.xmlEncode(session.getEmail()) + "<br/>";
687         sendHttpResponse(HttpStatus.SC_OK, null, "text/html;charset=UTF-8", buffer, true);
688     }
689 
690     /**
691      * Send inbox response for request.
692      *
693      * @param request Caldav request
694      * @throws IOException on error
695      */
696     public void sendInbox(CaldavRequest request) throws IOException {
697         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
698         response.startMultistatus();
699         appendInbox(response, request, null);
700         // do not try to access inbox on shared calendar
701         if (!session.isSharedFolder(request.getFolderPath(null)) && request.getDepth() == 1
702                 && !request.isLightning()) {
703             try {
704                 DavGatewayTray.debug(new BundleMessage("LOG_SEARCHING_CALENDAR_MESSAGES"));
705                 List<ExchangeSession.Event> events = session.getEventMessages(request.getFolderPath());
706                 DavGatewayTray.debug(new BundleMessage("LOG_FOUND_CALENDAR_MESSAGES", events.size()));
707                 appendEventsResponses(response, request, events);
708             } catch (HttpResponseException e) {
709                 // unauthorized access, probably an inbox on shared calendar
710                 DavGatewayTray.debug(new BundleMessage("LOG_ACCESS_FORBIDDEN", request.getFolderPath(), e.getMessage()));
711             }
712         }
713         response.endMultistatus();
714         response.close();
715     }
716 
717     /**
718      * Send outbox response for request.
719      *
720      * @param request Caldav request
721      * @throws IOException on error
722      */
723     public void sendOutbox(CaldavRequest request) throws IOException {
724         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
725         response.startMultistatus();
726         appendOutbox(response, request, null);
727         response.endMultistatus();
728         response.close();
729     }
730 
731     /**
732      * Send calendar response for request.
733      *
734      * @param request Caldav request
735      * @throws IOException on error
736      */
737     public void sendFolderOrItem(CaldavRequest request) throws IOException {
738         String folderPath = request.getFolderPath();
739         // process request before sending response to avoid sending headers twice on error
740         ExchangeSession.Folder folder = session.getFolder(folderPath);
741         List<ExchangeSession.Contact> contacts = null;
742         List<ExchangeSession.Event> events = null;
743         List<ExchangeSession.Folder> folderList = null;
744         if (request.getDepth() == 1) {
745             if (folder.isContact()) {
746                 contacts = session.getAllContacts(folderPath, !isOldCardavClient(request) && !request.isLightning());
747             } else if (folder.isCalendar() || folder.isTask()) {
748                 events = session.getAllEvents(folderPath);
749                 if (!folderPath.startsWith("/public")) {
750                     folderList = session.getSubCalendarFolders(folderPath, false);
751                 }
752             }
753         }
754 
755         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
756         response.startMultistatus();
757         appendFolderOrItem(response, request, folder, null);
758         if (request.getDepth() == 1) {
759             if (folder.isContact()) {
760                 appendContactsResponses(response, request, contacts);
761             } else if (folder.isCalendar() || folder.isTask()) {
762                 appendEventsResponses(response, request, events);
763                 // Send sub folders for multi-calendar support under iCal, except for public folders
764                 if (folderList != null) {
765                     for (ExchangeSession.Folder subFolder : folderList) {
766                         appendFolderOrItem(response, request, subFolder, subFolder.folderPath.substring(subFolder.folderPath.indexOf('/') + 1), true);
767                     }
768                 }
769             }
770         }
771         response.endMultistatus();
772         response.close();
773     }
774 
775     /**
776      * Fake PROPPATCH response for request.
777      *
778      * @param request Caldav request
779      * @throws IOException on error
780      */
781     public void patchCalendar(CaldavRequest request) throws IOException {
782         String displayname = request.getProperty("displayname");
783         String folderPath = request.getFolderPath();
784         if (displayname != null) {
785             String targetPath = request.getParentFolderPath() + '/' + displayname;
786             if (!targetPath.equals(folderPath)) {
787                 session.moveFolder(folderPath, targetPath);
788             }
789         }
790         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
791         response.startMultistatus();
792         // ical calendar folder proppatch
793         if (request.hasProperty("calendar-color") || request.hasProperty("calendar-order")) {
794             response.startPropstat();
795             if (request.hasProperty("calendar-color")) {
796                 response.appendProperty("x1:calendar-color", "x1=\"http://apple.com/ns/ical/\"", null);
797             }
798             if (request.hasProperty("calendar-order")) {
799                 response.appendProperty("x1:calendar-order", "x1=\"http://apple.com/ns/ical/\"", null);
800             }
801             response.endPropStatOK();
802         }
803         response.endMultistatus();
804         response.close();
805     }
806 
807     protected String getEventFileNameFromPath(String path) {
808         int index = path.lastIndexOf('/');
809         if (index < 0) {
810             return null;
811         } else {
812             return StringUtil.xmlDecode(path.substring(index + 1));
813         }
814     }
815 
816     /**
817      * Report items listed in request.
818      *
819      * @param request Caldav request
820      * @throws IOException on error
821      */
822     public void reportItems(CaldavRequest request) throws IOException {
823         String folderPath = request.getFolderPath();
824         List<ExchangeSession.Event> events;
825         List<String> notFound = new ArrayList<>();
826 
827         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
828         response.startMultistatus();
829         if (request.isMultiGet()) {
830             int count = 0;
831             int total = request.getHrefs().size();
832             for (String href : request.getHrefs()) {
833                 DavGatewayTray.debug(new BundleMessage("LOG_REPORT_ITEM", ++count, total));
834                 DavGatewayTray.switchIcon();
835                 String eventName = getEventFileNameFromPath(href);
836                 try {
837                     // ignore cases for Sunbird
838                     if (eventName != null && !eventName.isEmpty()
839                             && !"inbox".equals(eventName) && !"calendar".equals(eventName)) {
840                         ExchangeSession.Item item;
841                         try {
842                             item = session.getItem(folderPath, eventName);
843                         } catch (HttpNotFoundException e) {
844                             // workaround for Lightning bug
845                             if (request.isBrokenLightning() && eventName.indexOf('%') >= 0) {
846                                 item = session.getItem(folderPath, URIUtil.decode(StringUtil.encodePlusSign(eventName)));
847                             } else {
848                                 throw e;
849                             }
850 
851                         }
852                         if (!eventName.equals(item.getName())) {
853                             DavGatewayTray.debug(new BundleMessage("LOG_MESSAGE", "restore item name from received " + item.getName() + " eventName " + eventName));
854                             // force item name to requested value
855                             item.setItemName(eventName);
856                         }
857                         appendItemResponse(response, request, item);
858                     }
859                 } catch (SocketException | SocketTimeoutException |
860                          SSLException | UnknownHostException | NetworkDownException e) {
861                     // rethrow on client closed connection or network error
862                     throw e;
863                 } catch (Exception e) {
864                     wireLogger.debug(e.getMessage(), e);
865                     wireLogger.warn(new BundleMessage("LOG_ITEM_NOT_AVAILABLE", eventName, href).toString());
866                     notFound.add(href);
867                 }
868             }
869         } else if (request.isPath(1, "users") && request.isPath(3, "inbox")) {
870             events = session.getEventMessages(request.getFolderPath());
871             appendEventsResponses(response, request, events);
872         } else {
873             ExchangeSession.Folder folder = session.getFolder(folderPath);
874             if (folder.isContact()) {
875                 List<ExchangeSession.Contact> contacts = session.getAllContacts(folderPath, !isOldCardavClient(request));
876                 appendContactsResponses(response, request, contacts);
877             } else {
878                 if (request.vTodoOnly) {
879                     events = session.searchTasksOnly(request.getFolderPath());
880                 } else if (request.vEventOnly) {
881                     events = session.searchEventsOnly(request.getFolderPath(), request.timeRangeStart, request.timeRangeEnd);
882                 } else {
883                     events = session.searchEvents(request.getFolderPath(), request.timeRangeStart, request.timeRangeEnd);
884                 }
885                 appendEventsResponses(response, request, events);
886             }
887         }
888 
889         // send not found events errors
890         for (String href : notFound) {
891             response.startResponse(encodePath(request, href));
892             response.appendPropstatNotFound();
893             response.endResponse();
894         }
895         response.endMultistatus();
896         response.close();
897     }
898 
899     /**
900      * Send principals folder.
901      *
902      * @param request Caldav request
903      * @throws IOException on error
904      */
905     public void sendPrincipalsFolder(CaldavRequest request) throws IOException {
906         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
907         response.startMultistatus();
908         response.startResponse(encodePath(request, request.getPath()));
909         response.startPropstat();
910 
911         if (request.hasProperty("current-user-principal")) {
912             response.appendHrefProperty("D:current-user-principal", encodePath(request, "/principals/users/" + session.getEmail()));
913         }
914         if (request.hasProperty("principal-URL")) {
915             response.appendHrefProperty("D:principal-URL", encodePath(request, "/principals/users/" + session.getEmail()));
916         }
917         response.endPropStatOK();
918         response.endResponse();
919         response.endMultistatus();
920         response.close();
921     }
922 
923     /**
924      * Send user response for request.
925      *
926      * @param request Caldav request
927      * @throws IOException on error
928      */
929     public void sendUserRoot(CaldavRequest request) throws IOException {
930         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
931         response.startMultistatus();
932         response.startResponse(encodePath(request, request.getPath()));
933         response.startPropstat();
934 
935         if (request.hasProperty("resourcetype")) {
936             response.appendProperty("D:resourcetype", "<D:collection/>");
937         }
938         if (request.hasProperty("displayname")) {
939             response.appendProperty("D:displayname", request.getLastPath());
940         }
941         if (request.hasProperty("getctag")) {
942             ExchangeSession.Folder rootFolder = session.getFolder("");
943             response.appendProperty("CS:getctag", "CS=\"http://calendarserver.org/ns/\"",
944                     IOUtil.encodeBase64AsString(rootFolder.ctag));
945         }
946         response.endPropStatOK();
947         response.endResponse();
948         if (request.getDepth() == 1) {
949             appendInbox(response, request, "inbox");
950             appendOutbox(response, request, "outbox");
951             appendFolderOrItem(response, request, session.getFolder(request.getFolderPath("calendar")), "calendar");
952             appendFolderOrItem(response, request, session.getFolder(request.getFolderPath("contacts")), "contacts");
953         }
954         response.endMultistatus();
955         response.close();
956     }
957 
958     /**
959      * Send caldav response for / request.
960      *
961      * @param request Caldav request
962      * @throws IOException on error
963      */
964     public void sendRoot(CaldavRequest request) throws IOException {
965         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
966         response.startMultistatus();
967         response.startResponse("/");
968         response.startPropstat();
969 
970         if (request.hasProperty("principal-collection-set")) {
971             response.appendHrefProperty("D:principal-collection-set", "/principals/users/");
972         }
973         if (request.hasProperty("displayname")) {
974             response.appendProperty("D:displayname", "ROOT");
975         }
976         if (request.hasProperty("resourcetype")) {
977             response.appendProperty("D:resourcetype", "<D:collection/>");
978         }
979         if (request.hasProperty("current-user-principal")) {
980             response.appendHrefProperty("D:current-user-principal", encodePath(request, "/principals/users/" + session.getEmail()));
981         }
982         if (request.hasProperty("principal-URL")) {
983             response.appendHrefProperty("D:principal-URL", encodePath(request, "/principals/users/" + session.getEmail()));
984         }
985         response.endPropStatOK();
986         response.endResponse();
987         if (request.depth == 1) {
988             // iPhone workaround: send calendar subfolder
989             response.startResponse("/users/" + session.getEmail() + "/calendar");
990             response.startPropstat();
991             if (request.hasProperty("resourcetype")) {
992                 response.appendProperty("D:resourcetype", "<D:collection/>" +
993                         "<C:calendar xmlns:C=\"urn:ietf:params:xml:ns:caldav\"/>");
994             }
995             if (request.hasProperty("displayname")) {
996                 response.appendProperty("D:displayname", session.getEmail());
997             }
998             if (request.hasProperty("supported-calendar-component-set")) {
999                 response.appendProperty("C:supported-calendar-component-set", "<C:comp name=\"VEVENT\"/>");
1000             }
1001             response.endPropStatOK();
1002             response.endResponse();
1003 
1004             response.startResponse("/users");
1005             response.startPropstat();
1006             if (request.hasProperty("displayname")) {
1007                 response.appendProperty("D:displayname", "users");
1008             }
1009             if (request.hasProperty("resourcetype")) {
1010                 response.appendProperty("D:resourcetype", "<D:collection/>");
1011             }
1012             response.endPropStatOK();
1013             response.endResponse();
1014 
1015             response.startResponse("/principals");
1016             response.startPropstat();
1017             if (request.hasProperty("displayname")) {
1018                 response.appendProperty("D:displayname", "principals");
1019             }
1020             if (request.hasProperty("resourcetype")) {
1021                 response.appendProperty("D:resourcetype", "<D:collection/>");
1022             }
1023             response.endPropStatOK();
1024             response.endResponse();
1025         }
1026         response.endMultistatus();
1027         response.close();
1028     }
1029 
1030     /**
1031      * Send caldav response for /directory/ request.
1032      *
1033      * @param request Caldav request
1034      * @throws IOException on error
1035      */
1036     public void sendDirectory(CaldavRequest request) throws IOException {
1037         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
1038         response.startMultistatus();
1039         response.startResponse("/directory/");
1040         response.startPropstat();
1041         if (request.hasProperty("current-user-privilege-set")) {
1042             response.appendProperty("D:current-user-privilege-set", "<D:privilege><D:read/></D:privilege>");
1043         }
1044         response.endPropStatOK();
1045         response.endResponse();
1046         response.endMultistatus();
1047         response.close();
1048     }
1049 
1050     /**
1051      * Send caldav response for /.well-known/ request.
1052      *
1053      * @throws IOException on error
1054      */
1055     public void sendWellKnown() throws IOException {
1056         HashMap<String, String> headers = new HashMap<>();
1057         headers.put("Location", "/");
1058         sendHttpResponse(HttpStatus.SC_MOVED_PERMANENTLY, headers);
1059     }
1060 
1061     /**
1062      * Send Caldav principal response.
1063      *
1064      * @param request   Caldav request
1065      * @param prefix    principal prefix (users or public)
1066      * @param principal principal name (email address for users)
1067      * @throws IOException on error
1068      */
1069     public void sendPrincipal(CaldavRequest request, String prefix, String principal) throws IOException {
1070         // actual principal is email address
1071         String actualPrincipal = principal;
1072         if ("users".equals(prefix) &&
1073                 (principal.equalsIgnoreCase(session.getAlias()) || (principal.equalsIgnoreCase(session.getAliasFromLogin())))) {
1074             actualPrincipal = session.getEmail();
1075         }
1076 
1077         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
1078         response.startMultistatus();
1079         response.startResponse(encodePath(request, "/principals/" + prefix + '/' + principal));
1080         response.startPropstat();
1081 
1082         if (request.hasProperty("principal-URL")) {
1083             response.appendHrefProperty("D:principal-URL", encodePath(request, "/principals/" + prefix + '/' + actualPrincipal));
1084         }
1085 
1086 
1087         if (request.hasProperty("calendar-home-set")) {
1088             if ("users".equals(prefix)) {
1089                 response.appendHrefProperty("C:calendar-home-set", encodePath(request, "/users/" + actualPrincipal + "/calendar/"));
1090             } else {
1091                 response.appendHrefProperty("C:calendar-home-set", encodePath(request, '/' + prefix + '/' + actualPrincipal));
1092             }
1093         }
1094 
1095         if (request.hasProperty("calendar-user-address-set") && "users".equals(prefix)) {
1096             response.appendHrefProperty("C:calendar-user-address-set", "mailto:" + actualPrincipal);
1097         }
1098 
1099         if (request.hasProperty("addressbook-home-set")) {
1100             if (request.isUserAgent("Address%20Book") || request.isUserAgent("Darwin")) {
1101                 response.appendHrefProperty("E:addressbook-home-set", encodePath(request, '/' + prefix + '/' + actualPrincipal + '/'));
1102             } else if ("users".equals(prefix)) {
1103                 response.appendHrefProperty("E:addressbook-home-set", encodePath(request, "/users/" + actualPrincipal + "/contacts/"));
1104             } else {
1105                 response.appendHrefProperty("E:addressbook-home-set", encodePath(request, '/' + prefix + '/' + actualPrincipal + '/'));
1106             }
1107         }
1108 
1109         if ("users".equals(prefix)) {
1110             if (request.hasProperty("schedule-inbox-URL")) {
1111                 response.appendHrefProperty("C:schedule-inbox-URL", encodePath(request, "/users/" + actualPrincipal + "/inbox/"));
1112             }
1113 
1114             if (request.hasProperty("schedule-outbox-URL")) {
1115                 response.appendHrefProperty("C:schedule-outbox-URL", encodePath(request, "/users/" + actualPrincipal + "/outbox/"));
1116             }
1117         } else {
1118             // public calendar, send root href as inbox url (always empty) for Lightning
1119             if (request.isLightning() && request.hasProperty("schedule-inbox-URL")) {
1120                 response.appendHrefProperty("C:schedule-inbox-URL", "/");
1121             }
1122             // send user outbox
1123             if (request.hasProperty("schedule-outbox-URL")) {
1124                 response.appendHrefProperty("C:schedule-outbox-URL", encodePath(request, "/users/" + session.getEmail() + "/outbox/"));
1125             }
1126         }
1127 
1128         if (request.hasProperty("displayname")) {
1129             response.appendProperty("D:displayname", actualPrincipal);
1130         }
1131         if (request.hasProperty("resourcetype")) {
1132             response.appendProperty("D:resourcetype", "<D:collection/><D:principal/>");
1133         }
1134         if (request.hasProperty("supported-report-set")) {
1135             response.appendProperty("D:supported-report-set", "<D:supported-report><D:report><C:calendar-multiget/></D:report></D:supported-report>");
1136         }
1137         response.endPropStatOK();
1138         response.endResponse();
1139         response.endMultistatus();
1140         response.close();
1141     }
1142 
1143     /**
1144      * Send free busy response for body request.
1145      *
1146      * @param body request body
1147      * @throws IOException on error
1148      */
1149     public void sendFreeBusy(String body) throws IOException {
1150         HashMap<String, String> valueMap = new HashMap<>();
1151         ArrayList<String> attendees = new ArrayList<>();
1152         HashMap<String, String> attendeeKeyMap = new HashMap<>();
1153         try (ICSBufferedReader reader = new ICSBufferedReader(new StringReader(body))) {
1154             String line;
1155             String key;
1156             while ((line = reader.readLine()) != null) {
1157                 int index = line.indexOf(':');
1158                 if (index <= 0) {
1159                     throw new DavMailException("EXCEPTION_INVALID_REQUEST", body);
1160                 }
1161                 String fullkey = line.substring(0, index);
1162                 String value = line.substring(index + 1);
1163                 int semicolonIndex = fullkey.indexOf(';');
1164                 if (semicolonIndex > 0) {
1165                     key = fullkey.substring(0, semicolonIndex);
1166                 } else {
1167                     key = fullkey;
1168                 }
1169                 if ("ATTENDEE".equals(key)) {
1170                     attendees.add(value);
1171                     attendeeKeyMap.put(value, fullkey);
1172                 } else {
1173                     valueMap.put(key, value);
1174                 }
1175             }
1176         }
1177         // get freebusy for each attendee
1178         HashMap<String, ExchangeSession.FreeBusy> freeBusyMap = new HashMap<>();
1179         for (String attendee : attendees) {
1180             ExchangeSession.FreeBusy freeBusy = session.getFreebusy(attendee, valueMap.get("DTSTART"), valueMap.get("DTEND"));
1181             if (freeBusy != null) {
1182                 freeBusyMap.put(attendee, freeBusy);
1183             }
1184         }
1185         CaldavResponse response = new CaldavResponse(HttpStatus.SC_OK);
1186         response.startScheduleResponse();
1187 
1188         for (Map.Entry<String, ExchangeSession.FreeBusy> entry : freeBusyMap.entrySet()) {
1189             String attendee = entry.getKey();
1190             response.startRecipientResponse(attendee);
1191 
1192             StringBuilder ics = new StringBuilder();
1193             ics.append("BEGIN:VCALENDAR\r\n")
1194                     .append("VERSION:2.0\r\n")
1195                     .append("PRODID:-//davmail.sf.net/NONSGML DavMail Calendar V1.1//EN\r\n")
1196                     .append("METHOD:REPLY\r\n")
1197                     .append("BEGIN:VFREEBUSY\r\n")
1198                     .append("DTSTAMP:").append(valueMap.get("DTSTAMP")).append("\r\n")
1199                     .append("ORGANIZER:").append(valueMap.get("ORGANIZER")).append("\r\n")
1200                     .append("DTSTART:").append(valueMap.get("DTSTART")).append("\r\n")
1201                     .append("DTEND:").append(valueMap.get("DTEND")).append("\r\n")
1202                     .append("UID:").append(valueMap.get("UID")).append("\r\n")
1203                     .append(attendeeKeyMap.get(attendee)).append(':').append(attendee).append("\r\n");
1204             entry.getValue().appendTo(ics);
1205             ics.append("END:VFREEBUSY\r\n")
1206                     .append("END:VCALENDAR");
1207             response.appendCalendarData(ics.toString());
1208             response.endRecipientResponse();
1209 
1210         }
1211         response.endScheduleResponse();
1212         response.close();
1213 
1214     }
1215 
1216 
1217     /**
1218      * Send Http error response for exception
1219      *
1220      * @param e exception
1221      * @throws IOException on error
1222      */
1223     public void sendErr(Exception e) throws IOException {
1224         String message = e.getMessage();
1225         if (message == null) {
1226             message = e.toString();
1227         }
1228         if (e instanceof HttpResponseException) {
1229             sendErr(((HttpResponseException)e).getStatusCode(), ((HttpResponseException)e).getReasonPhrase());
1230         } else {
1231             // workaround for Lightning bug: sleep for 1 second
1232             try {
1233                 Thread.sleep(1000);
1234             } catch (InterruptedException ie) {
1235                 Thread.currentThread().interrupt();
1236             }
1237             sendErr(HttpStatus.SC_SERVICE_UNAVAILABLE, message);
1238         }
1239     }
1240 
1241     /**
1242      * Send 404 not found for unknown request.
1243      *
1244      * @param request Caldav request
1245      * @throws IOException on error
1246      */
1247     public void sendNotFound(CaldavRequest request) throws IOException {
1248         BundleMessage message = new BundleMessage("LOG_UNSUPPORTED_REQUEST", request);
1249         DavGatewayTray.debug(message);
1250         sendErr(HttpStatus.SC_NOT_FOUND, message.format());
1251     }
1252 
1253     /**
1254      * Send Http error status and message.
1255      *
1256      * @param status  Http status
1257      * @param message error messagee
1258      * @throws IOException on error
1259      */
1260     public void sendErr(int status, String message) throws IOException {
1261         sendHttpResponse(status, null, "text/plain;charset=UTF-8", message, false);
1262     }
1263 
1264     /**
1265      * Send OPTIONS response.
1266      *
1267      * @throws IOException on error
1268      */
1269     public void sendOptions() throws IOException {
1270         HashMap<String, String> headers = new HashMap<>();
1271         headers.put("Allow", "OPTIONS, PROPFIND, HEAD, GET, REPORT, PROPPATCH, PUT, DELETE, POST");
1272         sendHttpResponse(HttpStatus.SC_OK, headers);
1273     }
1274 
1275     /**
1276      * Send 401 Unauthorized response.
1277      *
1278      * @throws IOException on error
1279      */
1280     public void sendUnauthorized() throws IOException {
1281         HashMap<String, String> headers = new HashMap<>();
1282         headers.put("WWW-Authenticate", "Basic realm=\"" + BundleMessage.format("UI_DAVMAIL_GATEWAY") + '\"');
1283         sendHttpResponse(HttpStatus.SC_UNAUTHORIZED, headers, null, (byte[]) null, true);
1284     }
1285 
1286     /**
1287      * Send Http response with given status.
1288      *
1289      * @param status Http status
1290      * @throws IOException on error
1291      */
1292     public void sendHttpResponse(int status) throws IOException {
1293         sendHttpResponse(status, null, null, (byte[]) null, true);
1294     }
1295 
1296     /**
1297      * Send Http response with given status and headers.
1298      *
1299      * @param status  Http status
1300      * @param headers Http headers
1301      * @throws IOException on error
1302      */
1303     public void sendHttpResponse(int status, Map<String, String> headers) throws IOException {
1304         sendHttpResponse(status, headers, null, (byte[]) null, true);
1305     }
1306 
1307     /**
1308      * Send Http response with given status in chunked mode.
1309      *
1310      * @param status      Http status
1311      * @param contentType MIME content type
1312      * @throws IOException on error
1313      */
1314     public void sendChunkedHttpResponse(int status, String contentType) throws IOException {
1315         HashMap<String, String> headers = new HashMap<>();
1316         headers.put("Transfer-Encoding", "chunked");
1317         sendHttpResponse(status, headers, contentType, (byte[]) null, true);
1318     }
1319 
1320     /**
1321      * Send Http response with given status, headers, content type and content.
1322      * Close connection if keepAlive is false
1323      *
1324      * @param status      Http status
1325      * @param headers     Http headers
1326      * @param contentType MIME content type
1327      * @param content     response body as string
1328      * @param keepAlive   keep connection open
1329      * @throws IOException on error
1330      */
1331     public void sendHttpResponse(int status, Map<String, String> headers, String contentType, String content, boolean keepAlive) throws IOException {
1332         sendHttpResponse(status, headers, contentType, content.getBytes(StandardCharsets.UTF_8), keepAlive);
1333     }
1334 
1335     /**
1336      * Send Http response with given status, headers, content type and content.
1337      * Close connection if keepAlive is false
1338      *
1339      * @param status      Http status
1340      * @param headers     Http headers
1341      * @param contentType MIME content type
1342      * @param content     response body as byte array
1343      * @param keepAlive   keep connection open
1344      * @throws IOException on error
1345      */
1346     public void sendHttpResponse(int status, Map<String, String> headers, String contentType, byte[] content, boolean keepAlive) throws IOException {
1347         sendClient("HTTP/1.1 " + status + ' ' + EnglishReasonPhraseCatalog.INSTANCE.getReason(status, Locale.ENGLISH));
1348         if (status != HttpStatus.SC_UNAUTHORIZED) {
1349             sendClient("Server: DavMail Gateway " + DavGateway.getCurrentVersion());
1350             String scheduleMode;
1351             // enable automatic scheduling over EWS, can be disabled
1352             if (!(session instanceof DavExchangeSession)) {
1353                 scheduleMode = "calendar-auto-schedule";
1354             } else {
1355                 scheduleMode = "calendar-schedule";
1356             }
1357             sendClient("DAV: 1, calendar-access, " + scheduleMode + ", calendarserver-private-events, addressbook");
1358             SimpleDateFormat formatter = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH);
1359             // force GMT timezone
1360             formatter.setTimeZone(ExchangeSession.GMT_TIMEZONE);
1361             String now = formatter.format(new Date());
1362             sendClient("Date: " + now);
1363             sendClient("Expires: " + now);
1364             sendClient("Cache-Control: private, max-age=0");
1365         }
1366         if (headers != null) {
1367             for (Map.Entry<String, String> header : headers.entrySet()) {
1368                 sendClient(header.getKey() + ": " + header.getValue());
1369             }
1370         }
1371         if (contentType != null) {
1372             sendClient("Content-Type: " + contentType);
1373         }
1374         closed = closed || !keepAlive;
1375         sendClient("Connection: " + (closed ? "close" : "keep-alive"));
1376         if (content != null && content.length > 0) {
1377             sendClient("Content-Length: " + content.length);
1378         } else if (headers == null || !"chunked".equals(headers.get("Transfer-Encoding"))) {
1379             sendClient("Content-Length: 0");
1380         }
1381         sendClient("");
1382         if (content != null && content.length > 0) {
1383             // full debug trace
1384             if (wireLogger.isDebugEnabled()) {
1385                 wireLogger.debug("> " + new String(content, StandardCharsets.UTF_8));
1386             }
1387             sendClient(content);
1388         }
1389     }
1390 
1391     /**
1392      * Decode HTTP credentials
1393      *
1394      * @param authorization http authorization header value
1395      * @throws IOException if invalid credentials
1396      */
1397     protected void decodeCredentials(String authorization) throws IOException {
1398         int index = authorization.indexOf(' ');
1399         if (index > 0) {
1400             String mode = authorization.substring(0, index).toLowerCase();
1401             if (!"basic".equals(mode)) {
1402                 throw new DavMailAuthenticationException("EXCEPTION_UNSUPPORTED_AUTHORIZATION_MODE", mode);
1403             }
1404             String encodedCredentials = authorization.substring(index + 1);
1405             String decodedCredentials = IOUtil.decodeBase64AsString(encodedCredentials);
1406             index = decodedCredentials.indexOf(':');
1407             if (index > 0) {
1408                 userName = decodedCredentials.substring(0, index);
1409                 password = decodedCredentials.substring(index + 1);
1410             } else {
1411                 throw new DavMailAuthenticationException("EXCEPTION_INVALID_CREDENTIALS");
1412             }
1413         } else {
1414             throw new DavMailAuthenticationException("EXCEPTION_INVALID_CREDENTIALS");
1415         }
1416 
1417     }
1418 
1419     public static class CaldavRequest {
1420         protected final String command;
1421         protected final String path;
1422         protected final String[] pathElements;
1423         protected final Map<String, String> headers;
1424         protected int depth;
1425         protected final String body;
1426         protected final HashMap<String, String> properties = new HashMap<>();
1427         protected HashSet<String> hrefs;
1428         protected boolean isMultiGet;
1429         protected String timeRangeStart;
1430         protected String timeRangeEnd;
1431         protected boolean vTodoOnly;
1432         protected boolean vEventOnly;
1433 
1434         protected CaldavRequest(String command, String path, Map<String, String> headers, String body) throws IOException {
1435             this.command = command;
1436             this.path = path.replace("//", "/");
1437             pathElements = this.path.split("/");
1438             this.headers = headers;
1439             buildDepth();
1440             this.body = body;
1441 
1442             if (isPropFind() || isReport() || isMkCalendar() || isPropPatch()) {
1443                 parseXmlBody();
1444             }
1445         }
1446 
1447         public boolean isOptions() {
1448             return "OPTIONS".equals(command);
1449         }
1450 
1451         public boolean isPropFind() {
1452             return "PROPFIND".equals(command);
1453         }
1454 
1455         public boolean isPropPatch() {
1456             return "PROPPATCH".equals(command);
1457         }
1458 
1459         public boolean isReport() {
1460             return "REPORT".equals(command);
1461         }
1462 
1463         public boolean isGet() {
1464             return "GET".equals(command);
1465         }
1466 
1467         public boolean isHead() {
1468             return "HEAD".equals(command);
1469         }
1470 
1471         public boolean isPut() {
1472             return "PUT".equals(command);
1473         }
1474 
1475         public boolean isPost() {
1476             return "POST".equals(command);
1477         }
1478 
1479         public boolean isDelete() {
1480             return "DELETE".equals(command);
1481         }
1482 
1483         public boolean isMkCalendar() {
1484             return "MKCALENDAR".equals(command);
1485         }
1486 
1487         public boolean isMove() {
1488             return "MOVE".equals(command);
1489         }
1490 
1491         /**
1492          * Check if this request is a folder request.
1493          *
1494          * @return true if this is a folder (not event) request
1495          */
1496         public boolean isFolder() {
1497             return path.endsWith("/") || isPropFind() || isReport() || isPropPatch() || isOptions() || isPost();
1498         }
1499 
1500         public boolean isRoot() {
1501             return (pathElements.length == 0 || pathElements.length == 1);
1502         }
1503 
1504         public boolean isPathLength(int length) {
1505             return pathElements.length == length;
1506         }
1507 
1508         public int getPathLength() {
1509             return pathElements.length;
1510         }
1511 
1512         public String getPath() {
1513             return path;
1514         }
1515 
1516         public String getPath(String subFolder) {
1517             String folderPath;
1518             if (subFolder == null || subFolder.isEmpty()) {
1519                 folderPath = path;
1520             } else if (path.endsWith("/")) {
1521                 folderPath = path + subFolder;
1522             } else {
1523                 folderPath = path + '/' + subFolder;
1524             }
1525             if (folderPath.endsWith("/")) {
1526                 return folderPath;
1527             } else {
1528                 return folderPath + '/';
1529             }
1530         }
1531 
1532         /**
1533          * Check if path element at index is value
1534          *
1535          * @param index path element index
1536          * @param value path value
1537          * @return true if path element at index is value
1538          */
1539         public boolean isPath(int index, String value) {
1540             return value != null && value.equals(getPathElement(index));
1541         }
1542 
1543         protected String getPathElement(int index) {
1544             if (index < pathElements.length) {
1545                 return pathElements[index];
1546             } else {
1547                 return null;
1548             }
1549         }
1550 
1551         public String getLastPath() {
1552             return getPathElement(getPathLength() - 1);
1553         }
1554 
1555         protected boolean isBrokenHrefEncoding() {
1556             return isUserAgent("DAVKit/3") || isUserAgent("eM Client/3") || isBrokenLightning();
1557         }
1558 
1559         protected boolean isBrokenLightning() {
1560             return isUserAgent("Lightning/1.0b2");
1561         }
1562 
1563         protected boolean isLightning() {
1564             return isUserAgent("Lightning/") || isUserAgent("Thunderbird/");
1565         }
1566 
1567         protected boolean isIcal5() {
1568             return isUserAgent("CoreDAV/") || isUserAgent("iOS/")
1569                     // iCal 6
1570                     || isUserAgent("Mac OS X/10.8");
1571         }
1572 
1573         protected boolean isUserAgent(String key) {
1574             String userAgent = headers.get("user-agent");
1575             return userAgent != null && userAgent.contains(key);
1576         }
1577 
1578         public boolean isFreeBusy() {
1579             return body != null && body.contains("VFREEBUSY");
1580         }
1581 
1582         protected void buildDepth() {
1583             String depthValue = headers.get("depth");
1584             if ("infinity".equalsIgnoreCase(depthValue)) {
1585                 depth = Integer.MAX_VALUE;
1586             } else if (depthValue != null) {
1587                 try {
1588                     depth = Integer.parseInt(depthValue);
1589                 } catch (NumberFormatException e) {
1590                     DavGatewayTray.warn(new BundleMessage("LOG_INVALID_DEPTH", depthValue));
1591                 }
1592             }
1593         }
1594 
1595         public int getDepth() {
1596             return depth;
1597         }
1598 
1599         public String getBody() {
1600             return body;
1601         }
1602 
1603         public String getHeader(String headerName) {
1604             return headers.get(headerName);
1605         }
1606 
1607         protected void parseXmlBody() throws IOException {
1608             if (body == null) {
1609                 throw new DavMailException("EXCEPTION_INVALID_CALDAV_REQUEST", "Missing body");
1610             }
1611             XMLStreamReader streamReader = null;
1612             try {
1613                 streamReader = XMLStreamUtil.createXMLStreamReader(body);
1614                 while (streamReader.hasNext()) {
1615                     streamReader.next();
1616                     if (XMLStreamUtil.isStartTag(streamReader)) {
1617                         String tagLocalName = streamReader.getLocalName();
1618                         if ("prop".equals(tagLocalName)) {
1619                             handleProp(streamReader);
1620                         } else if ("calendar-multiget".equals(tagLocalName)
1621                                 || "addressbook-multiget".equals(tagLocalName)) {
1622                             isMultiGet = true;
1623                         } else if ("comp-filter".equals(tagLocalName)) {
1624                             handleCompFilter(streamReader);
1625                         } else if ("href".equals(tagLocalName)) {
1626                             if (hrefs == null) {
1627                                 hrefs = new HashSet<>();
1628                             }
1629                             if (isBrokenHrefEncoding()) {
1630                                 hrefs.add(streamReader.getElementText());
1631                             } else {
1632                                 hrefs.add(URIUtil.decode(StringUtil.encodePlusSign(streamReader.getElementText())));
1633                             }
1634                         }
1635                     }
1636                 }
1637             } catch (XMLStreamException e) {
1638                 throw new DavMailException("EXCEPTION_INVALID_CALDAV_REQUEST", e.getMessage());
1639             } finally {
1640                 try {
1641                     if (streamReader != null) {
1642                         streamReader.close();
1643                     }
1644                 } catch (XMLStreamException e) {
1645                     DavGatewayTray.error(e);
1646                 }
1647             }
1648         }
1649 
1650         public void handleCompFilter(XMLStreamReader reader) throws XMLStreamException {
1651             while (reader.hasNext() && !XMLStreamUtil.isEndTag(reader, "comp-filter")) {
1652                 reader.next();
1653                 if (XMLStreamUtil.isStartTag(reader, "comp-filter")) {
1654                     String name = reader.getAttributeValue(null, "name");
1655                     if ("VEVENT".equals(name)) {
1656                         vEventOnly = true;
1657                     } else if ("VTODO".equals(name)) {
1658                         vTodoOnly = true;
1659                     }
1660                 } else if (XMLStreamUtil.isStartTag(reader, "time-range")) {
1661                     timeRangeStart = reader.getAttributeValue(null, "start");
1662                     timeRangeEnd = reader.getAttributeValue(null, "end");
1663                 }
1664             }
1665         }
1666 
1667         public void handleProp(XMLStreamReader reader) throws XMLStreamException {
1668             while (reader.hasNext() && !XMLStreamUtil.isEndTag(reader, "prop")) {
1669                 reader.next();
1670                 if (XMLStreamUtil.isStartTag(reader)) {
1671                     String tagLocalName = reader.getLocalName();
1672                     String tagText = null;
1673                     if ("displayname".equals(tagLocalName) || reader.hasText()) {
1674                         tagText = XMLStreamUtil.getElementText(reader);
1675                     }
1676                     properties.put(tagLocalName, tagText);
1677                 }
1678             }
1679         }
1680 
1681         public boolean hasProperty(String propertyName) {
1682             return properties.containsKey(propertyName);
1683         }
1684 
1685         public String getProperty(String propertyName) {
1686             return properties.get(propertyName);
1687         }
1688 
1689         public boolean isMultiGet() {
1690             return isMultiGet && hrefs != null;
1691         }
1692 
1693         public Set<String> getHrefs() {
1694             return hrefs;
1695         }
1696 
1697         @Override
1698         public String toString() {
1699             return command + ' ' + path + " Depth: " + depth + '\n' + body;
1700         }
1701 
1702         /**
1703          * Get request folder path.
1704          *
1705          * @return exchange folder path
1706          */
1707         public String getFolderPath() {
1708             return getFolderPath(null);
1709         }
1710 
1711         public String getParentFolderPath() {
1712             int endIndex;
1713             if (isFolder()) {
1714                 endIndex = getPathLength() - 1;
1715             } else {
1716                 endIndex = getPathLength() - 2;
1717             }
1718             return getFolderPath(endIndex, null);
1719         }
1720 
1721         /**
1722          * Get request folder path with subFolder.
1723          *
1724          * @param subFolder sub folder path
1725          * @return folder path
1726          */
1727         public String getFolderPath(String subFolder) {
1728             int endIndex;
1729             if (isFolder()) {
1730                 endIndex = getPathLength();
1731             } else {
1732                 endIndex = getPathLength() - 1;
1733             }
1734             return getFolderPath(endIndex, subFolder);
1735         }
1736 
1737         protected String getFolderPath(int endIndex, String subFolder) {
1738 
1739             StringBuilder calendarPath = new StringBuilder();
1740             for (int i = 0; i < endIndex; i++) {
1741                 if (!getPathElement(i).isEmpty()) {
1742                     calendarPath.append('/').append(getPathElement(i));
1743                 }
1744             }
1745             if (subFolder != null && !subFolder.isEmpty()) {
1746                 calendarPath.append('/').append(subFolder);
1747             }
1748             if (this.isUserAgent("Address%20Book") || this.isUserAgent("Darwin")) {
1749                 /* WARNING - This is a kludge -
1750                  * If your public folder address book path has spaces, then Address Book app just ignores that account
1751                  * This kludge allows you to specify the path in which spaces are encoded as ___
1752                  * It'll make Address book to not ignore the account and communicate with DavMail.
1753                  * Here we replace the ___ in the path with spaces. Be warned if your actual address book path has ___
1754                  * it'll fail.
1755                  */
1756                 String result = calendarPath.toString();
1757                 // replace ___ encoding with spaces
1758                 if (result.contains("___")) {
1759                     result = result.replace("___", " ");
1760                 }
1761                 // replace /addressbook suffix on public folders
1762                 if (result.startsWith("/public")) {
1763                     result = result.replace("/addressbook", "");
1764                 }
1765 
1766                 return result;
1767             } else {
1768                 return calendarPath.toString();
1769             }
1770         }
1771     }
1772 
1773     /**
1774      * Http chunked response.
1775      */
1776     protected class ChunkedResponse {
1777         Writer writer;
1778 
1779         protected ChunkedResponse(int status, String contentType) throws IOException {
1780             writer = new OutputStreamWriter(new BufferedOutputStream(new OutputStream() {
1781                 @Override
1782                 public void write(byte[] data, int offset, int length) throws IOException {
1783                     sendClient(Integer.toHexString(length));
1784                     sendClient(data, offset, length);
1785                     if (wireLogger.isDebugEnabled()) {
1786                         wireLogger.debug("> " + new String(data, offset, length, StandardCharsets.UTF_8));
1787                     }
1788                     sendClient("");
1789                 }
1790 
1791                 @Override
1792                 public void write(int b) {
1793                     throw new UnsupportedOperationException();
1794                 }
1795 
1796                 @Override
1797                 public void close() throws IOException {
1798                     sendClient("0");
1799                     sendClient("");
1800                 }
1801             }), StandardCharsets.UTF_8);
1802             sendChunkedHttpResponse(status, contentType);
1803         }
1804 
1805         public void append(String data) throws IOException {
1806             writer.write(data);
1807         }
1808 
1809         public void close() throws IOException {
1810             writer.close();
1811         }
1812     }
1813 
1814     /**
1815      * Caldav response wrapper, content sent in chunked mode to avoid timeout
1816      */
1817     public class CaldavResponse extends ChunkedResponse {
1818 
1819         protected CaldavResponse(int status) throws IOException {
1820             super(status, "text/xml;charset=UTF-8");
1821             writer.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
1822         }
1823 
1824 
1825         public void startMultistatus() throws IOException {
1826             writer.write("<D:multistatus xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:caldav\" xmlns:E=\"urn:ietf:params:xml:ns:carddav\">");
1827         }
1828 
1829         public void startResponse(String href) throws IOException {
1830             writer.write("<D:response>");
1831             writer.write("<D:href>");
1832             writer.write(StringUtil.xmlEncode(href));
1833             writer.write("</D:href>");
1834         }
1835 
1836         public void startPropstat() throws IOException {
1837             writer.write("<D:propstat>");
1838             writer.write("<D:prop>");
1839         }
1840 
1841         public void appendCalendarData(String ics) throws IOException {
1842             if (ics != null && !ics.isEmpty()) {
1843                 writer.write("<C:calendar-data xmlns:C=\"urn:ietf:params:xml:ns:caldav\"");
1844                 writer.write(" C:content-type=\"text/calendar\" C:version=\"2.0\">");
1845                 writer.write(StringUtil.xmlEncode(ics));
1846                 writer.write("</C:calendar-data>");
1847             }
1848         }
1849 
1850         public void appendContactData(String vcard) throws IOException {
1851             if (vcard != null && !vcard.isEmpty()) {
1852                 writer.write("<E:address-data>");
1853                 writer.write(StringUtil.xmlEncode(vcard));
1854                 writer.write("</E:address-data>");
1855             }
1856         }
1857 
1858         public void appendHrefProperty(String propertyName, String propertyValue) throws IOException {
1859             appendProperty(propertyName, null, "<D:href>" + StringUtil.xmlEncode(propertyValue) + "</D:href>");
1860         }
1861 
1862         public void appendProperty(String propertyName) throws IOException {
1863             appendProperty(propertyName, null);
1864         }
1865 
1866         public void appendProperty(String propertyName, String propertyValue) throws IOException {
1867             appendProperty(propertyName, null, propertyValue);
1868         }
1869 
1870         public void appendProperty(String propertyName, String namespace, String propertyValue) throws IOException {
1871             if (propertyValue != null) {
1872                 startTag(propertyName, namespace);
1873                 writer.write('>');
1874                 writer.write(propertyValue);
1875                 writer.write("</");
1876                 writer.write(propertyName);
1877                 writer.write('>');
1878             } else {
1879                 startTag(propertyName, namespace);
1880                 writer.write("/>");
1881             }
1882         }
1883 
1884         private void startTag(String propertyName, String namespace) throws IOException {
1885             writer.write('<');
1886             writer.write(propertyName);
1887             if (namespace != null) {
1888                 writer.write(" xmlns:");
1889                 writer.write(namespace);
1890             }
1891         }
1892 
1893         public void endPropStatOK() throws IOException {
1894             writer.write("</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>");
1895         }
1896 
1897         public void appendPropstatNotFound() throws IOException {
1898             writer.write("<D:propstat><D:status>HTTP/1.1 404 Not Found</D:status></D:propstat>");
1899         }
1900 
1901         public void endResponse() throws IOException {
1902             writer.write("</D:response>");
1903         }
1904 
1905         public void endMultistatus() throws IOException {
1906             writer.write("</D:multistatus>");
1907         }
1908 
1909         public void startScheduleResponse() throws IOException {
1910             writer.write("<C:schedule-response xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:caldav\">");
1911         }
1912 
1913         public void startRecipientResponse(String recipient) throws IOException {
1914             writer.write("<C:response><C:recipient><D:href>");
1915             writer.write(recipient);
1916             writer.write("</D:href></C:recipient><C:request-status>2.0;Success</C:request-status>");
1917         }
1918 
1919         public void endRecipientResponse() throws IOException {
1920             writer.write("</C:response>");
1921         }
1922 
1923         public void endScheduleResponse() throws IOException {
1924             writer.write("</C:schedule-response>");
1925         }
1926 
1927     }
1928 }
1929