Bug 1546606 - Refactor CalDAV request handling. r=pmorris
authorPhilipp Kewisch <mozilla@kewis.ch>
Thu, 09 Apr 2020 13:51:09 +0300
changeset 38756 eefb833d48f28a41c97cd3ce9e84423201523a01
parent 38755 84249ce5084a8b7a0925b5d35c00331870a56a0c
child 38757 eb5fd4e3c650cabec787456b3f897cea0952851b
push id401
push userclokep@gmail.com
push dateMon, 01 Jun 2020 20:41:59 +0000
reviewerspmorris
bugs1546606
Bug 1546606 - Refactor CalDAV request handling. r=pmorris MozReview-Commit-ID: 81oND0JtEFK
calendar/providers/caldav/CalDavCalendar.jsm
calendar/providers/caldav/calDavRequestHandlers.js
calendar/providers/caldav/modules/calDavRequest.jsm
calendar/providers/caldav/modules/calDavSession.jsm
calendar/providers/caldav/modules/calDavUtils.jsm
calendar/providers/caldav/moz.build
calendar/test/unit/head_consts.js
calendar/test/unit/test_caldav_requests.js
calendar/test/unit/xpcshell-shared.ini
--- a/calendar/providers/caldav/CalDavCalendar.jsm
+++ b/calendar/providers/caldav/CalDavCalendar.jsm
@@ -11,55 +11,36 @@ var { Services } = ChromeUtils.import("r
 var { setTimeout } = ChromeUtils.import("resource://gre/modules/Timer.jsm");
 
 var { OAuth2 } = ChromeUtils.import("resource:///modules/OAuth2.jsm");
 
 var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
 
 Services.scriptloader.loadSubScript("resource:///components/calDavRequestHandlers.js");
 
-var xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>\n';
+ChromeUtils.import("resource:///modules/caldav/calDavRequest.jsm");
+ChromeUtils.import("resource:///modules/caldav/calDavSession.jsm");
+ChromeUtils.import("resource:///modules/caldav/calDavUtils.jsm");
 
-var davNS = "DAV:";
-var caldavNS = "urn:ietf:params:xml:ns:caldav";
-var calservNS = "http://calendarserver.org/ns/";
-var MIME_TEXT_CALENDAR = "text/calendar; charset=utf-8";
+var XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n';
 var MIME_TEXT_XML = "text/xml; charset=utf-8";
 
 var cIOL = Ci.calIOperationListener;
 
-function caldavNSResolver(prefix) {
-  /* eslint-disable id-length */
-  const namespaces = {
-    D: davNS,
-    C: caldavNS,
-    CS: calservNS,
-  };
-  /* eslint-enable id-length */
-
-  return namespaces[prefix] || null;
-}
-
-function caldavXPath(aNode, aExpr, aType) {
-  return cal.xml.evalXPath(aNode, aExpr, caldavNSResolver, aType);
-}
-function caldavXPathFirst(aNode, aExpr, aType) {
-  return cal.xml.evalXPathFirst(aNode, aExpr, caldavNSResolver, aType);
-}
-
 function CalDavCalendar() {
   this.initProviderBase();
   this.unmappedProperties = [];
   this.mUriParams = null;
   this.mItemInfoCache = {};
   this.mDisabled = false;
   this.mCalHomeSet = null;
   this.mInboxUrl = null;
   this.mOutboxUrl = null;
   this.mCalendarUserAddress = null;
+  this.mCheckedServerInfo = null;
   this.mPrincipalUrl = null;
   this.mSenderAddress = null;
   this.mHrefIndex = {};
   this.mAuthScheme = null;
   this.mAuthRealm = null;
   this.mObserver = null;
   this.mFirstRefreshDone = false;
   this.mOfflineStorage = null;
@@ -68,31 +49,25 @@ function CalDavCalendar() {
   this.mProposedCtag = null;
 
   // By default, support both events and todos.
   this.mGenerallySupportedItemTypes = ["VEVENT", "VTODO"];
   this.mSupportedItemTypes = this.mGenerallySupportedItemTypes.slice(0);
   this.mACLProperties = {};
 }
 
-// used in checking calendar URI for (Cal)DAV-ness
-var kDavResourceTypeNone = 0;
-var kDavResourceTypeCollection = 1;
-var kDavResourceTypeCalendar = 2;
-
 // used for etag checking
 var CALDAV_MODIFY_ITEM = "modify";
 var CALDAV_DELETE_ITEM = "delete";
 
 var calDavCalendarClassID = Components.ID("{a35fc6ea-3d92-11d9-89f9-00045ace3b8d}");
 var calDavCalendarInterfaces = [
   Ci.calICalendarProvider,
   Ci.nsIInterfaceRequestor,
   Ci.calIFreeBusyProvider,
-  Ci.nsIChannelEventSink,
   Ci.calIItipTransport,
   Ci.calISchedulingSupport,
   Ci.calICalendar,
   Ci.calIChangeLog,
   Ci.calICalDavCalendar,
 ];
 CalDavCalendar.prototype = {
   __proto__: cal.provider.BaseClass.prototype,
@@ -132,16 +107,29 @@ CalDavCalendar.prototype = {
 
       this.mOfflineStorage.superCalendar = this;
       this.mObserver = new calDavObserver(this);
       this.mOfflineStorage.addObserver(this.mObserver);
       this.mOfflineStorage.setProperty("relaxedMode", true);
     }
   },
 
+  get id() {
+    return this.mID;
+  },
+  set id(val) {
+    let setter = this.__proto__.__proto__.__lookupSetter__("id");
+    val = setter.call(this, val);
+
+    if (this.id) {
+      this.session = new CalDavSession(this.id, this.name);
+    }
+    return val;
+  },
+
   //
   // calICalendarProvider interface
   //
   get prefChromeOverlay() {
     return null;
   },
 
   get displayName() {
@@ -234,17 +222,17 @@ CalDavCalendar.prototype = {
 
   // in calIGenericOperationListener aListener
   replayChangesOn(aChangeLogListener) {
     if (this.checkedServerInfo) {
       this.safeRefresh(aChangeLogListener);
     } else {
       // If we haven't refreshed yet, then we should check the resource
       // type first. This will call refresh() again afterwards.
-      this.setupAuthentication(aChangeLogListener);
+      this.checkDavResourceType(aChangeLogListener);
     }
   },
   setMetaData(id, path, etag, isInboxItem) {
     if (this.mOfflineStorage.setMetaData) {
       if (id) {
         let dataString = [etag, path, isInboxItem ? "true" : "false"].join("\u001A");
         this.mOfflineStorage.setMetaData(id, dataString);
       } else {
@@ -336,89 +324,16 @@ CalDavCalendar.prototype = {
           this.mItemInfoCache[itemId] = item;
         }
       }
     }
 
     this.ensureMetaData();
   },
 
-  sendHttpRequest(
-    aUri,
-    aUploadData,
-    aContentType,
-    aExisting,
-    aSetupChannelFunc,
-    aFailureFunc,
-    aUseStreamLoader = true
-  ) {
-    function oauthCheck(
-      nextMethod,
-      loaderOrRequest /* either the nsIStreamLoader or nsIRequestObserver parameters */
-    ) {
-      let request = (loaderOrRequest.request || loaderOrRequest).QueryInterface(Ci.nsIHttpChannel);
-      let error = false;
-      try {
-        let wwwauth = request.getResponseHeader("WWW-Authenticate");
-        if (wwwauth.startsWith("Bearer") && wwwauth.includes("error=")) {
-          // An OAuth error occurred, we need to reauthenticate.
-          error = true;
-        }
-      } catch (e) {
-        // This happens in case the response header is missing, that's fine.
-      }
-
-      if (self.oauth && error) {
-        self.oauth.accessToken = null;
-        self.sendHttpRequest(...origArgs);
-      } else {
-        let nextArguments = Array.from(arguments).slice(1);
-        nextMethod(...nextArguments);
-      }
-    }
-
-    function authSuccess() {
-      let channel = cal.provider.prepHttpChannel(aUri, aUploadData, aContentType, self, aExisting);
-      if (usesGoogleOAuth) {
-        let hdr = "Bearer " + self.oauth.accessToken;
-        channel.setRequestHeader("Authorization", hdr, false);
-      }
-      let listener = aSetupChannelFunc(channel);
-      if (aUseStreamLoader) {
-        let loader = cal.provider.createStreamLoader();
-        listener.onStreamComplete = oauthCheck.bind(null, listener.onStreamComplete.bind(listener));
-        loader.init(listener);
-        listener = loader;
-      } else {
-        listener.onStartRequest = oauthCheck.bind(null, listener.onStartRequest.bind(listener));
-      }
-
-      self.mLastRedirectStatus = null;
-      channel.asyncOpen(listener);
-    }
-
-    const OAUTH_GRACE_TIME = 30 * 1000;
-
-    let usesGoogleOAuth = aUri && aUri.host == "apidata.googleusercontent.com" && this.oauth;
-    let origArgs = arguments;
-    let self = this;
-
-    if (
-      usesGoogleOAuth &&
-      (!this.oauth.accessToken || this.oauth.tokenExpires - OAUTH_GRACE_TIME < new Date().getTime())
-    ) {
-      // The token has expired, we need to reauthenticate first
-      cal.LOG("CalDAV: OAuth token expired or empty, refreshing");
-      this.oauthConnect(authSuccess, aFailureFunc, true);
-    } else {
-      // Either not Google OAuth, or the token is still valid.
-      authSuccess();
-    }
-  },
-
   //
   // calICalendar interface
   //
 
   // readonly attribute AUTF8String type;
   get type() {
     return "caldav";
   },
@@ -669,101 +584,74 @@ CalDavCalendar.prototype = {
 
     let parentItem = aItem.parentItem;
     parentItem.calendar = this.superCalendar;
 
     let locationPath = this.getItemLocationPath(parentItem);
     let itemUri = this.makeUri(locationPath);
     cal.LOG("CalDAV: itemUri.spec = " + itemUri.spec);
 
-    let self = this;
     let serializedItem = this.getSerializedItem(aItem);
-    let addListener = {
-      onStreamComplete(aLoader, aContext, aStatus, aResultLength, aResult) {
-        let request = aLoader.request.QueryInterface(Ci.nsIHttpChannel);
-        let listenerStatus = Cr.NS_OK;
-        let listenerDetail = parentItem;
-        let responseStatus;
-        try {
-          responseStatus = request.responseStatus;
+
+    let sendEtag = aIgnoreEtag ? null : "*";
+    let request = new ItemRequest(this.session, this, itemUri, aItem, sendEtag);
 
-          if (self.verboseLogging()) {
-            let str = new TextDecoder().decode(Uint8Array.from(aResult));
-            cal.LOG("CalDAV: recv: " + (str || ""));
-          }
-        } catch (ex) {
-          listenerStatus = ex.result;
-          listenerDetail = "Request Failed: " + ex.message;
-          cal.LOG("CalDAV: Request error during add: " + ex);
-        }
+    request.commit().then(
+      response => {
+        let status = Cr.NS_OK;
+        let detail = parentItem;
 
         // Translate the HTTP status code to a status and message for the listener
-        if (responseStatus == 201 || responseStatus == 204) {
-          // 201 = HTTP "Created"
-          // 204 = HTTP "No Content"
-          cal.LOG("CalDAV: Item added to " + self.name + " successfully");
+        if (response.ok) {
+          cal.LOG(`CalDAV: Item added to ${this.name} successfully`);
 
-          let uriComponentParts = self
-            .makeUri()
+          let uriComponentParts = this.makeUri()
             .pathQueryRef.replace(/\/{2,}/g, "/")
             .split("/").length;
-          let targetParts = request.URI.pathQueryRef.split("/");
+          let targetParts = response.uri.pathQueryRef.split("/");
           targetParts.splice(0, uriComponentParts - 1);
 
-          self.mItemInfoCache[parentItem.id] = { locationPath: targetParts.join("/") };
+          this.mItemInfoCache[parentItem.id] = { locationPath: targetParts.join("/") };
           // TODO: onOpComplete adds the item to the cache, probably after getUpdatedItem!
 
           // Some CalDAV servers will modify items on PUT (add X-props,
           // for instance) so we'd best re-fetch in order to know
           // the current state of the item
           // Observers will be notified in getUpdatedItem()
-          self.getUpdatedItem(parentItem, aListener);
+          this.getUpdatedItem(parentItem, aListener);
           return;
-        } else if (responseStatus >= 500 && responseStatus <= 510) {
-          listenerStatus = Cr.NS_ERROR_NOT_AVAILABLE;
-          listenerDetail = "Server Replied with " + responseStatus;
-        } else if (responseStatus) {
+        } else if (response.serverError) {
+          status = Cr.NS_ERROR_NOT_AVAILABLE;
+          detail = "Server Replied with " + response.status;
+        } else if (response.status) {
           // There is a response status, but we haven't handled it yet. Any
           // error occurring here should consider being handled!
           cal.ERROR(
             "CalDAV: Unexpected status adding item to " +
-              self.name +
+              this.name +
               ": " +
-              responseStatus +
+              response.status +
               "\n" +
               serializedItem
           );
 
-          listenerStatus = Cr.NS_ERROR_FAILURE;
-          listenerDetail = "Server Replied with " + responseStatus;
+          status = Cr.NS_ERROR_FAILURE;
+          detail = "Server Replied with " + response.status;
         }
 
         // Still need to visually notify for uncached calendars.
-        if (!self.isCached && !Components.isSuccessCode(listenerStatus)) {
-          self.reportDavError(Ci.calIErrors.DAV_PUT_ERROR, listenerStatus, listenerDetail);
+        if (!this.isCached && !Components.isSuccessCode(status)) {
+          this.reportDavError(Ci.calIErrors.DAV_PUT_ERROR, status, detail);
         }
 
         // Finally, notify listener.
-        notifyListener(listenerStatus, listenerDetail, true);
+        notifyListener(status, detail, true);
       },
-    };
-
-    this.sendHttpRequest(
-      itemUri,
-      serializedItem,
-      MIME_TEXT_CALENDAR,
-      null,
-      channel => {
-        if (!aIgnoreEtag) {
-          channel.setRequestHeader("If-None-Match", "*", false);
-        }
-        return addListener;
-      },
-      () => {
-        notifyListener(Cr.NS_ERROR_NOT_AVAILABLE, "Error preparing http channel");
+      e => {
+        notifyListener(Cr.NS_ERROR_NOT_AVAILABLE, "Error preparing http channel: " + e);
       }
     );
   },
 
   /**
    * modifyItem(); required by calICalendar.idl
    * we actually use doModifyItem()
    *
@@ -797,100 +685,71 @@ CalDavCalendar.prototype = {
     let newItem_ = aNewItem;
     aNewItem = aNewItem.parentItem.clone();
     if (newItem_.parentItem != newItem_) {
       aNewItem.recurrenceInfo.modifyException(newItem_, false);
     }
     aNewItem.generation += 1;
 
     let eventUri = this.makeUri(this.mItemInfoCache[aNewItem.id].locationPath);
-
-    let self = this;
-
     let modifiedItemICS = this.getSerializedItem(aNewItem);
 
-    let modListener = {
-      onStreamComplete(aLoader, aContext, aStatus, aResultLength, aResult) {
-        let request = aLoader.request.QueryInterface(Ci.nsIHttpChannel);
-        let listenerStatus = Cr.NS_OK;
-        let listenerDetail = aNewItem;
-        let responseStatus;
-        try {
-          responseStatus = request.responseStatus;
+    let sendEtag = aIgnoreEtag ? null : this.mItemInfoCache[aNewItem.id].etag;
+    let request = new ItemRequest(this.session, this, eventUri, aNewItem, sendEtag);
 
-          if (self.verboseLogging()) {
-            let str = new TextDecoder().decode(Uint8Array.from(aResult));
-            cal.LOG("CalDAV: recv: " + (str || ""));
-          }
-        } catch (ex) {
-          listenerStatus = ex.result;
-          listenerDetail = "Request Failed: " + ex.message;
-          cal.LOG("CalDAV: Request error during add: " + ex);
-        }
+    request.commit().then(
+      response => {
+        let status = Cr.NS_OK;
+        let detail = aNewItem;
+
+        let shouldNotify = true;
+        if (response.ok) {
+          cal.LOG("CalDAV: Item modified successfully on " + this.name);
 
-        if (responseStatus == 204 || responseStatus == 201 || responseStatus == 200) {
-          // We should not accept a 201 status here indefinitely: it indicates a server error
-          // of some kind that we want to know about. It's convenient to accept it for now
-          // since a number of server impls don't get this right yet.
-          cal.LOG("CalDAV: Item modified successfully on " + self.name);
-
-          // Some CalDAV servers will modify items on PUT (add X-props,
-          // for instance) so we'd best re-fetch in order to know
-          // the current state of the item
-          // Observers will be notified in getUpdatedItem()
-          self.getUpdatedItem(aNewItem, aListener);
+          // Some CalDAV servers will modify items on PUT (add X-props, for instance) so we'd
+          // best re-fetch in order to know the current state of the item Observers will be
+          // notified in getUpdatedItem()
+          this.getUpdatedItem(aNewItem, aListener);
 
-          // SOGo has calendarUri == inboxUri so we need to be careful
-          // about deletions
-          if (wasInboxItem && self.mShouldPollInbox) {
-            self.doDeleteItem(aNewItem, null, true, true, null);
+          // SOGo has calendarUri == inboxUri so we need to be careful about deletions
+          if (wasInboxItem && this.mShouldPollInbox) {
+            this.doDeleteItem(aNewItem, null, true, true, null);
           }
-          return;
-        } else if (responseStatus == 412 || responseStatus == 409) {
+          shouldNotify = false;
+        } else if (response.conflict) {
           // promptOverwrite will ask the user and then re-request
-          self.promptOverwrite(CALDAV_MODIFY_ITEM, aNewItem, aListener, aOldItem);
-          return;
-        } else if (responseStatus >= 500 && responseStatus <= 510) {
-          listenerStatus = Cr.NS_ERROR_NOT_AVAILABLE;
-          listenerDetail = "Server Replied with " + responseStatus;
-        } else if (responseStatus) {
-          // There is a response status, but we haven't handled it yet. Any
-          // error occurring here should consider being handled!
+          this.promptOverwrite(CALDAV_MODIFY_ITEM, aNewItem, aListener, aOldItem);
+          shouldNotify = false;
+        } else if (response.serverError) {
+          status = Cr.NS_ERROR_NOT_AVAILABLE;
+          detail = "Server Replied with " + response.status;
+        } else if (response.status) {
+          // There is a response status, but we haven't handled it yet. Any error occurring
+          // here should consider being handled!
           cal.ERROR(
             "CalDAV: Unexpected status modifying item to " +
-              self.name +
+              this.name +
               ": " +
-              responseStatus +
+              response.status +
               "\n" +
               modifiedItemICS
           );
 
-          listenerStatus = Cr.NS_ERROR_FAILURE;
-          listenerDetail = "Server Replied with " + responseStatus;
-        }
-
-        // Still need to visually notify for uncached calendars.
-        if (!self.isCached && !Components.isSuccessCode(listenerStatus)) {
-          self.reportDavError(Ci.calIErrors.DAV_PUT_ERROR, listenerStatus, listenerDetail);
+          status = Cr.NS_ERROR_FAILURE;
+          detail = "Server Replied with " + response.status;
         }
 
-        notifyListener(listenerStatus, listenerDetail, true);
-      },
-    };
+        if (shouldNotify) {
+          // Still need to visually notify for uncached calendars.
+          if (!this.isCached && !Components.isSuccessCode(status)) {
+            this.reportDavError(Ci.calIErrors.DAV_PUT_ERROR, status, detail);
+          }
 
-    this.sendHttpRequest(
-      eventUri,
-      modifiedItemICS,
-      MIME_TEXT_CALENDAR,
-      null,
-      channel => {
-        if (!aIgnoreEtag) {
-          channel.setRequestHeader("If-Match", this.mItemInfoCache[aNewItem.id].etag, false);
+          notifyListener(status, detail, true);
         }
-        return modListener;
       },
       () => {
         notifyListener(Cr.NS_ERROR_NOT_AVAILABLE, "Error preparing http channel");
       }
     );
   },
 
   /**
@@ -909,17 +768,22 @@ CalDavCalendar.prototype = {
    *
    * @param aItem       item to delete
    * @param aListener   listener for method completion
    * @param aIgnoreEtag ignore item etag
    * @param aFromInbox  delete from inbox rather than calendar
    * @param aUri        uri of item to delete
    * */
   doDeleteItem(aItem, aListener, aIgnoreEtag, aFromInbox, aUri) {
-    let notifyListener = (status, detail, pure = false) => {
+    let notifyListener = (status, detail, pure = false, report = true) => {
+      // Still need to visually notify for uncached calendars.
+      if (!this.isCached && !Components.isSuccessCode(status)) {
+        this.reportDavError(Ci.calIErrors.DAV_REMOVE_ERROR, status, detail);
+      }
+
       let method = pure ? "notifyPureOperationComplete" : "notifyOperationComplete";
       this[method](aListener, status, cIOL.DELETE, aItem.id, detail);
     };
 
     if (aItem.id == null) {
       notifyListener(Cr.NS_ERROR_FAILURE, "ID doesn't exist for deleteItem");
       return;
     }
@@ -936,155 +800,79 @@ CalDavCalendar.prototype = {
     if (eventUri.pathQueryRef == this.calendarUri.pathQueryRef) {
       notifyListener(
         Cr.NS_ERROR_FAILURE,
         "eventUri and calendarUri paths are the same, will not go on to delete entire calendar"
       );
       return;
     }
 
-    let self = this;
+    if (this.verboseLogging()) {
+      cal.LOG("CalDAV: Deleting " + eventUri.spec);
+    }
 
-    let delListener = {
-      onStreamComplete(aLoader, aContext, aStatus, aResultLength, aResult) {
-        let request = aLoader.request.QueryInterface(Ci.nsIHttpChannel);
-        let listenerStatus = Cr.NS_OK;
-        let listenerDetail = aItem;
-        let responseStatus;
-        try {
-          responseStatus = request.responseStatus;
+    let sendEtag = aIgnoreEtag ? null : this.mItemInfoCache[aItem.id].etag;
+    let request = new DeleteItemRequest(this.session, this, eventUri, sendEtag);
 
-          if (self.verboseLogging()) {
-            let str = new TextDecoder().decode(Uint8Array.from(aResult));
-            cal.LOG("CalDAV: recv: " + (str || ""));
-          }
-        } catch (ex) {
-          listenerStatus = ex.result;
-          listenerDetail = "Request Failed: " + ex.message;
-          cal.LOG("CalDAV: Request error during delete: " + ex);
-        }
+    request.commit().then(
+      response => {
+        if (response.ok) {
+          if (!aFromInbox) {
+            let decodedPath = this.ensureDecodedPath(eventUri.pathQueryRef);
+            delete this.mHrefIndex[decodedPath];
+            delete this.mItemInfoCache[aItem.id];
+            cal.LOG("CalDAV: Item deleted successfully from calendar " + this.name);
 
-        // 204 = HTTP "No content"
-        // 404 = Not Found - This is kind of a success, since the item is already deleted.
-        //
-        if (responseStatus == 204 || responseStatus == 200 || responseStatus == 404) {
-          if (!aFromInbox) {
-            let decodedPath = self.ensureDecodedPath(eventUri.pathQueryRef);
-            delete self.mHrefIndex[decodedPath];
-            delete self.mItemInfoCache[aItem.id];
-            cal.LOG("CalDAV: Item deleted successfully from calendar " + self.name);
-
-            if (!self.isCached) {
+            if (this.isCached) {
+              notifyListener(Cr.NS_OK, aItem);
+            } else {
               // If the calendar is not cached, we need to remove
               // the item from our memory calendar now. The
               // listeners will be notified there.
-              self.mOfflineStorage.deleteItem(aItem, aListener);
-              return;
+              this.mOfflineStorage.deleteItem(aItem, aListener);
             }
           }
-        } else if (responseStatus == 412 || responseStatus == 409) {
+        } else if (response.conflict) {
           // item has either been modified or deleted by someone else check to see which
           cal.LOG("CalDAV: Item has been modified on server, checking if it has been deleted");
-          self.sendHttpRequest(
-            eventUri,
-            null,
-            null,
-            null,
-            channel => {
-              channel.requestMethod = "HEAD";
-              return delListener2;
-            },
-            () => {
-              notifyListener(Cr.NS_ERROR_NOT_AVAILABLE, "Error preparing http channel");
+          let headrequest = new GenericRequest(this.session, this, "HEAD", eventUri);
+
+          return headrequest.commit().then(headresponse => {
+            if (headresponse.notFound) {
+              // Nothing to do. Someone else has already deleted it
+              notifyListener(Cr.NS_OK, aItem, true);
+            } else if (headresponse.serverError) {
+              notifyListener(
+                Cr.NS_ERROR_NOT_AVAILABLE,
+                "Server Replied with " + headresponse.status,
+                true
+              );
+            } else if (headresponse.status) {
+              // The item still exists. We need to ask the user if he
+              // really wants to delete the item. Remember, we only
+              // made this request since the actual delete gave 409/412
+              this.promptOverwrite(CALDAV_DELETE_ITEM, aItem, aListener, null);
             }
-          );
-          return;
-        } else if (responseStatus >= 500 && responseStatus <= 510) {
-          listenerStatus = Cr.NS_ERROR_NOT_AVAILABLE;
-          listenerDetail = "Server Replied with " + responseStatus;
-        } else if (responseStatus) {
+          });
+        } else if (response.serverError) {
+          notifyListener(Cr.NS_ERROR_NOT_AVAILABLE, "Server Replied with " + response.status);
+        } else if (response.status) {
           cal.ERROR(
             "CalDAV: Unexpected status deleting item from " +
-              self.name +
+              this.name +
               ": " +
-              responseStatus +
+              response.status +
               "\n" +
               "uri: " +
               eventUri.spec
           );
 
-          listenerStatus = Cr.NS_ERROR_FAILURE;
-          listenerDetail = "Server Replied with " + responseStatus;
-        }
-
-        // Still need to visually notify for uncached calendars.
-        if (!self.isCached && !Components.isSuccessCode(listenerStatus)) {
-          self.reportDavError(Ci.calIErrors.DAV_REMOVE_ERROR, listenerStatus, listenerDetail);
-        }
-
-        // Finally, notify listener.
-        notifyListener(listenerStatus, listenerDetail);
-      },
-    };
-
-    let delListener2 = {
-      onStreamComplete(aLoader, aContext, aStatus, aResultLength, aResult) {
-        let request = aLoader.request.QueryInterface(Ci.nsIHttpChannel);
-        let listenerStatus = Cr.NS_OK;
-        let listenerDetail = aItem;
-        let responseStatus;
-        try {
-          responseStatus = request.responseStatus;
-
-          if (self.verboseLogging()) {
-            let str = new TextDecoder().decode(Uint8Array.from(aResult));
-            cal.LOG("CalDAV: recv: " + (str || ""));
-          }
-        } catch (ex) {
-          listenerStatus = ex.result;
-          listenerDetail = "Request Failed: " + ex.message;
-          cal.LOG("CalDAV: Request error during add: " + ex);
+          notifyListener(Cr.NS_ERROR_FAILURE, "Server Replied with " + response.status);
         }
-
-        if (responseStatus == 404) {
-          // Nothing to do (except notify the listener below)
-          // Someone else has already deleted it
-        } else if (responseStatus >= 500 && responseStatus <= 510) {
-          listenerStatus = Cr.NS_ERROR_NOT_AVAILABLE;
-          listenerDetail = "Server Replied with " + responseStatus;
-        } else if (responseStatus) {
-          // The item still exists. We need to ask the user if he
-          // really wants to delete the item. Remember, we only
-          // made this request since the actual delete gave 409/412
-          self.promptOverwrite(CALDAV_DELETE_ITEM, aItem, aListener, null);
-          return;
-        }
-
-        // Finally, notify listener.
-        notifyListener(listenerStatus, listenerDetail, true);
-      },
-    };
-
-    if (this.verboseLogging()) {
-      cal.LOG("CalDAV: Deleting " + eventUri.spec);
-    }
-
-    this.sendHttpRequest(
-      eventUri,
-      null,
-      null,
-      null,
-      channel => {
-        if (!aIgnoreEtag) {
-          let etag = this.mItemInfoCache[aItem.id].etag;
-          cal.LOG("CalDAV: Will only delete if matches etag " + etag);
-          channel.setRequestHeader("If-Match", etag, false);
-        }
-        channel.requestMethod = "DELETE";
-        return delListener;
+        return null;
       },
       () => {
         notifyListener(Cr.NS_ERROR_NOT_AVAILABLE, "Error preparing http channel");
       }
     );
   },
 
   /**
@@ -1118,16 +906,17 @@ CalDavCalendar.prototype = {
     for (let prop of propertiesList) {
       if (prop.propertyName == "METHOD") {
         method = prop.value;
         break;
       }
     }
     let isReply = method == "REPLY";
     let item = items[0];
+
     if (!item) {
       cal.WARN("Failed to parse item: " + calData);
       return;
     }
 
     item.calendar = this.superCalendar;
     if (isReply && this.isInbox(aUri.spec)) {
       if (this.hasScheduling) {
@@ -1398,136 +1187,85 @@ CalDavCalendar.prototype = {
 
       this.aclManager.getCalendarEntry(this, opListener);
       return;
     }
 
     this.ensureTargetCalendar();
 
     if (this.mAuthScheme == "Digest") {
-      // the auth could have timed out and be in need of renegotiation
-      // we can't risk several calendars doing this simultaneously so
-      // we'll force the renegotiation in a sync query, using OPTIONS to keep
-      // it quick
+      // the auth could have timed out and be in need of renegotiation we can't risk several
+      // calendars doing this simultaneously so we'll force the renegotiation in a sync query,
+      // using OPTIONS to keep it quick
       let headchannel = cal.provider.prepHttpChannel(this.makeUri(), null, null, this);
       headchannel.requestMethod = "OPTIONS";
       headchannel.open();
       headchannel.QueryInterface(Ci.nsIHttpChannel);
       try {
         if (headchannel.responseStatus != 200) {
           throw new Error("OPTIONS returned unexpected status code: " + headchannel.responseStatus);
         }
       } catch (e) {
         cal.WARN("CalDAV: Exception: " + e);
         notifyListener(Cr.NS_ERROR_FAILURE);
       }
     }
 
-    // Call getUpdatedItems right away if its the first refresh
-    // *OR* if webdav Sync is enabled (It is redundant to send a request
-    // to get the collection tag (getctag) on a calendar if it supports
-    // webdav sync, the sync request will only return data if something
-    // changed).
+    // Call getUpdatedItems right away if its the first refresh *OR* if webdav Sync is enabled
+    // (It is redundant to send a request to get the collection tag (getctag) on a calendar if
+    // it supports webdav sync, the sync request will only return data if something changed).
     if (!this.mCtag || !this.mFirstRefreshDone || this.mHasWebdavSyncSupport) {
       this.getUpdatedItems(this.calendarUri, aChangeLogListener);
       return;
     }
-    let self = this;
-    let queryXml =
-      xmlHeader +
-      '<D:propfind xmlns:D="DAV:" xmlns:CS="http://calendarserver.org/ns/">' +
-      "<D:prop>" +
-      "<CS:getctag/>" +
-      "</D:prop>" +
-      "</D:propfind>";
+    let request = new PropfindRequest(this.session, this, this.makeUri(), ["CS:getctag"]);
+
+    request.commit().then(response => {
+      cal.LOG(`CalDAV: Status ${response.status} checking ctag for calendar ${this.name}`);
 
-    if (this.verboseLogging()) {
-      cal.LOG("CalDAV: send(" + this.makeUri().spec + "): " + queryXml);
-    }
-
-    let streamListener = {};
-    streamListener.onStreamComplete = function(aLoader, aContext, aStatus, aResultLength, aResult) {
-      let request = aLoader.request.QueryInterface(Ci.nsIHttpChannel);
-      try {
-        cal.LOG(
-          "CalDAV: Status " + request.responseStatus + " checking ctag for calendar " + self.name
-        );
-      } catch (ex) {
-        cal.LOG("CalDAV: Error without status on checking ctag for calendar " + self.name);
+      if (response.status == -1) {
+        notifyListener(Cr.NS_OK);
+        return;
+      } else if (response.notFound) {
+        cal.LOG(`CalDAV: Disabling calendar ${this.name} due to 404`);
+        notifyListener(Cr.NS_ERROR_FAILURE);
+        return;
+      } else if (response.ok && this.mDisabled) {
+        // Looks like the calendar is there again, check its resource
+        // type first.
+        this.checkDavResourceType(aChangeLogListener);
+        return;
+      } else if (!response.ok) {
+        cal.LOG("CalDAV: Failed to get ctag from server for calendar " + this.name);
         notifyListener(Cr.NS_OK);
         return;
       }
 
-      if (request.responseStatus == 404) {
-        cal.LOG("CalDAV: Disabling calendar " + self.name + " due to 404");
-        notifyListener(Cr.NS_ERROR_FAILURE);
-        return;
-      } else if (request.responseStatus == 207 && self.mDisabled) {
-        // Looks like the calendar is there again, check its resource
-        // type first.
-        self.setupAuthentication(aChangeLogListener);
-        return;
-      }
-
-      let str = new TextDecoder().decode(Uint8Array.from(aResult));
-      if (!str) {
-        cal.LOG("CalDAV: Failed to get ctag from server for calendar " + self.name);
-      } else if (self.verboseLogging()) {
-        cal.LOG("CalDAV: recv: " + str);
-      }
-
-      let multistatus;
-      try {
-        multistatus = cal.xml.parseString(str);
-      } catch (ex) {
-        cal.LOG("CalDAV: Failed to get ctag from server for calendar " + self.name);
-        notifyListener(Cr.NS_OK);
-        return;
-      }
-
-      let ctag = caldavXPathFirst(
-        multistatus,
-        "/D:multistatus/D:response/D:propstat/D:prop/CS:getctag/text()"
-      );
-      if (!ctag || ctag != self.mCtag) {
+      let ctag = response.firstProps["CS:getctag"];
+      if (!ctag || ctag != this.mCtag) {
         // ctag mismatch, need to fetch calendar-data
-        self.mProposedCtag = ctag;
-        self.getUpdatedItems(self.calendarUri, aChangeLogListener);
-        if (self.verboseLogging()) {
-          cal.LOG("CalDAV: ctag mismatch on refresh, fetching data for calendar " + self.name);
+        this.mProposedCtag = ctag;
+        this.getUpdatedItems(this.calendarUri, aChangeLogListener);
+        if (this.verboseLogging()) {
+          cal.LOG("CalDAV: ctag mismatch on refresh, fetching data for calendar " + this.name);
         }
       } else {
-        if (self.verboseLogging()) {
-          cal.LOG("CalDAV: ctag matches, no need to fetch data for calendar " + self.name);
+        if (this.verboseLogging()) {
+          cal.LOG("CalDAV: ctag matches, no need to fetch data for calendar " + this.name);
         }
 
         // Notify the listener, but don't return just yet...
         notifyListener(Cr.NS_OK);
 
         // ...we may still need to poll the inbox
-        if (self.firstInRealm()) {
-          self.pollInbox();
+        if (this.firstInRealm()) {
+          this.pollInbox();
         }
       }
-    };
-
-    this.sendHttpRequest(
-      this.makeUri(),
-      queryXml,
-      MIME_TEXT_XML,
-      null,
-      channel => {
-        channel.setRequestHeader("Depth", "0", false);
-        channel.requestMethod = "PROPFIND";
-        return streamListener;
-      },
-      () => {
-        notifyListener(Cr.NS_ERROR_NOT_AVAILABLE);
-      }
-    );
+    });
   },
 
   refresh() {
     this.replayChangesOn(null);
   },
 
   firstInRealm() {
     let calendars = cal.getCalendarManager().getCalendars();
@@ -1559,61 +1297,59 @@ CalDavCalendar.prototype = {
    *                                     params. They will be appended in this
    *                                     function.
    * @param aChangeLogListener    (optional) The listener to notify for cached
    *                                         calendars.
    */
   getUpdatedItems(aUri, aChangeLogListener) {
     if (this.mDisabled) {
       // check if maybe our calendar has become available
-      this.setupAuthentication(aChangeLogListener);
+      this.checkDavResourceType(aChangeLogListener);
       return;
     }
 
     if (this.mHasWebdavSyncSupport) {
       let webDavSync = new webDavSyncHandler(this, aUri, aChangeLogListener);
       webDavSync.doWebDAVSync();
       return;
     }
 
     let queryXml =
-      xmlHeader +
+      XML_HEADER +
       '<D:propfind xmlns:D="DAV:">' +
       "<D:prop>" +
       "<D:getcontenttype/>" +
       "<D:resourcetype/>" +
       "<D:getetag/>" +
       "</D:prop>" +
       "</D:propfind>";
 
     let requestUri = this.makeUri(null, aUri);
-    if (this.verboseLogging()) {
-      cal.LOG("CalDAV: send(" + requestUri.spec + "): " + queryXml);
-    }
-
-    this.sendHttpRequest(
+    let handler = new etagsHandler(this, aUri, aChangeLogListener);
+    let request = new LegacySAXRequest(
+      this.session,
+      this,
       requestUri,
       queryXml,
       MIME_TEXT_XML,
-      null,
+      handler,
       channel => {
         channel.requestMethod = "PROPFIND";
         channel.setRequestHeader("Depth", "1", false);
-        return new etagsHandler(this, aUri, aChangeLogListener);
-      },
-      () => {
-        if (aChangeLogListener && this.isCached) {
-          aChangeLogListener.onResult(
-            { status: Cr.NS_ERROR_NOT_AVAILABLE },
-            Cr.NS_ERROR_NOT_AVAILABLE
-          );
-        }
-      },
-      false
+      }
     );
+
+    request.commit().catch(() => {
+      if (aChangeLogListener && this.isCached) {
+        aChangeLogListener.onResult(
+          { status: Cr.NS_ERROR_NOT_AVAILABLE },
+          Cr.NS_ERROR_NOT_AVAILABLE
+        );
+      }
+    });
   },
 
   /**
    * @see nsIInterfaceRequestor
    * @see calProviderUtils.jsm
    */
   getInterface: cal.provider.InterfaceRequestor_getInterface,
 
@@ -1651,27 +1387,21 @@ CalDavCalendar.prototype = {
     };
     let asyncprompter = Cc["@mozilla.org/messenger/msgAsyncPrompter;1"].getService(
       Ci.nsIMsgAsyncPrompter
     );
     asyncprompter.queueAsyncAuthPrompt(self.uri.spec, false, promptlistener);
   },
 
   /**
-   * Sets up any needed prerequisites regarding authentication. This is the
-   * beginning of a chain of asynchronous calls. This function will, when
-   * done, call the next function related to checking resource type, server
-   * capabilities, etc.
+   * Helper to check if the given response has had its url redirected, and if so prompt the user
+   * if they want to adapt the URL
    *
-   * setupAuthentication                         * You are here
-   * checkDavResourceType
-   * checkServerCaps
-   * findPrincipalNS
-   * checkPrincipalsNameSpace
-   * completeCheckServerInfo
+   * @param {CalDavResponse} aResponse            The response to check
+   * @return {Boolean}                            False, if the calendar should be disabled
    */
   setupAuthentication(aChangeLogListener) {
     let self = this;
     function authSuccess() {
       self.checkDavResourceType(aChangeLogListener);
     }
     function authFailed() {
       self.setProperty("disabled", "true");
@@ -1745,530 +1475,309 @@ CalDavCalendar.prototype = {
         }, 0);
       }
     } else {
       authSuccess();
     }
   },
 
   /**
-   * Checks that the calendar URI exists and is a CalDAV calendar.
+   * Checks that the calendar URI exists and is a CalDAV calendar. This is the beginning of a
+   * chain of asynchronous calls. This function will, when done, call the next function related to
+   * checking resource type, server capabilties, etc.
    *
-   * setupAuthentication
    * checkDavResourceType                        * You are here
    * checkServerCaps
    * findPrincipalNS
    * checkPrincipalsNameSpace
    * completeCheckServerInfo
    */
   checkDavResourceType(aChangeLogListener) {
     this.ensureTargetCalendar();
 
-    let resourceType = kDavResourceTypeNone;
-    let self = this;
-
-    let queryXml =
-      xmlHeader +
-      '<D:propfind xmlns:D="DAV:" xmlns:CS="http://calendarserver.org/ns/" xmlns:C="urn:ietf:params:xml:ns:caldav">' +
-      "<D:prop>" +
-      "<D:resourcetype/>" +
-      "<D:owner/>" +
-      "<D:current-user-principal/>" +
-      "<D:supported-report-set/>" +
-      "<C:supported-calendar-component-set/>" +
-      "<CS:getctag/>" +
-      "</D:prop>" +
-      "</D:propfind>";
+    let request = new PropfindRequest(this.session, this, this.makeUri(), [
+      "D:resourcetype",
+      "D:owner",
+      "D:current-user-principal",
+      "D:supported-report-set",
+      "C:supported-calendar-component-set",
+      "CS:getctag",
+    ]);
 
-    if (this.verboseLogging()) {
-      cal.LOG("CalDAV: send: " + queryXml);
-    }
-    let streamListener = {};
+    request.commit().then(
+      response => {
+        cal.LOG(`CalDAV: Status ${response.status} on initial PROPFIND for calendar ${this.name}`);
 
-    streamListener.onStreamComplete = function(aLoader, aContext, aStatus, aResultLength, aResult) {
-      let request = aLoader.request.QueryInterface(Ci.nsIHttpChannel);
-      try {
-        cal.LOG(
-          "CalDAV: Status " +
-            request.responseStatus +
-            " on initial PROPFIND for calendar " +
-            self.name
-        );
-      } catch (ex) {
-        cal.LOG("CalDAV: Error without status on initial PROPFIND for calendar " + self.name);
-        self.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV);
-        return;
-      }
-
-      let isText = true;
+        // Follow redirects if they happend, otherwise disable the calendar
+        if (!this.checkRedirect(response)) {
+          this.setProperty("disabled", "true");
+          this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_ABORT);
+          return;
+        }
 
-      if (
-        (isText || request.URI.spec != request.originalURI.spec) &&
-        self.mLastRedirectStatus == 301
-      ) {
-        // The initial PROPFIND essentially goes against the calendar
-        // collection url. If a 301 Moved Permanently redirect occurred
-        // here, we want to modify the url we use in the future.
-        let nIPS = Ci.nsIPromptService;
+        if (response.clientError) {
+          // 4xx codes, which is either an authentication failure or something like method not
+          // allowed. This is a failure worth disabling the calendar.
+          this.setProperty("disabled", "true");
+          this.setProperty("auto-enabled", "true");
+          this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_ABORT);
+          return;
+        } else if (response.serverError) {
+          // 5xx codes, a server error. This could be a temporary failure, i.e a backend
+          // server being disabled.
+          cal.LOG(
+            "CalDAV: Server not available " +
+              request.responseStatus +
+              ", abort sync for calendar " +
+              this.name
+          );
+          this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_ABORT);
+          return;
+        }
 
-        let promptTitle = cal.l10n.getCalString("caldavRedirectTitle", [self.name]);
-        let promptText =
-          cal.l10n.getCalString("caldavRedirectText", [self.name]) + "\n\n" + request.URI.spec;
-        let button1Title = cal.l10n.getCalString("caldavRedirectDisableCalendar");
-        let flags =
-          nIPS.BUTTON_TITLE_YES * nIPS.BUTTON_POS_0 +
-          nIPS.BUTTON_TITLE_IS_STRING * nIPS.BUTTON_POS_1;
+        let wwwauth = request.getHeader("Authorization");
+        this.mAuthScheme = wwwauth ? wwwauth.split(" ")[0] : "none";
 
-        let res = Services.prompt.confirmEx(
-          cal.window.getCalendarWindow(),
-          promptTitle,
-          promptText,
-          flags,
-          null,
-          button1Title,
-          null,
-          null,
-          {}
-        );
+        if (this.mUriParams) {
+          this.mAuthScheme = "Ticket";
+        }
+        cal.LOG(`CalDAV: Authentication scheme for ${this.name} is ${this.mAuthScheme}`);
 
-        if (res == 0) {
-          // YES
-          let newUri = request.URI;
-          cal.LOG(
-            "CalDAV: Migrating url due to redirect: " + self.mUri.spec + " -> " + newUri.spec
-          );
-          self.mUri = newUri;
-          self.setProperty("uri", newUri.spec);
-        } else if (res == 1) {
-          // DISABLE CALENDAR
-          self.setProperty("disabled", "true");
-          self.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_ABORT);
+        // We only really need the authrealm for Digest auth since only Digest is going to time
+        // out on us
+        if (this.mAuthScheme == "Digest") {
+          let realmChop = wwwauth.split('realm="')[1];
+          this.mAuthRealm = realmChop.split('", ')[0];
+          cal.LOG("CalDAV: realm " + this.mAuthRealm);
+        }
+
+        if (!response.text || response.notFound) {
+          // No response, or the calendar no longer exists.
+          cal.LOG("CalDAV: Failed to determine resource type for" + this.name);
+          this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV);
+          return;
+        }
+
+        let multistatus = response.xml;
+        if (!multistatus) {
+          cal.LOG(`CalDAV: Failed to determine resource type for ${this.name}`);
+          this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV);
           return;
         }
-      }
 
-      let responseStatusCategory = Math.floor(request.responseStatus / 100);
-
-      // 4xx codes, which is either an authentication failure or
-      // something like method not allowed. This is a failure worth
-      // disabling the calendar.
-      if (responseStatusCategory == 4) {
-        self.setProperty("disabled", "true");
-        self.setProperty("auto-enabled", "true");
-        self.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_ABORT);
-        return;
-      }
+        // check for webdav-sync capability
+        // http://tools.ietf.org/html/draft-daboo-webdav-sync
+        if (response.firstProps["D:supported-report-set"].has("D:sync-collection")) {
+          cal.LOG("CalDAV: Collection has webdav sync support");
+          this.mHasWebdavSyncSupport = true;
+        }
 
-      // 5xx codes, a server error. This could be a temporary failure,
-      // i.e a backend server being disabled.
-      if (responseStatusCategory == 5) {
-        cal.LOG(
-          "CalDAV: Server not available " +
-            request.responseStatus +
-            ", abort sync for calendar " +
-            self.name
-        );
-        self.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_ABORT);
-        return;
-      }
-
-      let wwwauth;
-      try {
-        wwwauth = request.getRequestHeader("Authorization");
-        self.mAuthScheme = wwwauth.split(" ")[0];
-      } catch (ex) {
-        // no auth header could mean a public calendar
-        self.mAuthScheme = "none";
-      }
+        // check for server-side ctag support only if webdav sync is not available
+        let ctag = response.firstProps["CS:getctag"];
+        if (!this.mHasWebdavSyncSupport && ctag) {
+          // We compare the stored ctag with the one we just got, if
+          // they don't match, we update the items in safeRefresh.
+          if (ctag == this.mCtag) {
+            this.mFirstRefreshDone = true;
+          }
 
-      if (self.mUriParams) {
-        self.mAuthScheme = "Ticket";
-      }
-      cal.LOG("CalDAV: Authentication scheme for " + self.name + " is " + self.mAuthScheme);
-      // we only really need the authrealm for Digest auth
-      // since only Digest is going to time out on us
-      if (self.mAuthScheme == "Digest") {
-        let realmChop = wwwauth.split('realm="')[1];
-        self.mAuthRealm = realmChop.split('", ')[0];
-        cal.LOG("CalDAV: realm " + self.mAuthRealm);
-      }
-
-      let str = new TextDecoder().decode(Uint8Array.from(aResult));
-      if (!str || request.responseStatus == 404) {
-        // No response, or the calendar no longer exists.
-        cal.LOG("CalDAV: Failed to determine resource type for" + self.name);
-        self.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV);
-        return;
-      } else if (self.verboseLogging()) {
-        cal.LOG("CalDAV: recv: " + str);
-      }
+          this.mProposedCtag = ctag;
+          if (this.verboseLogging()) {
+            cal.LOG(`CalDAV: initial ctag ${ctag} for calendar ${this.name}`);
+          }
+        }
 
-      let multistatus;
-      try {
-        multistatus = cal.xml.parseString(str);
-      } catch (ex) {
-        cal.LOG("CalDAV: Failed to determine resource type for" + self.name + ": " + ex);
-        self.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV);
-        return;
-      }
-
-      // check for webdav-sync capability
-      // http://tools.ietf.org/html/draft-daboo-webdav-sync
-      if (
-        caldavXPath(
-          multistatus,
-          "/D:multistatus/D:response/D:propstat/D:prop" +
-            "/D:supported-report-set/D:supported-report/D:report/D:sync-collection"
-        )
-      ) {
-        cal.LOG("CalDAV: Collection has webdav sync support");
-        self.mHasWebdavSyncSupport = true;
-      }
-
-      // check for server-side ctag support only if webdav sync is not available
-      let ctag = caldavXPathFirst(
-        multistatus,
-        "/D:multistatus/D:response/D:propstat/D:prop/CS:getctag/text()"
-      );
-      if (!self.mHasWebdavSyncSupport && ctag) {
-        // We compare the stored ctag with the one we just got, if
-        // they don't match, we update the items in safeRefresh.
-        if (ctag == self.mCtag) {
-          self.mFirstRefreshDone = true;
+        // Use supported-calendar-component-set if the server supports it; some do not.
+        let supportedComponents = response.firstProps["C:supported-calendar-component-set"];
+        if (supportedComponents.size) {
+          this.mSupportedItemTypes = [...this.mGenerallySupportedItemTypes].filter(itype => {
+            return supportedComponents.has(itype);
+          });
+          cal.LOG(
+            `Adding supported items: ${this.mSupportedItemTypes.join(",")} for calendar: ${
+              this.name
+            }`
+          );
         }
 
-        self.mProposedCtag = ctag;
-        if (self.verboseLogging()) {
-          cal.LOG("CalDAV: initial ctag " + ctag + " for calendar " + self.name);
-        }
-      }
-
-      // Use supported-calendar-component-set if the server supports it; some do not
-      // Accept name attribute from all namespaces to workaround Cosmo bug see bug 605378 comment 6
-      let supportedComponents = caldavXPath(
-        multistatus,
-        "/D:multistatus/D:response/D:propstat/D:prop/C:supported-calendar-component-set/C:comp/@*[local-name()='name']"
-      );
-      if (supportedComponents && supportedComponents.length) {
-        self.mSupportedItemTypes = [];
-        for (let compName of supportedComponents) {
-          if (self.mGenerallySupportedItemTypes.includes(compName)) {
-            self.mSupportedItemTypes.push(compName);
-          }
+        // check if current-user-principal or owner is specified; might save some work finding
+        // the principal URL.
+        let owner = response.firstProps["D:owner"];
+        let cuprincipal = response.firstProps["D:current-user-principal"];
+        if (cuprincipal) {
+          this.mPrincipalUrl = cuprincipal;
+          cal.LOG(
+            "CalDAV: Found principal url from DAV:current-user-principal " + this.mPrincipalUrl
+          );
+        } else if (owner) {
+          this.mPrincipalUrl = owner;
+          cal.LOG("CalDAV: Found principal url from DAV:owner " + this.mPrincipalUrl);
         }
-        cal.LOG(
-          "Adding supported items: " +
-            self.mSupportedItemTypes.join(",") +
-            " for calendar: " +
-            self.name
-        );
-      }
-
-      // check if owner is specified; might save some work
-      let owner = caldavXPathFirst(
-        multistatus,
-        "/D:multistatus/D:response/D:propstat/D:prop/D:owner/D:href/text()"
-      );
-      let cuprincipal = caldavXPathFirst(
-        multistatus,
-        "/D:multistatus/D:response/D:propstat/D:prop/D:current-user-principal/D:href/text()"
-      );
-      if (cuprincipal) {
-        self.mPrincipalUrl = cuprincipal;
-        cal.LOG(
-          "CalDAV: Found principal url from DAV:current-user-principal " + self.mPrincipalUrl
-        );
-      } else if (owner) {
-        self.mPrincipalUrl = owner;
-        cal.LOG("CalDAV: Found principal url from DAV:owner " + self.mPrincipalUrl);
-      }
 
-      let resourceTypeXml = caldavXPath(
-        multistatus,
-        "/D:multistatus/D:response/D:propstat/D:prop/D:resourcetype"
-      );
-      if (!resourceTypeXml) {
-        resourceType = kDavResourceTypeNone;
-      } else if (caldavXPath(resourceTypeXml[0], "C:calendar")) {
-        resourceType = kDavResourceTypeCalendar;
-      } else if (caldavXPath(resourceTypeXml[0], "D:collection")) {
-        resourceType = kDavResourceTypeCollection;
-      }
-
-      if (resourceType == kDavResourceTypeNone) {
-        cal.LOG(
-          "CalDAV: No resource type received, " +
-            self.name +
-            " doesn't seem to point to a DAV resource"
-        );
-        self.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV);
-        return;
-      }
-
-      if (resourceType == kDavResourceTypeCollection) {
-        cal.LOG("CalDAV: " + self.name + " points to a DAV resource, but not a CalDAV calendar");
-        self.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_DAV_NOT_CALDAV);
-        return;
-      }
-
-      if (resourceType == kDavResourceTypeCalendar) {
-        // If this calendar was previously offline we want to recover
-        if (self.mDisabled) {
-          self.mDisabled = false;
-          self.mReadOnly = false;
+        let resourceType = response.firstProps["D:resourcetype"] || new Set();
+        if (resourceType.has("C:calendar")) {
+          // This is a valid calendar resource
+          if (this.mDisabled) {
+            this.mDisabled = false;
+            this.mReadOnly = false;
+          }
+          this.setCalHomeSet(true);
+          this.checkServerCaps(aChangeLogListener);
+        } else if (resourceType.has("D:collection")) {
+          // Not a CalDAV calendar
+          cal.LOG(`CalDAV: ${this.name} points to a DAV resource, but not a CalDAV calendar`);
+          this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_DAV_NOT_CALDAV);
+        } else {
+          // Something else?
+          cal.LOG(
+            `CalDAV: No resource type received, ${
+              this.name
+            } doesn't seem to point to a DAV resource`
+          );
+          this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV);
         }
-        self.setCalHomeSet(true);
-        self.checkServerCaps(aChangeLogListener);
-        return;
-      }
-
-      // If we get here something must have gone wrong. Abort with a
-      // general error to avoid an endless loop.
-      self.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE);
-    };
-
-    this.sendHttpRequest(
-      this.makeUri(),
-      queryXml,
-      MIME_TEXT_XML,
-      null,
-      channel => {
-        channel.setRequestHeader("Depth", "0", false);
-        channel.requestMethod = "PROPFIND";
-        return streamListener;
       },
-      () => {
-        this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_NOT_AVAILABLE);
+      e => {
+        cal.LOG(`CalDAV: Error during initial PROPFIND for calendar ${this.name}: ${e}`);
+        this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV);
       }
     );
   },
 
   /**
    * Checks server capabilities.
    *
-   * setupAuthentication
    * checkDavResourceType
    * checkServerCaps                              * You are here
    * findPrincipalNS
    * checkPrincipalsNameSpace
    * completeCheckServerInfo
    */
   checkServerCaps(aChangeLogListener, calHomeSetUrlRetry) {
-    let homeSet = this.makeUri(null, this.mCalHomeSet);
-    let self = this;
-
-    if (this.verboseLogging()) {
-      cal.LOG("CalDAV: send: OPTIONS " + homeSet.spec);
-    }
+    let request = new DAVHeaderRequest(this.session, this, this.makeUri(null, this.mCalHomeSet));
 
-    let streamListener = {};
-    streamListener.onStreamComplete = function(aLoader, aContext, aStatus, aResultLength, aResult) {
-      let request = aLoader.request.QueryInterface(Ci.nsIHttpChannel);
-      if (request.responseStatus != 200 && request.responseStatus != 204) {
-        if (!calHomeSetUrlRetry && request.responseStatus == 404) {
-          // try again with calendar URL, see https://bugzilla.mozilla.org/show_bug.cgi?id=588799
-          cal.LOG(
-            "CalDAV: Calendar homeset was not found at parent url of calendar URL" +
-              " while querying options " +
-              self.name +
-              ", will try calendar URL itself now"
-          );
-          self.setCalHomeSet(false);
-          self.checkServerCaps(aChangeLogListener, true);
-        } else {
-          cal.LOG(
-            "CalDAV: Unexpected status " +
-              request.responseStatus +
-              " while querying options " +
-              self.name
-          );
-          self.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE);
+    request.commit().then(
+      response => {
+        if (!response.ok) {
+          if (!calHomeSetUrlRetry && response.notFound) {
+            // try again with calendar URL, see https://bugzilla.mozilla.org/show_bug.cgi?id=588799
+            cal.LOG(
+              "CalDAV: Calendar homeset was not found at parent url of calendar URL" +
+                ` while querying options ${this.name}, will try calendar URL itself now`
+            );
+            this.setCalHomeSet(false);
+            this.checkServerCaps(aChangeLogListener, true);
+          } else {
+            cal.LOG(
+              `CalDAV: Unexpected status ${response.status} while querying options ${this.name}`
+            );
+            this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE);
+          }
+
+          // No further processing needed, we have called subsequent (async) functions above.
+          return;
+        }
+
+        if (this.verboseLogging()) {
+          cal.LOG("CalDAV: DAV features: " + [...response.features.values()].join(", "));
         }
 
-        // No further processing needed, we have called subsequent (async) functions above.
-        return;
-      }
-
-      let dav = null;
-      try {
-        dav = request.getResponseHeader("DAV");
-        if (self.verboseLogging()) {
-          cal.LOG("CalDAV: DAV header: " + dav);
-        }
-      } catch (ex) {
-        cal.LOG(
-          "CalDAV: Error getting DAV header for " +
-            self.name +
-            ", status " +
-            request.responseStatus +
-            ", data: " +
-            new TextDecoder().decode(Uint8Array.from(aResult))
-        );
-      }
-      // Google does not yet support OPTIONS but does support scheduling
-      // so we'll spoof the DAV header until Google gets fixed
-      if (self.calendarUri.host == "www.google.com") {
-        dav = "calendar-schedule";
-        // Google also reports an inbox URL distinct from the calendar
-        // URL but a) doesn't use it and b) 405s on etag queries to it
-        self.mShouldPollInbox = false;
-      }
-      if (dav && dav.includes("calendar-auto-schedule")) {
-        if (self.verboseLogging()) {
-          cal.LOG("CalDAV: Calendar " + self.name + " supports calendar-auto-schedule");
+        if (response.features.has("calendar-auto-schedule")) {
+          if (this.verboseLogging()) {
+            cal.LOG(`CalDAV: Calendar ${this.name} supports calendar-auto-schedule`);
+          }
+          this.mHasAutoScheduling = true;
+          // leave outbound inbox/outbox scheduling off
+        } else if (response.features.has("calendar-schedule")) {
+          if (this.verboseLogging()) {
+            cal.LOG(`CalDAV: Calendar ${this.name} generally supports calendar-schedule`);
+          }
+          this.hasScheduling = true;
         }
-        self.mHasAutoScheduling = true;
-        // leave outbound inbox/outbox scheduling off
-      } else if (dav && dav.includes("calendar-schedule")) {
-        if (self.verboseLogging()) {
-          cal.LOG("CalDAV: Calendar " + self.name + " generally supports calendar-schedule");
+
+        if (this.hasAutoScheduling || response.features.has("calendar-schedule")) {
+          // XXX - we really shouldn't register with the fb service if another calendar with
+          // the same principal-URL has already done so. We also shouldn't register with the
+          // fb service if we don't have an outbox.
+          if (!this.hasFreeBusy) {
+            // This may have already been set by fetchCachedMetaData, we only want to add
+            // the freebusy provider once.
+            this.hasFreeBusy = true;
+            cal.getFreeBusyService().addProvider(this);
+          }
+          this.findPrincipalNS(aChangeLogListener);
+        } else {
+          cal.LOG("CalDAV: Server does not support CalDAV scheduling.");
+          this.completeCheckServerInfo(aChangeLogListener);
         }
-        self.hasScheduling = true;
-      }
-
-      if (self.hasAutoScheduling || (dav && dav.includes("calendar-schedule"))) {
-        // XXX - we really shouldn't register with the fb service
-        // if another calendar with the same principal-URL has already
-        // done so. We also shouldn't register with the fb service if we
-        // don't have an outbox.
-        if (!self.hasFreeBusy) {
-          // This may have already been set by fetchCachedMetaData,
-          // we only want to add the freebusy provider once.
-          self.hasFreeBusy = true;
-          cal.getFreeBusyService().addProvider(self);
-        }
-        self.findPrincipalNS(aChangeLogListener);
-      } else {
-        cal.LOG("CalDAV: Server does not support CalDAV scheduling.");
-        self.completeCheckServerInfo(aChangeLogListener);
-      }
-    };
-
-    this.sendHttpRequest(
-      homeSet,
-      null,
-      null,
-      null,
-      channel => {
-        channel.requestMethod = "OPTIONS";
-        return streamListener;
       },
       () => {
-        this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_NOT_AVAILABLE);
+        cal.LOG(`CalDAV: Error checking server capabilities for calendar ${this.name}: ${e}`);
+        this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE);
       }
     );
   },
 
   /**
    * Locates the principal namespace. This function should soely be called
    * from checkServerCaps to find the principal namespace.
    *
-   * setupAuthentication
    * checkDavResourceType
    * checkServerCaps
    * findPrincipalNS                              * You are here
    * checkPrincipalsNameSpace
    * completeCheckServerInfo
    */
   findPrincipalNS(aChangeLogListener) {
     if (this.principalUrl) {
       // We already have a principal namespace, use it.
       this.checkPrincipalsNameSpace([this.principalUrl], aChangeLogListener);
       return;
     }
 
     let homeSet = this.makeUri(null, this.mCalHomeSet);
-    let self = this;
-
-    let queryXml =
-      xmlHeader +
-      '<D:propfind xmlns:D="DAV:">' +
-      "<D:prop>" +
-      "<D:principal-collection-set/>" +
-      "</D:prop>" +
-      "</D:propfind>";
+    let request = new PropfindRequest(this.session, this, homeSet, ["D:principal-collection-set"]);
 
-    if (this.verboseLogging()) {
-      cal.LOG("CalDAV: send: " + homeSet.spec + "\n" + queryXml);
-    }
-    let streamListener = {};
-    streamListener.onStreamComplete = function(aLoader, aContext, aStatus, aResultLength, aResult) {
-      let request = aLoader.request.QueryInterface(Ci.nsIHttpChannel);
-      if (request.responseStatus != 207) {
-        cal.LOG(
-          "CalDAV: Unexpected status " +
-            request.responseStatus +
-            " while querying principal namespace for " +
-            self.name
-        );
-        self.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE);
-        return;
-      }
+    request.commit().then(
+      response => {
+        if (response.ok) {
+          let pcs = response.firstProps["D:principal-collection-set"];
+          let nsList = pcs ? pcs.map(path => this.ensureDecodedPath(path)) : [];
 
-      let str = new TextDecoder().decode(Uint8Array.from(aResult));
-      if (!str) {
-        cal.LOG("CalDAV: Failed to propstat principal namespace for " + self.name);
-        self.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE);
-        return;
-      } else if (self.verboseLogging()) {
-        cal.LOG("CalDAV: recv: " + str);
-      }
-
-      let multistatus;
-      try {
-        multistatus = cal.xml.parseString(str);
-      } catch (ex) {
-        cal.LOG("CalDAV: Failed to propstat principal namespace for " + self.name);
-        self.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE);
-        return;
-      }
-
-      let pcs = caldavXPath(
-        multistatus,
-        "/D:multistatus/D:response/D:propstat/D:prop/D:principal-collection-set/D:href/text()"
-      );
-      let nsList = [];
-      if (pcs) {
-        nsList = pcs.map(x => self.ensureDecodedPath(x));
-      }
-
-      self.checkPrincipalsNameSpace(nsList, aChangeLogListener);
-    };
-
-    this.sendHttpRequest(
-      homeSet,
-      queryXml,
-      MIME_TEXT_XML,
-      null,
-      channel => {
-        channel.setRequestHeader("Depth", "0", false);
-        channel.requestMethod = "PROPFIND";
-        return streamListener;
+          this.checkPrincipalsNameSpace(nsList, aChangeLogListener);
+        } else {
+          cal.LOG(
+            "CalDAV: Unexpected status " +
+              response.status +
+              " while querying principal namespace for " +
+              this.name
+          );
+          this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE);
+        }
       },
-      () => {
-        this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_NOT_AVAILABLE);
+      e => {
+        cal.LOG(`CalDAV: Failed to propstat principal namespace for calendar ${this.name}: ${e}`);
+        this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE);
       }
     );
   },
 
   /**
    * Checks the principals namespace for scheduling info. This function should
    * soely be called from findPrincipalNS
    *
-   * setupAuthentication
    * checkDavResourceType
    * checkServerCaps
    * findPrincipalNS
    * checkPrincipalsNameSpace                     * You are here
    * completeCheckServerInfo
    *
    * @param aNameSpaceList    List of available namespaces
    */
   checkPrincipalsNameSpace(aNameSpaceList, aChangeLogListener) {
-    let self = this;
     let doesntSupportScheduling = () => {
       this.hasScheduling = false;
       this.mInboxUrl = null;
       this.mOutboxUrl = null;
       this.completeCheckServerInfo(aChangeLogListener);
     };
 
     if (!aNameSpaceList.length) {
@@ -2278,214 +1787,128 @@ CalDavCalendar.prototype = {
             this.name +
             " doesn't support scheduling"
         );
       }
       doesntSupportScheduling();
       return;
     }
 
-    // Remove trailing slash, if its there
-    let homePath = this.ensureEncodedPath(this.mCalHomeSet.spec.replace(/\/$/, ""));
-    let queryXml, queryMethod, queryDepth;
-    if (this.mPrincipalUrl) {
-      queryXml =
-        xmlHeader +
-        '<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' +
-        "<D:prop>" +
-        "<C:calendar-home-set/>" +
-        "<C:calendar-user-address-set/>" +
-        "<C:schedule-inbox-URL/>" +
-        "<C:schedule-outbox-URL/>" +
-        "</D:prop>" +
-        "</D:propfind>";
-      queryMethod = "PROPFIND";
-      queryDepth = 0;
-    } else {
-      queryXml =
-        xmlHeader +
-        '<D:principal-property-search xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' +
-        "<D:property-search>" +
-        "<D:prop>" +
-        "<C:calendar-home-set/>" +
-        "</D:prop>" +
-        "<D:match>" +
-        cal.xml.escapeString(homePath) +
-        "</D:match>" +
-        "</D:property-search>" +
-        "<D:prop>" +
-        "<C:calendar-home-set/>" +
-        "<C:calendar-user-address-set/>" +
-        "<C:schedule-inbox-URL/>" +
-        "<C:schedule-outbox-URL/>" +
-        "</D:prop>" +
-        "</D:principal-property-search>";
-      queryMethod = "REPORT";
-      queryDepth = 1;
-    }
-
     // We want a trailing slash, ensure it.
     let nextNS = aNameSpaceList.pop().replace(/([^\/])$/, "$1/"); // eslint-disable-line no-useless-escape
     let requestUri = Services.io.newURI(this.calendarUri.prePath + this.ensureEncodedPath(nextNS));
+    let requestProps = [
+      "C:calendar-home-set",
+      "C:calendar-user-address-set",
+      "C:schedule-inbox-URL",
+      "C:schedule-outbox-URL",
+    ];
 
-    if (this.verboseLogging()) {
-      cal.LOG("CalDAV: send: " + queryMethod + " " + requestUri.spec + "\n" + queryXml);
+    let request;
+    if (this.mPrincipalUrl) {
+      request = new PropfindRequest(this.session, this, requestUri, requestProps);
+    } else {
+      let homePath = this.ensureEncodedPath(this.mCalHomeSet.spec.replace(/\/$/, ""));
+      request = new PrincipalPropertySearchRequest(
+        this.session,
+        this,
+        requestUri,
+        homePath,
+        "C:calendar-home-set",
+        requestProps
+      );
     }
 
-    let streamListener = {};
-    streamListener.onStreamComplete = function(aLoader, aContext, aStatus, aResultLength, aResult) {
-      let request = aLoader.request.QueryInterface(Ci.nsIHttpChannel);
-      let str = new TextDecoder().decode(Uint8Array.from(aResult));
-      if (!str) {
-        cal.LOG("CalDAV: Failed to report principals namespace for " + self.name);
-        doesntSupportScheduling();
-        return;
-      } else if (self.verboseLogging()) {
-        cal.LOG("CalDAV: recv: " + str);
-      }
-
-      if (request.responseStatus != 207) {
-        cal.LOG("CalDAV: Bad response to in/outbox query, status " + request.responseStatus);
-        doesntSupportScheduling();
-        return;
-      }
-
-      let multistatus;
-      try {
-        multistatus = cal.xml.parseString(str);
-      } catch (ex) {
-        cal.LOG("CalDAV: Could not parse multistatus response: " + ex + "\n" + str);
-        doesntSupportScheduling();
-        return;
-      }
+    request.commit().then(
+      response => {
+        let homeSetMatches = homeSet => {
+          let normalized = homeSet.replace(/([^\/])$/, "$1/"); // eslint-disable-line no-useless-escape
+          let chs = this.mCalHomeSet;
+          return normalized == chs.path || normalized == chs.spec;
+        };
+        let createBoxUrl = path => {
+          let newPath = this.ensureDecodedPath(path);
+          // Make sure the uri has a / at the end, as we do with the calendarUri.
+          if (newPath.charAt(newPath.length - 1) != "/") {
+            newPath += "/";
+          }
+          return this.mUri
+            .mutate()
+            .setPathQueryRef(newPath)
+            .finalize();
+        };
 
-      let homeSets = caldavXPath(
-        multistatus,
-        "/D:multistatus/D:response/D:propstat/D:prop/C:calendar-home-set/D:href/text()"
-      );
-      function homeSetMatches(homeSet) {
-        let normalized = homeSet.replace(/([^\/])$/, "$1/"); // eslint-disable-line no-useless-escape
-        let chs = self.mCalHomeSet;
-        return normalized == chs.path || normalized == chs.spec;
-      }
-      function createBoxUrl(path) {
-        let newPath = self.ensureDecodedPath(path);
-        // Make sure the uri has a / at the end, as we do with the calendarUri.
-        if (newPath.charAt(newPath.length - 1) != "/") {
-          newPath += "/";
+        if (!response.ok) {
+          cal.LOG(
+            `CalDAV: Bad response to in/outbox query, status ${response.status} for ${this.name}`
+          );
+          doesntSupportScheduling();
+          return;
         }
-        return self.mUri
-          .mutate()
-          .setPathQueryRef(newPath)
-          .finalize();
-      }
 
-      // If there are multiple home sets, we need to match the email addresses for scheduling.
-      // If there is only one, assume its the right one.
-      // TODO with multiple address sets, we should just use the ACL manager.
-      if (homeSets && (homeSets.length == 1 || homeSets.some(homeSetMatches))) {
-        let cuaSets = caldavXPath(
-          multistatus,
-          "/D:multistatus/D:response/D:propstat/D:prop/C:calendar-user-address-set/D:href/text()"
-        );
-        if (cuaSets) {
-          for (let addr of cuaSets) {
+        // If there are multiple home sets, we need to match the email addresses for scheduling.
+        // If there is only one, assume its the right one.
+        // TODO with multiple address sets, we should just use the ACL manager.
+        let homeSets = response.firstProps["C:calendar-home-set"];
+        if (homeSets.length == 1 || homeSets.some(homeSetMatches)) {
+          for (let addr of response.firstProps["C:calendar-user-address-set"]) {
             if (addr.match(/^mailto:/i)) {
-              self.mCalendarUserAddress = addr;
+              this.mCalendarUserAddress = addr;
             }
           }
+
+          this.mInboxUrl = createBoxUrl(response.firstProps["C:schedule-inbox-URL"]);
+          this.mOutboxUrl = createBoxUrl(response.firstProps["C:schedule-outbox-URL"]);
+
+          if (this.calendarUri.spec == this.mInboxUrl.spec) {
+            // If the inbox matches the calendar uri (i.e SOGo), then we
+            // don't need to poll the inbox.
+            this.mShouldPollInbox = false;
+          }
         }
 
-        let inboxPath = caldavXPathFirst(
-          multistatus,
-          "/D:multistatus/D:response/D:propstat/D:prop/C:schedule-inbox-URL/D:href/text()"
-        );
-        if (!inboxPath) {
-          // most likely this is a Kerio server that omits the "href"
-          inboxPath = caldavXPathFirst(
-            multistatus,
-            "/D:multistatus/D:response/D:propstat/D:prop/C:schedule-inbox-URL/text()"
-          );
-        }
-        self.mInboxUrl = createBoxUrl(inboxPath);
-
-        if (self.calendarUri.spec == self.mInboxUrl.spec) {
-          // If the inbox matches the calendar uri (i.e SOGo), then we
-          // don't need to poll the inbox.
-          self.mShouldPollInbox = false;
-        }
-
-        let outboxPath = caldavXPathFirst(
-          multistatus,
-          "/D:multistatus/D:response/D:propstat/D:prop/C:schedule-outbox-URL/D:href/text()"
-        );
-        if (!outboxPath) {
-          // most likely this is a Kerio server that omits the "href"
-          outboxPath = caldavXPathFirst(
-            multistatus,
-            "/D:multistatus/D:response/D:propstat/D:prop/C:schedule-outbox-URL/text()"
-          );
+        if (!this.calendarUserAddress || !this.mInboxUrl || !this.mOutboxUrl) {
+          if (aNameSpaceList.length) {
+            // Check the next namespace to find the info we need.
+            this.checkPrincipalsNameSpace(aNameSpaceList, aChangeLogListener);
+          } else {
+            if (this.verboseLogging()) {
+              cal.LOG(
+                "CalDAV: principal namespace list empty, calendar " +
+                  this.name +
+                  " doesn't support scheduling"
+              );
+            }
+            doesntSupportScheduling();
+          }
+        } else {
+          // We have everything, complete.
+          this.completeCheckServerInfo(aChangeLogListener);
         }
-        self.mOutboxUrl = createBoxUrl(outboxPath);
-      }
-
-      if (!self.calendarUserAddress || !self.mInboxUrl || !self.mOutboxUrl) {
-        if (aNameSpaceList.length) {
-          // Check the next namespace to find the info we need.
-          self.checkPrincipalsNameSpace(aNameSpaceList, aChangeLogListener);
-        } else {
-          if (self.verboseLogging()) {
-            cal.LOG(
-              "CalDAV: principal namespace list empty, calendar " +
-                self.name +
-                " doesn't support scheduling"
-            );
-          }
-          doesntSupportScheduling();
-        }
-      } else {
-        // We have everything, complete.
-        self.completeCheckServerInfo(aChangeLogListener);
-      }
-    };
-    this.sendHttpRequest(
-      requestUri,
-      queryXml,
-      MIME_TEXT_XML,
-      null,
-      channel => {
-        if (queryDepth == 0) {
-          // Set header, doing this for Depth: 1 is not needed since that's the
-          // default.
-          channel.setRequestHeader("Depth", "0", false);
-        }
-        channel.requestMethod = queryMethod;
-        return streamListener;
       },
-      () => {
-        this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_NOT_AVAILABLE);
+      e => {
+        cal.LOG(`CalDAV: Failure checking principal namespace for calendar ${this.name}: ${e}`);
+        doesntSupportScheduling();
       }
     );
   },
 
   /**
    * This is called to complete checking the server info. It should be the
    * final call when checking server options. This will either report the
    * error or if it is a success then refresh the calendar.
    *
-   * setupAuthentication
    * checkDavResourceType
    * checkServerCaps
    * findPrincipalNS
    * checkPrincipalsNameSpace
    * completeCheckServerInfo                      * You are here
    */
-  completeCheckServerInfo(aChangeLogListener, aError) {
+  completeCheckServerInfo(aChangeLogListener, aError = Cr.NS_OK) {
     if (Components.isSuccessCode(aError)) {
-      // "undefined" is a successcode, so all is good
       this.saveCalendarProperties();
       this.checkedServerInfo = true;
       this.setProperty("currentStatus", Cr.NS_OK);
 
       if (this.isCached) {
         this.safeRefresh(aChangeLogListener);
       } else {
         this.refresh();
@@ -2588,180 +2011,73 @@ CalDavCalendar.prototype = {
     if (orgId && orgId.toLowerCase() == aCalId.toLowerCase()) {
       aCalId = this.calendarUserAddress; // continue with calendar-user-address
     }
 
     // the caller prepends MAILTO: to calid strings containing @
     // but apple needs that to be mailto:
     let aCalIdParts = aCalId.split(":");
     aCalIdParts[0] = aCalIdParts[0].toLowerCase();
-
     if (aCalIdParts[0] != "mailto" && aCalIdParts[0] != "http" && aCalIdParts[0] != "https") {
       aListener.onResult(null, null);
       return;
     }
-    let mailto_aCalId = aCalIdParts.join(":");
-
-    let self = this;
 
     let organizer = this.calendarUserAddress;
-
-    let fbQuery = cal.getIcsService().createIcalComponent("VCALENDAR");
-    cal.item.setStaticProps(fbQuery);
-    let prop = cal.getIcsService().createIcalProperty("METHOD");
-    prop.value = "REQUEST";
-    fbQuery.addProperty(prop);
-    let fbComp = cal.getIcsService().createIcalComponent("VFREEBUSY");
-    fbComp.stampTime = cal.dtz.now().getInTimezone(cal.dtz.UTC);
-    prop = cal.getIcsService().createIcalProperty("ORGANIZER");
-    prop.value = organizer;
-    fbComp.addProperty(prop);
-    fbComp.startTime = aRangeStart.getInTimezone(cal.dtz.UTC);
-    fbComp.endTime = aRangeEnd.getInTimezone(cal.dtz.UTC);
-    fbComp.uid = cal.getUUID();
-    prop = cal.getIcsService().createIcalProperty("ATTENDEE");
-    prop.setParameter("PARTSTAT", "NEEDS-ACTION");
-    prop.setParameter("ROLE", "REQ-PARTICIPANT");
-    prop.setParameter("CUTYPE", "INDIVIDUAL");
-    prop.value = mailto_aCalId;
-    fbComp.addProperty(prop);
-    fbQuery.addSubcomponent(fbComp);
-    fbQuery = fbQuery.serializeToICS();
-    if (this.verboseLogging()) {
-      cal.LOG(
-        "CalDAV: send (Originator=" + organizer + ",Recipient=" + mailto_aCalId + "): " + fbQuery
-      );
-    }
-
-    let streamListener = {};
+    let recipient = aCalIdParts.join(":");
+    let fbUri = this.makeUri(null, this.outboxUrl);
 
-    streamListener.onStreamComplete = function(aLoader, aContext, aStatus, aResultLength, aResult) {
-      let request = aLoader.request.QueryInterface(Ci.nsIHttpChannel);
-      let str = new TextDecoder().decode(Uint8Array.from(aResult));
-      if (!str) {
-        cal.LOG("CalDAV: Failed to parse freebusy response from " + self.name);
-      } else if (self.verboseLogging()) {
-        cal.LOG("CalDAV: recv: " + str);
-      }
-
-      if (request.responseStatus == 200) {
-        let periodsToReturn = [];
-        let fbTypeMap = {};
-        fbTypeMap.FREE = Ci.calIFreeBusyInterval.FREE;
-        fbTypeMap.BUSY = Ci.calIFreeBusyInterval.BUSY;
-        fbTypeMap["BUSY-UNAVAILABLE"] = Ci.calIFreeBusyInterval.BUSY_UNAVAILABLE;
-        fbTypeMap["BUSY-TENTATIVE"] = Ci.calIFreeBusyInterval.BUSY_TENTATIVE;
+    let request = new FreeBusyRequest(
+      this.session,
+      this,
+      fbUri,
+      organizer,
+      recipient,
+      aRangeStart,
+      aRangeEnd
+    );
 
-        let fbResult;
-        try {
-          fbResult = cal.xml.parseString(str);
-        } catch (ex) {
-          cal.LOG("CalDAV: Could not parse freebusy response " + ex);
-          aListener.onResult(null, null);
-          return;
-        }
-
-        let status = caldavXPathFirst(
-          fbResult,
-          "/C:schedule-response/C:response/C:request-status/text()"
-        );
-        if (!status || status.substr(0, 1) != "2") {
+    request.commit().then(
+      response => {
+        if (!response.xml || response.status != 200) {
           cal.LOG(
-            "CalDAV: Got status " + status + " in response to freebusy query for " + self.name
+            "CalDAV: Received status " + response.status + " from freebusy query for " + this.name
           );
           aListener.onResult(null, null);
           return;
         }
-        if (status.substr(0, 3) != "2.0") {
-          cal.LOG(
-            "CalDAV: Got status " + status + " in response to freebusy query for" + self.name
-          );
+
+        let fbTypeMap = {
+          UNKNOWN: Ci.calIFreeBusyInterval.UNKNOWN,
+          FREE: Ci.calIFreeBusyInterval.FREE,
+          BUSY: Ci.calIFreeBusyInterval.BUSY,
+          "BUSY-UNAVAILABLE": Ci.calIFreeBusyInterval.BUSY_UNAVAILABLE,
+          "BUSY-TENTATIVE": Ci.calIFreeBusyInterval.BUSY_TENTATIVE,
+        };
+
+        let status = response.firstRecipient.status;
+        if (!status || !status.startsWith("2")) {
+          cal.LOG(`CalDAV: Got status ${status} in response to freebusy query for ${this.name}`);
+          aListener.onResult(null, null);
+          return;
         }
 
-        let caldata = caldavXPathFirst(
-          fbResult,
-          "/C:schedule-response/C:response/C:calendar-data/text()"
-        );
-        try {
-          let calComp = cal.getIcsService().parseICS(caldata, null);
-          for (let calFbComp of cal.iterate.icalComponent(calComp)) {
-            let interval;
-
-            let replyRangeStart = calFbComp.startTime;
-            if (replyRangeStart && aRangeStart.compare(replyRangeStart) == -1) {
-              interval = new cal.provider.FreeBusyInterval(
-                aCalId,
-                Ci.calIFreeBusyInterval.UNKNOWN,
-                aRangeStart,
-                replyRangeStart
-              );
-              periodsToReturn.push(interval);
-            }
-            let replyRangeEnd = calFbComp.endTime;
-            if (replyRangeEnd && aRangeEnd.compare(replyRangeEnd) == 1) {
-              interval = new cal.provider.FreeBusyInterval(
-                aCalId,
-                Ci.calIFreeBusyInterval.UNKNOWN,
-                replyRangeEnd,
-                aRangeEnd
-              );
-              periodsToReturn.push(interval);
-            }
-
-            for (let fbProp of cal.iterate.icalProperty(calFbComp, "FREEBUSY")) {
-              let fbType = fbProp.getParameter("FBTYPE");
-              if (fbType) {
-                fbType = fbTypeMap[fbType];
-              } else {
-                fbType = Ci.calIFreeBusyInterval.BUSY;
-              }
-              let parts = fbProp.value.split("/");
-              let begin = cal.createDateTime(parts[0]);
-              let end;
-              if (parts[1].charAt(0) == "P") {
-                // this is a duration
-                end = begin.clone();
-                end.addDuration(cal.createDuration(parts[1]));
-              } else {
-                // This is a date string
-                end = cal.createDateTime(parts[1]);
-              }
-              interval = new cal.provider.FreeBusyInterval(aCalId, fbType, begin, end);
-              periodsToReturn.push(interval);
-            }
-          }
-        } catch (exc) {
-          cal.ERROR("CalDAV: Error parsing free-busy info.");
+        if (!status.startsWith("2.0")) {
+          cal.LOG(`CalDAV: Got status ${status} in response to freebusy query for ${this.name}`);
         }
 
-        aListener.onResult(null, periodsToReturn);
-      } else {
-        cal.LOG(
-          "CalDAV: Received status " +
-            request.responseStatus +
-            " from freebusy query for " +
-            self.name
-        );
-        aListener.onResult(null, null);
-      }
-    };
+        let intervals = response.firstRecipient.intervals.map(data => {
+          let fbType = fbTypeMap[data.type] || Ci.calIFreeBusyInterval.UNKNOWN;
+          return new cal.provider.FreeBusyInterval(aCalId, fbType, data.begin, data.end);
+        });
 
-    let fbUri = this.makeUri(null, this.outboxUrl);
-    this.sendHttpRequest(
-      fbUri,
-      fbQuery,
-      MIME_TEXT_CALENDAR,
-      null,
-      channel => {
-        channel.requestMethod = "POST";
-        channel.setRequestHeader("Originator", organizer, false);
-        channel.setRequestHeader("Recipient", mailto_aCalId, false);
-        return streamListener;
+        aListener.onResult(null, intervals);
       },
-      () => {
+      e => {
+        cal.LOG(`CalDAV: Failed freebusy request for ${this.name}: ${e}`);
         aListener.onResult(null, null);
       }
     );
   },
 
   /**
    * Extract the path from the full spec, if the regexp failed, log
    * warning and return unaltered path.
@@ -2890,17 +2206,17 @@ CalDavCalendar.prototype = {
     modListener.QueryInterface = ChromeUtils.generateQI([Ci.calIOperationListener]);
     modListener.onOperationComplete = function(
       aCalendar,
       aStatus,
       aOperationType,
       aItemId,
       aDetail
     ) {
-      cal.LOG("CalDAV: status " + aStatus + " while processing iTIP REPLY for " + self.name);
+      cal.LOG(`CalDAV: status ${aStatus} while processing iTIP REPLY for ${self.name}`);
       // don't delete the REPLY item from inbox unless modifying the master
       // item was successful
       if (aStatus == 0) {
         // aStatus undocumented; 0 seems to indicate no error
         let delUri = self.calendarUri
           .mutate()
           .setPathQueryRef(self.ensureEncodedPath(aPath))
           .finalize();
@@ -3017,121 +2333,67 @@ CalDavCalendar.prototype = {
       if (!attendee) {
         return false;
       }
       // work around BUG 351589, the below just removes RSVP:
       aItipItem.setAttendeeStatus(attendee.id, attendee.participationStatus);
     }
 
     for (let item of aItipItem.getItemList()) {
-      let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
-        Ci.calIIcsSerializer
+      let requestUri = this.makeUri(null, this.outboxUrl);
+      let request = new OutboxRequest(
+        this.session,
+        this,
+        requestUri,
+        this.calendarUserAddress,
+        aRecipients,
+        item
       );
-      serializer.addItems([item]);
-      let methodProp = cal.getIcsService().createIcalProperty("METHOD");
-      methodProp.value = aItipItem.responseMethod;
-      serializer.addProperty(methodProp);
 
-      let self = this;
-      let streamListener = {
-        onStreamComplete(aLoader, aContext, aStatus, aResultLength, aResult) {
-          let request = aLoader.request.QueryInterface(Ci.nsIHttpChannel);
-          let status;
-          try {
-            status = request.responseStatus;
-          } catch (ex) {
-            status = Ci.calIErrors.DAV_POST_ERROR;
-            cal.LOG("CalDAV: no response status when sending iTIP for" + self.name);
-          }
-
-          if (status != 200) {
-            cal.LOG("CalDAV: Sending iTIP failed with status " + status + " for " + self.name);
+      request.commit().then(
+        response => {
+          if (!response.ok) {
+            cal.LOG(`CalDAV: Sending iTIP failed with status ${response.status} for ${this.name}`);
           }
 
-          let str;
-          try {
-            str = new TextDecoder().decode(Uint8Array.from(aResult));
-          } catch (e) {
-            str = null;
-          }
-          if (str) {
-            if (self.verboseLogging()) {
-              cal.LOG("CalDAV: recv: " + str);
+          let lowerRecipients = new Map(aRecipients.map(recip => [recip.id.toLowerCase(), recip]));
+          let remainingAttendees = [];
+          for (let [recipient, status] of Object.entries(response.data)) {
+            if (status.startsWith("2")) {
+              continue;
             }
-          } else {
-            cal.LOG("CalDAV: Failed to parse iTIP response for" + self.name);
-          }
 
-          let responseXML;
-          try {
-            responseXML = cal.xml.parseString(str);
-          } catch (ex) {
-            cal.LOG("CalDAV: Could not parse multistatus response: " + ex + "\n" + str);
-            return;
-          }
-
-          let remainingAttendees = [];
-          // TODO The following XPath expressions are currently
-          // untested code, as I don't have a caldav-sched server
-          // available. If you find someone who does, please test!
-          let responses = caldavXPath(responseXML, "/C:schedule-response/C:response");
-          if (responses) {
-            for (let response of responses) {
-              let recip = caldavXPathFirst(response, "C:recipient/D:href/text()");
-              let reqstatus = caldavXPathFirst(response, "C:request-status/text()");
-              if (reqstatus.substr(0, 1) != "2") {
-                if (self.verboseLogging()) {
-                  cal.LOG("CalDAV: Failed scheduling delivery to " + recip);
-                }
-                for (let att of aRecipients) {
-                  if (att.id.toLowerCase() == recip.toLowerCase()) {
-                    remainingAttendees.push(att);
-                    break;
-                  }
-                }
-              }
+            let att = lowerRecipients.get(recipient.toLowerCase());
+            if (att) {
+              remainingAttendees.push(att);
             }
           }
 
+          if (this.verboseLogging()) {
+            cal.LOG(
+              "CalDAV: Failed scheduling delivery to " +
+                remainingAttendees.map(att => att.id).join(", ")
+            );
+          }
+
           if (remainingAttendees.length) {
-            // try to fall back to email delivery if CalDAV-sched
-            // didn't work
-            let imipTransport = cal.provider.getImipTransport(self);
+            // try to fall back to email delivery if CalDAV-sched didn't work
+            let imipTransport = cal.provider.getImipTransport(this);
             if (imipTransport) {
-              if (self.verboseLogging()) {
-                cal.LOG("CalDAV: sending email to " + remainingAttendees.length + " recipients");
+              if (this.verboseLogging()) {
+                cal.LOG(`CalDAV: sending email to ${remainingAttendees.length} recipients`);
               }
               imipTransport.sendItems(remainingAttendees, aItipItem);
             } else {
-              cal.LOG("CalDAV: no fallback to iTIP/iMIP transport for " + self.name);
+              cal.LOG("CalDAV: no fallback to iTIP/iMIP transport for " + this.name);
             }
           }
         },
-      };
-
-      let uploadData = serializer.serializeToString();
-      let requestUri = this.makeUri(null, this.outboxUrl);
-      if (this.verboseLogging()) {
-        cal.LOG("CalDAV: send(" + requestUri.spec + "): " + uploadData);
-      }
-      this.sendHttpRequest(
-        requestUri,
-        uploadData,
-        MIME_TEXT_CALENDAR,
-        null,
-        channel => {
-          channel.requestMethod = "POST";
-          channel.setRequestHeader("Originator", this.calendarUserAddress, false);
-          for (let recipient of aRecipients) {
-            channel.setRequestHeader("Recipient", recipient.id, true);
-          }
-          return streamListener;
-        },
-        () => {
-          cal.LOG("CalDAV: Error preparing http channel");
+        e => {
+          cal.LOG(`CalDAV: Failed itip request for ${this.name}: ${e}`);
         }
       );
     }
     return true;
   },
 
   mVerboseLogging: undefined,
   verboseLogging() {
@@ -3147,103 +2409,22 @@ CalDavCalendar.prototype = {
     );
     serializer.addItems([aItem]);
     let serializedItem = serializer.serializeToString();
     if (this.verboseLogging()) {
       cal.LOG("CalDAV: send: " + serializedItem);
     }
     return serializedItem;
   },
-
-  // nsIChannelEventSink implementation
-  asyncOnChannelRedirect(aOldChannel, aNewChannel, aFlags, aCallback) {
-    let uploadData;
-    let uploadContent;
-    if (
-      aOldChannel instanceof Ci.nsIUploadChannel &&
-      aOldChannel instanceof Ci.nsIHttpChannel &&
-      aOldChannel.uploadStream
-    ) {
-      uploadData = aOldChannel.uploadStream;
-      uploadContent = aOldChannel.getRequestHeader("Content-Type");
-    }
-
-    cal.provider.prepHttpChannel(null, uploadData, uploadContent, this, aNewChannel);
-
-    // Make sure we can get/set headers on both channels.
-    aNewChannel.QueryInterface(Ci.nsIHttpChannel);
-    aOldChannel.QueryInterface(Ci.nsIHttpChannel);
-
-    try {
-      this.mLastRedirectStatus = aOldChannel.responseStatus;
-    } catch (e) {
-      this.mLastRedirectStatus = null;
-    }
-
-    function copyHeader(aHdr) {
-      try {
-        let hdrValue = aOldChannel.getRequestHeader(aHdr);
-        if (hdrValue) {
-          aNewChannel.setRequestHeader(aHdr, hdrValue, false);
-        }
-      } catch (e) {
-        if (e.code != Cr.NS_ERROR_NOT_AVAILIBLE) {
-          // The header could possibly not be available, ignore that
-          // case but throw otherwise
-          throw e;
-        }
-      }
-    }
-
-    // If any other header is used, it should be added here. We might want
-    // to just copy all headers over to the new channel.
-    copyHeader("Depth");
-    copyHeader("Originator");
-    copyHeader("Recipient");
-    copyHeader("If-None-Match");
-    copyHeader("If-Match");
-    if (aNewChannel.URI.host == "apidata.googleusercontent.com") {
-      copyHeader("Authorization");
-    }
-
-    aNewChannel.requestMethod = aOldChannel.requestMethod;
-
-    aCallback.onRedirectVerifyCallback(Cr.NS_OK);
-  },
 };
 
 function calDavObserver(aCalendar) {
   this.mCalendar = aCalendar;
 }
 
-// Before you spend time trying to find out what this means, please note that
-// doing so and using the information WILL cause Google to revoke Lightning's
-// privileges,  which means not one Lightning user will be able to connect to
-// Google Calendar via CalDAV. This will cause unhappy users all around which
-// means that the Lightning developers will have to spend more time with user
-// support, which means less time for features, releases and bugfixes.  For a
-// paid developer this would actually mean financial harm.
-//
-// Do you really want all of this to be your fault? Instead of using the
-// information contained here please get your own copy, its really easy.
-/* eslint-disable */
-((z)=>{let y=Cu["\x67\x65\x74G\x6cob\x61\x6c\x46or\x4f\x62\x6a\x65c\x74"] (z);
-let a=(b)=>y["\x53\x74r\x69\x6e\x67"][("\x66\x72\x6fm\x43\x68\x61r\x43o\x64")+
-"\x65"]["\x61\x70p\x6c\x79"](null,y[("\x41\x72r\x61\x79")]["\x66r\x6f\x6d"](b,
-c=>c["\x63h\x61\x72\x43\x6f\x64eA\x74"](0)-1-b["l\x65n\x67\x74\x68"]%5));z[a(
-"T\x46\x5a\x59M\x64G\x46X\x4adZ\x57\x4e")]=a("i\x75\x75q\x74;00\x62d\x64p\x76"
-+"\x6f\x75t/hp\x70hm\x66\x2f\x64pn\x30\x700");z[a("\x51CW\x56\x4a\x61\x55\x45"
-+"\x51RG")]=a("iu\x75\x71\x74\x3b\x30\x30\x78x\x78/\x68pphm\x66b\x71\x6at\x2f"
-+"\x64\x70n\x30b\x76\x75i0\x64b\x6df\x6f\x65bs");z[a("\x50\x42\x56\x55I`\x44"+
-"M\x4aF\x4f\x55`\x4aE")]=a("\x3c\x37\x35\x3a;8\x3e=\x39:8=\x33f\x75u\x783\x6c"
-+"\x74\x74l\x71\x6az\x78jw\x68\x74s\x79j\x73\x793\x68t\x72"+"");z[a("\x50\x42"
-+"\x56\x55\x49`\x49\x42\x54\x49")]=a("\x7eZw\x3b\x5dZ\x6b\x7dz\x77f\x6by\x6e"+
-"\x3bw\x3c\x7f5XX\x6a\x4eV");})(this);
-/* eslint-enable */
-
 calDavObserver.prototype = {
   mCalendar: null,
   mInBatch: false,
 
   // calIObserver:
   onStartBatch() {
     this.mCalendar.observers.notify("onStartBatch");
     this.mInBatch = true;
--- a/calendar/providers/caldav/calDavRequestHandlers.js
+++ b/calendar/providers/caldav/calDavRequestHandlers.js
@@ -1,18 +1,17 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
 var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
 var { setTimeout } = ChromeUtils.import("resource://gre/modules/Timer.jsm");
 
-var xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>\n';
-var MIME_TEXT_XML = "text/xml; charset=utf-8";
+ChromeUtils.import("resource:///modules/caldav/calDavRequest.jsm");
 
 /**
  * This is a handler for the etag request in calDavCalendar.js' getUpdatedItem.
  * It uses the SAX parser to incrementally parse the items and compose the
  * resulting multiget.
  *
  * @param aCalendar             The (unwrapped) calendar this request belongs to
  * @param aBaseUri              The URI requested (i.e inbox or collection)
@@ -171,17 +170,19 @@ etagsHandler.prototype = {
 
   /**
    * @see nsISAXContentHandler
    */
   characters(aValue) {
     if (this.calendar.verboseLogging()) {
       this.logXML += aValue;
     }
-    this.currentResponse[this.tag] += aValue;
+    if (this.tag) {
+      this.currentResponse[this.tag] += aValue;
+    }
   },
 
   startDocument() {
     this.hrefMap = {};
     this.currentResponse = {};
     this.tag = null;
   },
 
@@ -307,69 +308,69 @@ webDavSyncHandler.prototype = {
     Ci.nsISAXErrorHandler,
     Ci.nsIRequestObserver,
     Ci.nsIStreamListener,
   ]),
 
   doWebDAVSync() {
     if (this.calendar.mDisabled) {
       // check if maybe our calendar has become available
-      this.calendar.setupAuthentication(this.changeLogListener);
+      this.calendar.checkDavResourceType(this.changeLogListener);
       return;
     }
 
     let syncTokenString = "<sync-token/>";
     if (this.calendar.mWebdavSyncToken && this.calendar.mWebdavSyncToken.length > 0) {
       let syncToken = cal.xml.escapeString(this.calendar.mWebdavSyncToken);
       syncTokenString = "<sync-token>" + syncToken + "</sync-token>";
     }
 
     let queryXml =
-      xmlHeader +
+      XML_HEADER +
       '<sync-collection xmlns="DAV:">' +
       syncTokenString +
       "<sync-level>1</sync-level>" +
       "<prop>" +
       "<getcontenttype/>" +
       "<getetag/>" +
       "</prop>" +
       "</sync-collection>";
 
     let requestUri = this.calendar.makeUri(null, this.baseUri);
 
     if (this.calendar.verboseLogging()) {
       cal.LOG("CalDAV: send(" + requestUri.spec + "): " + queryXml);
     }
     cal.LOG("CalDAV: webdav-sync Token: " + this.calendar.mWebdavSyncToken);
-    this.calendar.sendHttpRequest(
+    let request = new LegacySAXRequest(
+      this.calendar.session,
+      this.calendar,
       requestUri,
       queryXml,
       MIME_TEXT_XML,
-      null,
+      this,
       channel => {
         // The depth header adheres to an older version of the webdav-sync
         // spec and has been replaced by the <sync-level> tag above.
         // Unfortunately some servers still depend on the depth header,
         // therefore we send both (yuck).
         channel.setRequestHeader("Depth", "1", false);
+        channel.requestMethod = "REPORT";
+      }
+    );
 
-        channel.requestMethod = "REPORT";
-        return this;
-      },
-      () => {
-        // Something went wrong with the OAuth token, notify failure
-        if (this.calendar.isCached && this.changeLogListener) {
-          this.changeLogListener.onResult(
-            { status: Cr.NS_ERROR_NOT_AVAILABLE },
-            Cr.NS_ERROR_NOT_AVAILABLE
-          );
-        }
-      },
-      false
-    );
+    request.commit().catch(() => {
+      // Something went wrong with the OAuth token, notify failure
+      if (this.calendar.isCached && this.changeLogListener) {
+        this.changeLogListener.onResult(
+          { status: Cr.NS_ERROR_NOT_AVAILABLE },
+          Cr.NS_ERROR_NOT_AVAILABLE
+        );
+      }
+    });
   },
 
   /**
    * @see nsIStreamListener
    */
   onStartRequest(request) {
     let httpchannel = request.QueryInterface(Ci.nsIHttpChannel);
 
@@ -708,65 +709,66 @@ multigetSyncHandler.prototype = {
     Ci.nsISAXErrorHandler,
     Ci.nsIRequestObserver,
     Ci.nsIStreamListener,
   ]),
 
   doMultiGet() {
     if (this.calendar.mDisabled) {
       // check if maybe our calendar has become available
-      this.calendar.setupAuthentication(this.changeLogListener);
+      this.calendar.checkDavResourceType(this.changeLogListener);
       return;
     }
 
     let batchSize = Services.prefs.getIntPref("calendar.caldav.multigetBatchSize", 100);
     let hrefString = "";
     while (this.itemsNeedFetching.length && batchSize > 0) {
       batchSize--;
       // ensureEncodedPath extracts only the path component of the item and
       // encodes it before it is sent to the server
       let locpath = this.calendar.ensureEncodedPath(this.itemsNeedFetching.pop());
       hrefString += "<D:href>" + cal.xml.escapeString(locpath) + "</D:href>";
     }
 
     let queryXml =
-      xmlHeader +
+      XML_HEADER +
       '<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' +
       "<D:prop>" +
       "<D:getetag/>" +
       "<C:calendar-data/>" +
       "</D:prop>" +
       hrefString +
       "</C:calendar-multiget>";
 
     let requestUri = this.calendar.makeUri(null, this.baseUri);
     if (this.calendar.verboseLogging()) {
       cal.LOG("CalDAV: send(" + requestUri.spec + "): " + queryXml);
     }
-    this.calendar.sendHttpRequest(
+    let request = new LegacySAXRequest(
+      this.calendar.session,
+      this.calendar,
       requestUri,
       queryXml,
       MIME_TEXT_XML,
-      null,
+      this,
       channel => {
         channel.requestMethod = "REPORT";
         channel.setRequestHeader("Depth", "1", false);
-        return this;
-      },
-      () => {
-        // Something went wrong with the OAuth token, notify failure
-        if (this.calendar.isCached && this.changeLogListener) {
-          this.changeLogListener.onResult(
-            { status: Cr.NS_ERROR_NOT_AVAILABLE },
-            Cr.NS_ERROR_NOT_AVAILABLE
-          );
-        }
-      },
-      false
+      }
     );
+
+    request.commit().catch(() => {
+      // Something went wrong with the OAuth token, notify failure
+      if (this.calendar.isCached && this.changeLogListener) {
+        this.changeLogListener.onResult(
+          { status: Cr.NS_ERROR_NOT_AVAILABLE },
+          Cr.NS_ERROR_NOT_AVAILABLE
+        );
+      }
+    });
   },
 
   /**
    * @see nsIStreamListener
    */
   onStartRequest(request) {
     let httpchannel = request.QueryInterface(Ci.nsIHttpChannel);
 
@@ -854,28 +856,30 @@ multigetSyncHandler.prototype = {
       // No reader means request error
       this._reader.onDataAvailable(request, inputStream, offset, count);
     }
   },
 
   /**
    * @see nsISAXErrorHandler
    */
-  fatalError() {
-    cal.WARN("CalDAV: Fatal Error doing multiget for " + this.calendar.name);
+  fatalError(error) {
+    cal.WARN("CalDAV: Fatal Error doing multiget for " + this.calendar.name + ": " + error);
   },
 
   /**
    * @see nsISAXContentHandler
    */
   characters(aValue) {
     if (this.calendar.verboseLogging()) {
       this.logXML += aValue;
     }
-    this.currentResponse[this.tag] += aValue;
+    if (this.tag) {
+      this.currentResponse[this.tag] += aValue;
+    }
   },
 
   startDocument() {
     this.hrefMap = {};
     this.currentResponse = {};
     this.tag = null;
     this.logXML = "";
     this.calendar.superCalendar.startBatch();
@@ -925,17 +929,16 @@ multigetSyncHandler.prototype = {
         if (
           resp.href &&
           resp.href.length &&
           resp.status &&
           resp.status.length &&
           resp.status.indexOf(" 404") > 0
         ) {
           if (this.calendar.mHrefIndex[resp.href]) {
-            this.changeCount++;
             this.calendar.deleteTargetCalendarItem(resp.href);
           } else {
             cal.LOG("CalDAV: skipping unfound deleted item : " + resp.href);
           }
           // Created or Updated item
         } else if (
           resp.getetag &&
           resp.getetag.length &&
@@ -947,17 +950,16 @@ multigetSyncHandler.prototype = {
           let oldEtag;
           let itemId = this.calendar.mHrefIndex[resp.href];
           if (itemId) {
             oldEtag = this.calendar.mItemInfoCache[itemId].etag;
           } else {
             oldEtag = null;
           }
           if (!oldEtag || oldEtag != resp.getetag) {
-            this.changeCount++;
             this.calendar.addTargetCalendarItem(
               resp.href,
               resp.calendardata,
               this.baseUri,
               resp.getetag,
               this.listener
             );
           } else {
new file mode 100644
--- /dev/null
+++ b/calendar/providers/caldav/modules/calDavRequest.jsm
@@ -0,0 +1,1111 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+ChromeUtils.import("resource:///modules/caldav/calDavUtils.jsm");
+ChromeUtils.import("resource:///modules/caldav/calDavSession.jsm");
+
+/* exported GenericRequest, LegacySAXRequest, ItemRequest, DeleteItemRequest, PropfindRequest,
+            DAVHeaderRequest, PrincipalPropertySearchRequest, OutboxRequest, FreeBusyRequest */
+this.EXPORTED_SYMBOLS = [
+  "GenericRequest",
+  "LegacySAXRequest",
+  "ItemRequest",
+  "DeleteItemRequest",
+  "PropfindRequest",
+  "DAVHeaderRequest",
+  "PrincipalPropertySearchRequest",
+  "OutboxRequest",
+  "FreeBusyRequest",
+];
+
+const XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n';
+const MIME_TEXT_CALENDAR = "text/calendar; charset=utf-8";
+const MIME_TEXT_XML = "text/xml; charset=utf-8";
+
+/**
+ * Base class for a caldav request.
+ */
+class CalDavRequest {
+  QueryInterface(aIID) {
+    return cal.generateClassQI(this, aIID, [Ci.nsIChannelEventSink, Ci.nsIInterfaceRequestor]);
+  }
+
+  /**
+   * Creates a new base response, this should mainly be done using the subclass constructor
+   *
+   * @param {CalDavSession} aSession                  The session to use for this request
+   * @param {?calICalendar} aCalendar                 The calendar this request belongs to (can be null)
+   * @param {nsIURI} aUri                             The uri to request
+   * @param {?String} aUploadData                     The data to upload
+   * @param {?String} aContentType                    The MIME content type for the upload data
+   * @param {?Function<nsIChannel>} aOnSetupChannel   The function to call to set up the channel
+   */
+  constructor(
+    aSession,
+    aCalendar,
+    aUri,
+    aUploadData = null,
+    aContentType = null,
+    aOnSetupChannel = null
+  ) {
+    if (typeof aUploadData == "function") {
+      aOnSetupChannel = aUploadData;
+      aUploadData = null;
+      aContentType = null;
+    }
+
+    this.session = aSession;
+    this.calendar = aCalendar;
+    this.uri = aUri;
+    this.uploadData = aUploadData;
+    this.contentType = aContentType;
+    this.onSetupChannel = aOnSetupChannel;
+    this.response = null;
+    this.reset();
+  }
+
+  /**
+   * @return {Object}  The class of the response for this request
+   */
+  get responseClass() {
+    return CalDavSimpleResponse;
+  }
+
+  /**
+   * Resets the channel for this request
+   */
+  reset() {
+    this.channel = cal.provider.prepHttpChannel(this.uri, this.uploadData, this.contentType, this);
+  }
+
+  /**
+   * Retrieves the given request header. Requires the request to be committed.
+   *
+   * @param {String} aHeader      The header to retrieve
+   * @return {?String}            The requested header, or null if unavailable
+   */
+  getHeader(aHeader) {
+    try {
+      return this.response.nsirequest.getRequestHeader(aHeader);
+    } catch (e) {
+      return null;
+    }
+  }
+
+  /**
+   * Executes the request with the configuration set up in the constructor
+   *
+   * @return {Promise}        A promise that resolves with the CalDavResponse, or a subclass
+   *                            thereof based on |responseClass|
+   */
+  async commit() {
+    await this.session.prepareRequest(this.channel);
+
+    if (this.onSetupChannel) {
+      this.onSetupChannel(this.channel);
+    }
+
+    if (cal.verboseLogEnabled && this.uploadData) {
+      let method = this.channel.requestMethod;
+      cal.LOGverbose(`CalDAV: send (${method} ${this.uri.spec}): ${this.uploadData}`);
+    }
+
+    let ResponseClass = this.responseClass;
+    this.response = new ResponseClass(this);
+    this.response.lastRedirectStatus = null;
+    this.channel.asyncOpen(this.response.listener, this.channel);
+
+    await this.response.responded;
+
+    let action = await this.session.completeRequest(this.response);
+    if (action == CalDavSession.RESTART_REQUEST) {
+      this.reset();
+      return this.commit();
+    }
+
+    if (cal.verboseLogEnabled) {
+      let text = this.response.text;
+      if (text) {
+        cal.LOGverbose("CalDAV: recv: " + text);
+      }
+    }
+
+    return this.response;
+  }
+
+  /** Implement nsIInterfaceRequestor */
+  /* eslint-disable-next-line valid-jsdoc */
+  getInterface(aIID) {
+    /**
+     * Attempt to call nsIInterfaceRequestor::getInterface on the given object, and return null
+     * if it fails.
+     *
+     * @param {Object} aObj     The object to call on.
+     * @return {?*}             The requested interface object, or null.
+     */
+    function tryGetInterface(aObj) {
+      try {
+        let requestor = aObj.QueryInterface(Ci.nsIInterfaceRequestor);
+        return requestor.getInterface(aIID);
+      } catch (e) {
+        return null;
+      }
+    }
+
+    // Special case our nsIChannelEventSink, can't use tryGetInterface due to recursion errors
+    if (aIID.equals(Ci.nsIChannelEventSink)) {
+      return this.QueryInterface(Ci.nsIChannelEventSink);
+    }
+
+    // First check if the session has what we need. It may have an auth prompt implementation
+    // that should go first. Ideally we should move the auth prompt to the session anyway, but
+    // this is a task for another day (tm).
+    return tryGetInterface(this.session) || tryGetInterface(this.calendar);
+  }
+
+  /** Implement nsIChannelEventSink */
+  /* eslint-disable-next-line valid-jsdoc */
+  asyncOnChannelRedirect(aOldChannel, aNewChannel, aFlags, aCallback) {
+    /**
+     * Copy the given header from the old channel to the new one, ignoring missing headers
+     *
+     * @param {String} aHdr         The header to copy
+     */
+    function copyHeader(aHdr) {
+      try {
+        let hdrValue = aOldChannel.getRequestHeader(aHdr);
+        if (hdrValue) {
+          aNewChannel.setRequestHeader(aHdr, hdrValue, false);
+        }
+      } catch (e) {
+        if (e.result != Cr.NS_ERROR_NOT_AVAILABLE) {
+          // The header could possibly not be availible, ignore that
+          // case but throw otherwise
+          throw e;
+        }
+      }
+    }
+
+    let uploadData, uploadContent;
+    let oldUploadChannel = cal.wrapInstance(aOldChannel, Ci.nsIUploadChannel);
+    let oldHttpChannel = cal.wrapInstance(aOldChannel, Ci.nsIHttpChannel);
+    if (oldUploadChannel && oldHttpChannel && oldUploadChannel.uploadStream) {
+      uploadData = oldUploadChannel.uploadStream;
+      uploadContent = oldHttpChannel.getRequestHeader("Content-Type");
+    }
+
+    cal.provider.prepHttpChannel(null, uploadData, uploadContent, this, aNewChannel);
+
+    // Make sure we can get/set headers on both channels.
+    aNewChannel.QueryInterface(Ci.nsIHttpChannel);
+    aOldChannel.QueryInterface(Ci.nsIHttpChannel);
+
+    try {
+      this.response.lastRedirectStatus = oldHttpChannel.responseStatus;
+    } catch (e) {
+      this.response.lastRedirectStatus = null;
+    }
+
+    // If any other header is used, it should be added here. We might want
+    // to just copy all headers over to the new channel.
+    copyHeader("Depth");
+    copyHeader("Originator");
+    copyHeader("Recipient");
+    copyHeader("If-None-Match");
+    copyHeader("If-Match");
+
+    aNewChannel.requestMethod = oldHttpChannel.requestMethod;
+    this.session.prepareRedirect(aOldChannel, aNewChannel).then(() => {
+      aCallback.onRedirectVerifyCallback(Cr.NS_OK);
+    });
+  }
+}
+
+/**
+ * The caldav response base class. Should be subclassed, and works with xpcom network code that uses
+ * nsIRequest.
+ */
+class CalDavResponse {
+  /**
+   * Constructs a new caldav response
+   *
+   * @param {CalDavRequest} aRequest      The request that initiated the response
+   */
+  constructor(aRequest) {
+    this.request = aRequest;
+
+    this.responded = new Promise((resolve, reject) => {
+      this._onresponded = resolve;
+      this._onrespondederror = reject;
+    });
+    this.completed = new Promise((resolve, reject) => {
+      this._oncompleted = resolve;
+      this._oncompleteerror = reject;
+    });
+  }
+
+  /** The listener passed to the channel's asyncOpen */
+  get listener() {
+    throw Cr.NS_ERROR_NOT_IMPLEMENTED;
+  }
+
+  /** @return {nsIURI} The request URI **/
+  get uri() {
+    return this.nsirequest.URI;
+  }
+
+  /** @return {Boolean} True, if the request was redirected */
+  get redirected() {
+    return this.uri.spec != this.nsirequest.originalURI.spec;
+  }
+
+  /** @return {Number} The http response status of the request */
+  get status() {
+    try {
+      return this.nsirequest.responseStatus;
+    } catch (e) {
+      return -1;
+    }
+  }
+
+  /** The http status category, i.e. the first digit */
+  get statusCategory() {
+    return (this.status / 100) | 0;
+  }
+
+  /** If the response has a success code */
+  get ok() {
+    return this.statusCategory == 2;
+  }
+
+  /** If the response has a client error (4xx) */
+  get clientError() {
+    return this.statusCategory == 4;
+  }
+
+  /** If the response had an auth error */
+  get authError() {
+    // 403 is technically "Forbidden", but for our terms it is the same
+    return this.status == 401 || this.status == 403;
+  }
+
+  /** If the respnse has a conflict code */
+  get conflict() {
+    return this.status == 409 || this.status == 412;
+  }
+
+  /** If the response indicates the resource was not found */
+  get notFound() {
+    return this.status == 404;
+  }
+
+  /** If the response has a server error (5xx) */
+  get serverError() {
+    return this.statusCategory == 5;
+  }
+
+  /**
+   * Raise an exception if one of the handled 4xx and 5xx occured
+   */
+  raiseForStatus() {
+    if (this.authError) {
+      throw new HttpUnauthorizedError(this);
+    } else if (this.conflict) {
+      throw new HttpConflictError(this);
+    } else if (this.notFound) {
+      throw new HttpNotFoundError(this);
+    } else if (this.serverError) {
+      throw new HttpServerError(this);
+    }
+  }
+
+  /** The text response of the request */
+  get text() {
+    throw Cr.NS_ERROR_NOT_IMPLEMENTED;
+  }
+
+  /** @return {DOMDocument} A DOM document with the response xml */
+  get xml() {
+    if (this.text && !this._responseXml) {
+      try {
+        this._responseXml = cal.xml.parseString(this.text);
+      } catch (e) {
+        return null;
+      }
+    }
+
+    return this._responseXml;
+  }
+
+  /**
+   * Retrieve a request header
+   *
+   * @param {String} aHeader      The header to retrieve
+   * @return {String}             The header value
+   */
+  getHeader(aHeader) {
+    try {
+      return this.nsirequest.getResponseHeader(aHeader);
+    } catch (e) {
+      return null;
+    }
+  }
+}
+
+/**
+ * A simple caldav response using nsIStreamLoader
+ */
+class CalDavSimpleResponse extends CalDavResponse {
+  QueryInterface(aIID) {
+    return cal.generateClassQI(this, aIID, [Ci.nsIStreamLoaderObserver]);
+  }
+
+  get listener() {
+    if (!this._listener) {
+      this._listener = cal.provider.createStreamLoader();
+      this._listener.init(this);
+    }
+    return this._listener;
+  }
+
+  get text() {
+    if (!this._responseText) {
+      this._responseText = new TextDecoder().decode(Uint8Array.from(this.result)) || "";
+    }
+    return this._responseText;
+  }
+
+  /** Implement nsIStreamLoaderObserver */
+  /* eslint-disable-next-line valid-jsdoc */
+  onStreamComplete(aLoader, aContext, aStatus, aResultLength, aResult) {
+    this.resultLength = aResultLength;
+    this.result = aResult;
+
+    this.nsirequest = aLoader.request.QueryInterface(Ci.nsIHttpChannel);
+
+    if (Components.isSuccessCode(aStatus)) {
+      this._onresponded(this);
+    } else {
+      this._onrespondederror(this);
+    }
+  }
+}
+
+/**
+ * A generic request method that uses the CalDavRequest/CalDavResponse infrastructure
+ */
+class GenericRequest extends CalDavRequest {
+  /**
+   * Constructs the generic caldav request
+   *
+   * @param {CalDavSession} aSession                  The session to use for this request
+   * @param {calICalendar} aCalendar                  The calendar this request belongs to
+   * @param {String} aMethod                          The HTTP method to use
+   * @param {nsIURI} aUri                             The uri to request
+   * @param {Object} aHeaders                         An object with headers to set
+   * @param {?String} aUploadData                     Optional data to upload
+   * @param {?String} aUploadType                     Content type for upload data
+   */
+  constructor(
+    aSession,
+    aCalendar,
+    aMethod,
+    aUri,
+    aHeaders = {},
+    aUploadData = null,
+    aUploadType = null
+  ) {
+    super(aSession, aCalendar, aUri, aUploadData, aUploadType, channel => {
+      channel.requestMethod = aMethod;
+
+      for (let [name, value] of Object.entries(aHeaders)) {
+        channel.setRequestHeader(name, value, false);
+      }
+    });
+  }
+}
+
+/**
+ * Legacy request handlers request that uses an external request listener. Used for transitioning
+ * because once I started refactoring calDavRequestHandlers.js I was on the verge of refactoring the
+ * whole caldav provider. Too risky right now.
+ */
+class LegacySAXRequest extends CalDavRequest {
+  /**
+   * Constructs the legacy caldav request
+   *
+   * @param {CalDavSession} aSession                  The session to use for this request
+   * @param {calICalendar} aCalendar                  The calendar this request belongs to
+   * @param {nsIURI} aUri                             The uri to request
+   * @param {?String} aUploadData                     Optional data to upload
+   * @param {?String} aUploadType                     Content type for upload data
+   * @param {?Object} aHandler                        The external request handler
+   * @param {?Function<nsIChannel>} aOnSetupChannel   The function to call to set up the channel
+   */
+  constructor(
+    aSession,
+    aCalendar,
+    aUri,
+    aUploadData = null,
+    aUploadType = null,
+    aHandler = null,
+    aOnSetupChannel = null
+  ) {
+    super(aSession, aCalendar, aUri, aUploadData, aUploadType, aOnSetupChannel);
+    this._handler = aHandler;
+  }
+
+  /**
+   * @return {Object}  The class of the response for this request
+   */
+  get responseClass() {
+    return LegacySAXResponse;
+  }
+}
+
+/**
+ * Response class for legacy requests. Contains a listener that proxies the external handler to run
+ * the promises we use.
+ */
+class LegacySAXResponse extends CalDavResponse {
+  /** @return {nsIStreamListener} The listener passed to the channel's asyncOpen */
+  get listener() {
+    if (!this._listener) {
+      this._listener = new Proxy(this.request._handler, {
+        get: function(aTarget, aProp, aReceiver) {
+          if (aProp == "OnStartRequest") {
+            return function(...args) {
+              try {
+                let result = aTarget[aProp].apply(this, args);
+                self._onresponded();
+                return result;
+              } catch (e) {
+                self._onrespondederror(e);
+                return null;
+              }
+            };
+          } else if (aProp == "OnStopRequest") {
+            return function(...args) {
+              try {
+                let result = aTarget[aProp].apply(this, args);
+                self._oncompleted();
+                return result;
+              } catch (e) {
+                self._oncompletederror(e);
+                return null;
+              }
+            };
+          } else {
+            return Reflect.get(...arguments);
+          }
+        },
+      });
+    }
+    return this._listener;
+  }
+
+  /** @return {String} The text response of the request */
+  get text() {
+    return this.request._handler.logXML;
+  }
+}
+
+/**
+ * Upload an item to the caldav server
+ */
+class ItemRequest extends CalDavRequest {
+  /**
+   * Constructs an item request
+   *
+   * @param {CalDavSession} aSession                  The session to use for this request
+   * @param {calICalendar} aCalendar                  The calendar this request belongs to
+   * @param {nsIURI} aUri                             The uri to request
+   * @param {calIItemBase} aItem                      The item to send
+   * @param {?String} aEtag                           The etag to check. The special value "*"
+   *                                                    sets the If-None-Match header, otherwise
+   *                                                    If-Match is set to the etag.
+   */
+  constructor(aSession, aCalendar, aUri, aItem, aEtag = null) {
+    let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+      Ci.calIIcsSerializer
+    );
+    serializer.addItems([aItem], 1);
+    let serializedItem = serializer.serializeToString();
+
+    super(aSession, aCalendar, aUri, serializedItem, MIME_TEXT_CALENDAR, channel => {
+      if (aEtag == "*") {
+        channel.setRequestHeader("If-None-Match", "*", false);
+      } else if (aEtag) {
+        channel.setRequestHeader("If-Match", aEtag, false);
+      }
+    });
+  }
+
+  /**
+   * @return {Object}  The class of the response for this request
+   */
+  get responseClass() {
+    return ItemResponse;
+  }
+}
+
+/**
+ * The response for uploading an item to the server
+ */
+class ItemResponse extends CalDavSimpleResponse {
+  /** If the response has a success code */
+  get ok() {
+    // We should not accept a 201 status here indefinitely: it indicates a server error of some
+    // kind that we want to know about. It's convenient to accept it for now since a number of
+    // server impls don't get this right yet.
+    return this.status == 204 || this.status == 201 || this.status == 200;
+  }
+}
+
+/**
+ * A request for deleting an item from the server
+ */
+class DeleteItemRequest extends CalDavRequest {
+  /**
+   * Constructs an delete item request
+   *
+   * @param {CalDavSession} aSession                  The session to use for this request
+   * @param {calICalendar} aCalendar                  The calendar this request belongs to
+   * @param {nsIURI} aUri                             The uri to request
+   * @param {?String} aEtag                           The etag to check, or null to
+   *                                                    unconditionally delete
+   */
+  constructor(aSession, aCalendar, aUri, aEtag = null) {
+    super(aSession, aCalendar, aUri, channel => {
+      if (aEtag) {
+        channel.setRequestHeader("If-Match", aEtag, false);
+      }
+      channel.requestMethod = "DELETE";
+    });
+  }
+
+  /**
+   * @return {Object}  The class of the response for this request
+   */
+  get responseClass() {
+    return DeleteItemResponse;
+  }
+}
+
+/**
+ * The response class to deleting an item
+ */
+class DeleteItemResponse extends ItemResponse {
+  /** If the response has a success code */
+  get ok() {
+    // Accepting 404 as success because then the item is already deleted
+    return this.status == 204 || this.status == 200 || this.status == 404;
+  }
+}
+
+/**
+ * A dav PROPFIND request to retrieve specific properties of a dav resource
+ */
+class PropfindRequest extends CalDavRequest {
+  /**
+   * Constructs a propfind request
+   *
+   * @param {CalDavSession} aSession                  The session to use for this request
+   * @param {calICalendar} aCalendar                  The calendar this request belongs to
+   * @param {nsIURI} aUri                             The uri to request
+   * @param {String[]} aProps                         The properties to request, including
+   *                                                    namespace prefix.
+   * @param {Number} aDepth                           The depth for the request, defaults to 0
+   */
+  constructor(aSession, aCalendar, aUri, aProps, aDepth = 0) {
+    let xml =
+      XML_HEADER +
+      `<D:propfind ${tagsToXmlns("D", ...aProps)}><D:prop>` +
+      aProps.map(prop => `<${prop}/>`).join("") +
+      "</D:prop></D:propfind>";
+
+    super(aSession, aCalendar, aUri, xml, MIME_TEXT_XML, channel => {
+      channel.setRequestHeader("Depth", aDepth, false);
+      channel.requestMethod = "PROPFIND";
+    });
+
+    this.depth = aDepth;
+  }
+
+  /**
+   * @return {Object}  The class of the response for this request
+   */
+  get responseClass() {
+    return PropfindResponse;
+  }
+}
+
+/**
+ * The response for a PROPFIND request
+ */
+class PropfindResponse extends CalDavSimpleResponse {
+  get decorators() {
+    /**
+     * Retrieves the trimmed text content of the node, or null if empty
+     *
+     * @param {Element} node        The node to get the text content of
+     * @return {?String}            The text content, or null if empty
+     */
+    function textContent(node) {
+      let text = node.textContent;
+      return text ? text.trim() : null;
+    }
+
+    /**
+     * Returns an array of string with each href value within the node scope
+     *
+     * @param {Element} parent      The node to get the href values in
+     * @return {String[]}           The array with trimmed text content values
+     */
+    function href(parent) {
+      return [...parent.querySelectorAll(":scope > href")].map(node => node.textContent.trim());
+    }
+
+    /**
+     * Returns the single href value within the node scope
+     *
+     * @param {Element} node        The node to get the href value in
+     * @return {?String}            The trimmed text content
+     */
+    function singleHref(node) {
+      let hrefval = node.querySelector(":scope > href");
+      return hrefval ? hrefval.textContent.trim() : null;
+    }
+
+    /**
+     * Returns a Set with the respective element local names in the path
+     *
+     * @param {String} path         The css path to search
+     * @param {Element} parent      The parent element to search in
+     * @return {Set<String>}        A set with the element names
+     */
+    function nodeNames(path, parent) {
+      return new Set(
+        [...parent.querySelectorAll(path)].map(node => {
+          let prefix = caldavNSUnresolver(node.namespaceURI) || node.prefix;
+          return prefix + ":" + node.localName;
+        })
+      );
+    }
+
+    /**
+     * Returns a Set with the respective attribute values in the path
+     *
+     * @param {String} path         The css path to search
+     * @param {String} attribute    The attribute name to retrieve for each node
+     * @param {Element} parent      The parent element to search in
+     * @return {Set<String>}        A set with the attribute values
+     */
+    function attributeValue(path, attribute, parent) {
+      return new Set(
+        [...parent.querySelectorAll(path)].map(node => {
+          return node.getAttribute(attribute);
+        })
+      );
+    }
+
+    /**
+     * Return the result of either function a or function b, passing the node
+     *
+     * @param {Function} a      The first function to call
+     * @param {Function} b      The second function to call
+     * @param {Element} node    The node to call the functions with
+     * @return {*}              The return value of either a() or b()
+     */
+    function either(a, b, node) {
+      return a(node) || b(node);
+    }
+
+    return {
+      "D:principal-collection-set": href,
+      "C:calendar-home-set": href,
+      "C:calendar-user-address-set": href,
+      "D:current-user-principal": singleHref,
+      "D:owner": singleHref,
+      "D:supported-report-set": nodeNames.bind(null, ":scope > supported-report > report > *"),
+      "D:resourcetype": nodeNames.bind(null, ":scope > *"),
+      "C:supported-calendar-component-set": attributeValue.bind(null, ":scope > comp", "name"),
+      "C:schedule-inbox-URL": either.bind(null, singleHref, textContent),
+      "C:schedule-outbox-URL": either.bind(null, singleHref, textContent),
+    };
+  }
+  /**
+   * Quick access to the properties of the PROPFIND request. Returns an object with the hrefs as
+   * keys, and an object with the normalized properties as the value.
+   *
+   * @return {Object}    The object
+   */
+  get data() {
+    if (!this._data) {
+      this._data = {};
+      for (let response of this.xml.querySelectorAll(":scope > response")) {
+        let href = response.querySelector(":scope > href").textContent;
+        this._data[href] = {};
+
+        // This will throw 200's and 400's in one pot, but since 400's are empty that is ok
+        // for our needs.
+        for (let prop of response.querySelectorAll(":scope > propstat > prop > *")) {
+          let prefix = caldavNSUnresolver(prop.namespaceURI) || prop.prefix;
+          let qname = prefix + ":" + prop.localName;
+          if (qname in this.decorators) {
+            this._data[href][qname] = this.decorators[qname](prop) || null;
+          } else {
+            this._data[href][qname] = prop.textContent.trim() || null;
+          }
+        }
+      }
+    }
+    return this._data;
+  }
+
+  /**
+   * Shortcut for the properties of the first response, useful for depth=0
+   */
+  get firstProps() {
+    return Object.values(this.data)[0];
+  }
+
+  /** If the response has a success code */
+  get ok() {
+    return this.status == 207 && this.xml;
+  }
+}
+
+/**
+ * An OPTIONS request for retrieving the DAV header
+ */
+class DAVHeaderRequest extends CalDavRequest {
+  /**
+   * Constructs the options request
+   *
+   * @param {CalDavSession} aSession                  The session to use for this request
+   * @param {calICalendar} aCalendar                  The calendar this request belongs to
+   * @param {nsIURI} aUri                             The uri to request
+   */
+  constructor(aSession, aCalendar, aUri) {
+    super(aSession, aCalendar, aUri, channel => {
+      channel.requestMethod = "OPTIONS";
+    });
+  }
+
+  /**
+   * @return {Object}  The class of the response for this request
+   */
+  get responseClass() {
+    return DAVHeaderResponse;
+  }
+}
+
+/**
+ * The response class for the dav header request
+ */
+class DAVHeaderResponse extends CalDavSimpleResponse {
+  /**
+   * Returns a Set with the DAV features, not including the version
+   */
+  get features() {
+    if (!this._features) {
+      let dav = this.getHeader("dav") || "";
+      let features = dav.split(/,\s*/);
+      features.shift();
+      this._features = new Set(features);
+    }
+    return this._features;
+  }
+
+  /**
+   * The version from the DAV header
+   */
+  get version() {
+    let dav = this.getHeader("dav");
+    return parseInt(dav.substr(0, dav.indexOf(",")), 10);
+  }
+}
+
+/**
+ * Request class for principal-property-search queries
+ */
+class PrincipalPropertySearchRequest extends CalDavRequest {
+  /**
+   * Constructs a principal-property-search query.
+   *
+   * @param {CalDavSession} aSession                  The session to use for this request
+   * @param {calICalendar} aCalendar                  The calendar this request belongs to
+   * @param {nsIURI} aUri                             The uri to request
+   * @param {String} aMatch                           The href to search in
+   * @param {String} aSearchProp                      The property to search for
+   * @param {String[]} aProps                         The properties to retieve
+   * @param {Number} aDepth                           The depth of the query, defaults to 1
+   */
+  constructor(aSession, aCalendar, aUri, aMatch, aSearchProp, aProps, aDepth = 1) {
+    let xml =
+      XML_HEADER +
+      `<D:principal-property-search ${tagsToXmlns("D", aSearchProp, ...aProps)}>` +
+      "<D:property-search>" +
+      "<D:prop>" +
+      `<${aSearchProp}/>` +
+      "</D:prop>" +
+      `<D:match>${cal.xml.escapeString(aMatch)}</D:match>` +
+      "</D:property-search>" +
+      "<D:prop>" +
+      aProps.map(prop => `<${prop}/>`).join("") +
+      "</D:prop>" +
+      "</D:principal-property-search>";
+
+    super(aSession, aCalendar, aUri, xml, MIME_TEXT_XML, channel => {
+      channel.setRequestHeader("Depth", aDepth, false);
+      channel.requestMethod = "REPORT";
+    });
+  }
+
+  /**
+   * @return {Object}  The class of the response for this request
+   */
+  get responseClass() {
+    return PropfindResponse;
+  }
+}
+
+/**
+ * Request class for calendar outbox queries, to send or respond to invitations
+ */
+class OutboxRequest extends CalDavRequest {
+  /**
+   * Constructs an outbox request
+   *
+   * @param {CalDavSession} aSession                  The session to use for this request
+   * @param {calICalendar} aCalendar                  The calendar this request belongs to
+   * @param {nsIURI} aUri                             The uri to request
+   * @param {String} aOrganizer                       The organizer of the request
+   * @param {String} aRecipients                      The recipients of the request
+   * @param {String} aResponseMethod                  The itip resonse method, e.g. REQUEST,REPLY
+   * @param {calIItemBase} aItem                      The item to send
+   */
+  constructor(aSession, aCalendar, aUri, aOrganizer, aRecipients, aResponseMethod, aItem) {
+    let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+      Ci.calIIcsSerializer
+    );
+    serializer.addItems([aItem], 1);
+
+    let method = cal.getIcsService().createIcalProperty("METHOD");
+    method.value = aResponseMethod;
+    serializer.addProperty(method);
+
+    super(
+      aSession,
+      aCalendar,
+      aUri,
+      serializer.serializeToString(),
+      MIME_TEXT_CALENDAR,
+      channel => {
+        channel.requestMethod = "POST";
+        channel.setRequestHeader("Originator", aOrganizer, false);
+        for (let recipient of aRecipients) {
+          channel.setRequestHeader("Recipient", recipient, true);
+        }
+      }
+    );
+  }
+
+  /**
+   * @return {Object}  The class of the response for this request
+   */
+  get responseClass() {
+    return OutboxResponse;
+  }
+}
+
+/**
+ * Response class for the caldav outbox request
+ */
+class OutboxResponse extends CalDavSimpleResponse {
+  /**
+   * An object with the recipients as keys, and the request status as values
+   */
+  get data() {
+    if (!this._data) {
+      this._data = {};
+      // TODO The following queries are currently untested code, as I don't have
+      // a caldav-sched server available. If you find someone who does, please test!
+      for (let response of this.xml.querySelectorAll(":scope > response")) {
+        let recipient = response.querySelector(":scope > recipient > href").textContent;
+        let status = response.querySelector(":scope > request-status").textContent;
+        this.data[recipient] = status;
+      }
+    }
+    return this._data;
+  }
+
+  /** If the response has a success code */
+  get ok() {
+    return this.status == 200 && this.xml;
+  }
+}
+
+/**
+ * Request class for freebusy queries
+ */
+class FreeBusyRequest extends CalDavRequest {
+  /**
+   * Creates a freebusy request, for the specified range
+   *
+   * @param {CalDavSession} aSession                  The session to use for this request
+   * @param {calICalendar} aCalendar                  The calendar this request belongs to
+   * @param {nsIURI} aUri                             The uri to request
+   * @param {String} aOrganizer                       The organizer of the request
+   * @param {String} aRecipient                       The attendee to look up
+   * @param {calIDateTime} aRangeStart                The start of the range
+   * @param {calIDateTime} aRangeEnd                  The end of the range
+   */
+  constructor(aSession, aCalendar, aUri, aOrganizer, aRecipient, aRangeStart, aRangeEnd) {
+    let ics = cal.getIcsService();
+    let vcalendar = ics.createIcalComponent("VCALENDAR");
+    cal.item.setStaticProps(vcalendar);
+
+    let method = ics.createIcalProperty("METHOD");
+    method.value = "REQUEST";
+    vcalendar.addProperty(method);
+
+    let freebusy = ics.createIcalComponent("VFREEBUSY");
+    freebusy.uid = cal.getUUID();
+    freebusy.stampTime = cal.dtz.now().getInTimezone(cal.dtz.UTC);
+    freebusy.startTime = aRangeStart.getInTimezone(cal.dtz.UTC);
+    freebusy.endTime = aRangeEnd.getInTimezone(cal.dtz.UTC);
+    vcalendar.addSubcomponent(freebusy);
+
+    let organizer = ics.createIcalProperty("ORGANIZER");
+    organizer.value = aOrganizer;
+    freebusy.addProperty(organizer);
+
+    let attendee = ics.createIcalProperty("ATTENDEE");
+    attendee.setParameter("PARTSTAT", "NEEDS-ACTION");
+    attendee.setParameter("ROLE", "REQ-PARTICIPANT");
+    attendee.setParameter("CUTYPE", "INDIVIDUAL");
+    attendee.value = aRecipient;
+    freebusy.addProperty(attendee);
+
+    super(aSession, aCalendar, aUri, vcalendar.serializeToICS(), MIME_TEXT_CALENDAR, channel => {
+      channel.requestMethod = "POST";
+      channel.setRequestHeader("Originator", aOrganizer, false);
+      channel.setRequestHeader("Recipient", aRecipient, false);
+    });
+
+    this._rangeStart = aRangeStart;
+    this._rangeEnd = aRangeEnd;
+  }
+
+  /**
+   * @return {Object}  The class of the response for this request
+   */
+  get responseClass() {
+    return FreeBusyResponse;
+  }
+}
+
+/**
+ * Response class for the freebusy request
+ */
+class FreeBusyResponse extends CalDavSimpleResponse {
+  /**
+   * Quick access to the freebusy response data. An object is returned with the keys being
+   * recipients:
+   *
+   * {
+   *   "mailto:user@example.com": {
+   *     status: "HTTP/1.1 200 OK",
+   *     intervals: [
+   *       { type: "BUSY", begin: ({calIDateTime}), end: ({calIDateTime or calIDuration}) },
+   *       { type: "FREE", begin: ({calIDateTime}), end: ({calIDateTime or calIDuration}) }
+   *     ]
+   *   }
+   * }
+   */
+  get data() {
+    /**
+     * Helper to get the trimmed text content
+     *
+     * @param {Element} aParent     The parent node to search in
+     * @param {String} aPath        The css query path to serch
+     * @return {String}             The trimmed text content
+     */
+    function querySelectorText(aParent, aPath) {
+      let node = aParent.querySelector(aPath);
+      return node ? node.textContent.trim() : "";
+    }
+
+    if (!this._data) {
+      let icssvc = cal.getIcsService();
+      this._data = {};
+      for (let response of this.xml.querySelectorAll(":scope > response")) {
+        let recipient = querySelectorText(response, ":scope > recipient > href");
+        let status = querySelectorText(response, ":scope > request-status");
+        let caldata = querySelectorText(response, ":scope > calendar-data");
+        let intervals = [];
+        if (caldata) {
+          let component;
+          try {
+            component = icssvc.parseICS(caldata, null);
+          } catch (e) {
+            cal.LOG("CalDAV: Could not parse freebusy data: " + e);
+            continue;
+          }
+
+          for (let fbcomp of cal.iterate.icalComponent(component, "VFREEBUSY")) {
+            let fbstart = fbcomp.startTime;
+            if (fbstart && this.request._rangeStart.compare(fbstart) < 0) {
+              intervals.push({
+                type: "UNKNOWN",
+                begin: this.request._rangeStart,
+                end: fbstart,
+              });
+            }
+
+            for (let fbprop of cal.iterate.icalProperty(fbcomp, "FREEBUSY")) {
+              let type = fbprop.getParameter("FBTYPE");
+
+              let parts = fbprop.value.split("/");
+              let begin = cal.createDateTime(parts[0]);
+              let end;
+              if (parts[1].startsWith("P")) {
+                // this is a duration
+                end = begin.clone();
+                end.addDuration(cal.createDuration(parts[1]));
+              } else {
+                // This is a date string
+                end = cal.createDateTime(parts[1]);
+              }
+
+              intervals.push({ type, begin, end });
+            }
+
+            let fbend = fbcomp.endTime;
+            if (fbend && this.request._rangeEnd.compare(fbend) > 0) {
+              intervals.push({
+                type: "UNKNOWN",
+                begin: fbend,
+                end: this.request._rangeEnd,
+              });
+            }
+          }
+        }
+        this._data[recipient] = { status, intervals };
+      }
+    }
+    return this._data;
+  }
+
+  /**
+   * The data for the first recipient, useful if just one recipient was requested
+   */
+  get firstRecipient() {
+    return Object.values(this.data)[0];
+  }
+}
new file mode 100644
--- /dev/null
+++ b/calendar/providers/caldav/modules/calDavSession.jsm
@@ -0,0 +1,345 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ChromeUtils.import("resource://gre/modules/Timer.jsm");
+
+ChromeUtils.import("resource:///modules/OAuth2.jsm");
+
+ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * Session and authentication tools for the caldav provider
+ */
+
+this.EXPORTED_SYMBOLS = ["CalDavSession"]; /* exported CalDavSession */
+
+const OAUTH_GRACE_TIME = 30 * 1000;
+
+/**
+ * Authentication provider for Google's OAuth.
+ */
+class CalDavGoogleOAuth extends OAuth2 {
+  /**
+   * Constructs a new Google OAuth autentication provider
+   *
+   * @param {String} sessionId    The session id, used in the password manager
+   * @param {String} name         The user-readable description of this session
+   */
+  constructor(sessionId, name) {
+    super(OAUTH_BASE_URI, OAUTH_SCOPE, OAUTH_CLIENT_ID, OAUTH_HASH);
+
+    this.id = sessionId;
+    this.pwMgrId = "Google CalDAV v2";
+    this.requestWindowTitle = cal.l10n.getAnyString(
+      "global",
+      "commonDialogs",
+      "EnterUserPasswordFor2",
+      [name]
+    );
+
+    this.requestWindowFeatures = "chrome,private,centerscreen,width=430,height=600";
+  }
+
+  /**
+   * Returns true if the token has expired, or will expire within the grace time
+   */
+  get tokenExpired() {
+    let now = new Date().getTime();
+    return this.tokenExpires - OAUTH_GRACE_TIME < now;
+  }
+
+  /**
+   * Retrieves the refresh token from the password manager. The token is cached.
+   */
+  get refreshToken() {
+    if (!this._refreshToken) {
+      let pass = { value: null };
+      try {
+        let origin = "oauth:" + this.id;
+        cal.auth.passwordManagerGet(this.id, pass, origin, this.pwMgrId);
+      } catch (e) {
+        // User might have cancelled the master password prompt, thats ok
+        if (e.result != Cr.NS_ERROR_ABORT) {
+          throw e;
+        }
+      }
+      this._refreshToken = pass.value;
+    }
+    return this._refreshToken;
+  }
+
+  /**
+   * Saves the refresh token in the password manager
+   * @param {String} aVal   The value to set
+   */
+  set refreshToken(aVal) {
+    try {
+      let origin = "oauth:" + this.id;
+      if (aVal) {
+        cal.auth.passwordManagerSave(this.id, aVal, origin, this.pwMgrId);
+      } else {
+        cal.auth.passwordManagerRemove(this.id, origin, this.pwMgrId);
+      }
+    } catch (e) {
+      // User might have cancelled the master password prompt, thats ok
+      if (e.result != Cr.NS_ERROR_ABORT) {
+        throw e;
+      }
+    }
+    return (this._refreshToken = aVal);
+  }
+
+  /**
+   * Wait for the calendar window to appear.
+   *
+   * This is a workaround for bug 901329: If the calendar window isn't loaded yet the master
+   * password prompt will show just the buttons and possibly hang. If we postpone until the window
+   * is loaded, all is well.
+   *
+   * @return {Promise}    A promise resolved without value when the window is loaded
+   */
+  waitForCalendarWindow() {
+    return new Promise(resolve => {
+      // eslint-disable-next-line func-names, require-jsdoc
+      function postpone() {
+        let win = cal.window.getCalendarWindow();
+        if (!win || win.document.readyState != "complete") {
+          setTimeout(postpone, 0);
+        } else {
+          resolve();
+        }
+      }
+      setTimeout(postpone, 0);
+    });
+  }
+
+  /**
+   * Promisified version of |connect|, using all means necessary to gracefully display the
+   * authentication prompt.
+   *
+   * @param {Boolean} aWithUI       If UI should be shown for authentication
+   * @param {Boolean} aRefresh      Force refresh the token TODO default false
+   * @return {Promise}              A promise resolved when the OAuth process is completed
+   */
+  promiseConnect(aWithUI = true, aRefresh = true) {
+    return this.waitForCalendarWindow().then(() => {
+      return new Promise((resolve, reject) => {
+        let self = this;
+        let asyncprompter = Cc["@mozilla.org/messenger/msgAsyncPrompter;1"].getService(
+          Ci.nsIMsgAsyncPrompter
+        );
+        asyncprompter.queueAsyncAuthPrompt(this.id, false, {
+          onPromptStartAsync(callback) {
+            this.onPromptAuthAvailable(callback);
+          },
+
+          onPromptAuthAvailable(callback) {
+            self.connect(
+              () => {
+                if (callback) {
+                  callback.onAuthResult(true);
+                }
+                resolve();
+              },
+              () => {
+                if (callback) {
+                  callback.onAuthResult(false);
+                }
+                reject();
+              },
+              aWithUI,
+              aRefresh
+            );
+          },
+          onPromptCanceled: reject,
+          onPromptStart() {},
+        });
+      });
+    });
+  }
+
+  /**
+   * Prepare the given channel for an OAuth request
+   *
+   * @param {nsIChannel} aChannel     The channel to prepare
+   */
+  async prepareRequest(aChannel) {
+    if (!this.accessToken || this.tokenExpired) {
+      // The token has expired, we need to reauthenticate first
+      cal.LOG("CalDAV: OAuth token expired or empty, refreshing");
+      await this.promiseConnect();
+    }
+
+    let hdr = "Bearer " + this.accessToken;
+    aChannel.setRequestHeader("Authorization", hdr, false);
+  }
+
+  /**
+   * Prepare the redirect, copying the auth header to the new channel
+   *
+   * @param {nsIChannel} aOldChannel      The old channel that is being redirected
+   * @param {nsIChannel} aNewChannel      The new channel to prepare
+   */
+  async prepareRedirect(aOldChannel, aNewChannel) {
+    try {
+      let hdrValue = aOldChannel.getRequestHeader("WWW-Authenticate");
+      if (hdrValue) {
+        aNewChannel.setRequestHeader("WWW-Authenticate", hdrValue, false);
+      }
+    } catch (e) {
+      if (e.result != Cr.NS_ERROR_NOT_AVAILABLE) {
+        // The header could possibly not be availible, ignore that
+        // case but throw otherwise
+        throw e;
+      }
+    }
+  }
+
+  /**
+   * Check for OAuth auth errors and restart the request without a token if necessary
+   *
+   * @param {CalDavResponse} aResponse    The response to inspect for completion
+   * @return {Promise}                    A promise resolved when complete, with
+   *                                        CalDavSession.RESTART_REQUEST or null
+   */
+  async completeRequest(aResponse) {
+    // Check for OAuth errors
+    let wwwauth = aResponse.getHeader("WWW-Authenticate");
+    if (this.oauth && wwwauth && wwwauth.startsWith("Bearer") && wwwauth.includes("error=")) {
+      this.oauth.accessToken = null;
+
+      return CalDavSession.RESTART_REQUEST;
+    }
+    return null;
+  }
+}
+
+/**
+ * A session for the caldav provider. Two or more calendars can share a session if they have the
+ * same auth credentials.
+ */
+class CalDavSession {
+  QueryInterface(aIID) {
+    return cal.generateClassQI(this, aIID, [Ci.nsIInterfaceRequestor]);
+  }
+
+  /**
+   * Constant returned by |completeRequest| when the request should be restarted
+   * @return {Number}    The constant
+   */
+  static get RESTART_REQUEST() {
+    return 1;
+  }
+
+  /**
+   * Creates a new caldav session
+   *
+   * @param {String} aSessionId    The session id, used in the password manager
+   * @param {String} aName         The user-readable description of this session
+   */
+  constructor(aSessionId, aName) {
+    this.id = aSessionId;
+    this.name = aName;
+
+    // There is only one right now, but for better separation this is ready for more oauth hosts
+    /* eslint-disable object-curly-newline */
+    this.authAdapters = {
+      "apidata.googleusercontent.com": new CalDavGoogleOAuth(aSessionId, aName),
+    };
+    /* eslint-enable object-curly-newline */
+  }
+
+  /**
+   * Implement nsIInterfaceRequestor. The base class has no extra interfaces, but a subclass of
+   * the session may.
+   *
+   * @param {nsIIDRef} aIID       The IID of the interface being requested
+   * @return {?*}                 Either this object QI'd to the IID, or null.
+   *                                Components.returnCode is set accordingly.
+   */
+  getInterface(aIID) {
+    try {
+      // Try to query the this object for the requested interface but don't
+      // throw if it fails since that borks the network code.
+      return this.QueryInterface(aIID);
+    } catch (e) {
+      Components.returnCode = e;
+    }
+
+    return null;
+  }
+
+  /**
+   * Calls the auth adapter for the given host in case it exists. This allows delegating auth
+   * preparation based on the host, e.g. for OAuth.
+   *
+   * @param {String} aHost        The host to check the auth adapter for
+   * @param {String} aMethod      The method to call
+   * @param {...*} aArgs          Remaining args specific to the adapted method
+   * @return {*}                  Return value specific to the adapter method
+   */
+  async _callAdapter(aHost, aMethod, ...aArgs) {
+    let adapter = this.authAdapters[aHost] || null;
+    if (adapter) {
+      return adapter[aMethod](...aArgs);
+    }
+    return null;
+  }
+
+  /**
+   * Prepare the channel for a request, e.g. setting custom authentication headers
+   *
+   * @param {nsIChannel} aChannel     The channel to prepare
+   * @return {Promise}                A promise resolved when the preparations are complete
+   */
+  async prepareRequest(aChannel) {
+    return this._callAdapter(aChannel.URI.host, "prepareRequest", aChannel);
+  }
+
+  /**
+   * Prepare the given new channel for a redirect, e.g. copying headers.
+   *
+   * @param {nsIChannel} aOldChannel      The old channel that is being redirected
+   * @param {nsIChannel} aNewChannel      The new channel to prepare
+   * @return {Promise}                    A promise resolved when the preparations are complete
+   */
+  async prepareRedirect(aOldChannel, aNewChannel) {
+    return this._callAdapter(aNewChannel.URI.host, "prepareRedirect", aOldChannel, aNewChannel);
+  }
+
+  /**
+   * Complete the request based on the results from the response. Allows restarting the session if
+   * |CalDavSession.RESTART_REQUEST| is returned.
+   *
+   * @param {CalDavResponse} aResponse    The response to inspect for completion
+   * @return {Promise}                    A promise resolved when complete, with
+   *                                        CalDavSession.RESTART_REQUEST or null
+   */
+  async completeRequest(aResponse) {
+    return this._callAdapter(aResponse.request.uri.host, "completeRequest", aResponse);
+  }
+}
+
+// Before you spend time trying to find out what this means, please note that
+// doing so and using the information WILL cause Google to revoke Lightning's
+// privileges,  which means not one Lightning user will be able to connect to
+// Google Calendar via CalDAV. This will cause unhappy users all around which
+// means that the Lightning developers will have to spend more time with user
+// support, which means less time for features, releases and bugfixes.  For a
+// paid developer this would actually mean financial harm.
+//
+// Do you really want all of this to be your fault? Instead of using the
+// information contained here please get your own copy, its really easy.
+/* eslint-disable */
+// prettier-ignore
+(zqdx=>{zqdx["\x65\x76\x61\x6C"](zqdx["\x41\x72\x72\x61\x79"]["\x70\x72\x6F\x74"+
+"\x6F\x74\x79\x70\x65"]["\x6D\x61\x70"]["\x63\x61\x6C\x6C"]("uijt/PBVUI`CBTF`VS"+
+"J>#iuuqt;00bddpvout/hpphmf/dpn0p0#<uijt/PBVUI`TDPQF>#iuuqt;00xxx/hpphmfbqjt/dp"+
+"n0bvui0dbmfoebs#<uijt/PBVUI`DMJFOU`JE>#831674:95649/bqqt/hpphmfvtfsdpoufou/dpn"+
+"#<uijt/PBVUI`IBTI>#zVs7YVgyvsbguj7s8{1TTfJR#<",_=>zqdx["\x53\x74\x72\x69\x6E"+
+"\x67"]["\x66\x72\x6F\x6D\x43\x68\x61\x72\x43\x6F\x64\x65"](_["\x63\x68\x61\x72"+
+"\x43\x6F\x64\x65\x41\x74"](0)-1),this)[""+"\x6A\x6F\x69\x6E"](""))})["\x63\x61"+
+"\x6C\x6C"]((this),Components["\x75\x74\x69\x6c\x73"]["\x67\x65\x74\x47\x6c\x6f"+
+"\x62\x61\x6c\x46\x6f\x72\x4f\x62\x6a\x65\x63\x74"](this))
+/* eslint-enable */
new file mode 100644
--- /dev/null
+++ b/calendar/providers/caldav/modules/calDavUtils.jsm
@@ -0,0 +1,108 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Various utility functions for the caldav provider
+ */
+
+/* exported xmlns, tagsToXmlns, caldavNSUnresolver, caldavNSResolver, caldavXPath,
+ *          caldavXPathFirst */
+this.EXPORTED_SYMBOLS = [
+  "xmlns",
+  "tagsToXmlns",
+  "caldavNSUnresolver",
+  "caldavNSResolver",
+  "caldavXPath",
+  "caldavXPathFirst",
+];
+
+/**
+ * Creates an xmlns string with the requested namespace prefixes
+ *
+ * @param {...String} aRequested        The requested namespace prefixes
+ * @return {String}                     An xmlns string that can be inserted into xml documents
+ */
+function xmlns(...aRequested) {
+  let namespaces = [];
+  for (let namespace of aRequested) {
+    let nsUri = caldavNSResolver(namespace);
+    if (namespace) {
+      namespaces.push(`xmlns:${namespace}='${nsUri}'`);
+    }
+  }
+
+  return namespaces.join(" ");
+}
+
+/**
+ * Helper function to gather namespaces from QNames or namespace prefixes, plus a few extra for the
+ * remaining request.
+ *
+ * @param {...String} aTags     Either QNames, or just namespace prefixes to be resolved.
+ * @return {String}             The complete namespace string
+ */
+function tagsToXmlns(...aTags) {
+  let namespaces = new Set(aTags.map(tag => tag.split(":")[0]));
+  return xmlns(...namespaces.values());
+}
+
+/**
+ * Resolve the namespace URI to one of the prefixes used in our codebase
+ *
+ * @param {String} aNamespace       The namespace URI to resolve
+ * @return {?String}                The namespace prefix we use
+ */
+function caldavNSUnresolver(aNamespace) {
+  const prefixes = {
+    "http://apple.com/ns/ical/": "A",
+    "DAV:": "D",
+    "urn:ietf:params:xml:ns:caldav": "C",
+    "http://calendarserver.org/ns/": "CS",
+  };
+  return prefixes[aNamespace] || null;
+}
+
+/**
+ * Resolve the namespace URI from one of the prefixes used in our codebase
+ *
+ * @param {String} aPrefix          The namespace prefix we use
+ * @return {?String}                The namespace URI for the prefix
+ */
+function caldavNSResolver(aPrefix) {
+  /* eslint-disable id-length */
+  const namespaces = {
+    A: "http://apple.com/ns/ical/",
+    D: "DAV:",
+    C: "urn:ietf:params:xml:ns:caldav",
+    CS: "http://calendarserver.org/ns/",
+  };
+  /* eslint-enable id-length */
+
+  return namespaces[aPrefix] || null;
+}
+
+/**
+ * Run an xpath expression on the given node, using the caldav namespace resolver
+ *
+ * @param {Element} aNode           The context node to search from
+ * @param {String} aExpr            The XPath expression to search for
+ * @param {?XPathResult} aType      (optional) Force a result type, must be an XPathResult constant
+ * @return {Element[]}              Array of found elements
+ */
+function caldavXPath(aNode, aExpr, aType) {
+  return cal.xml.evalXPath(aNode, aExpr, caldavNSResolver, aType);
+}
+
+/**
+ * Run an xpath expression on the given node, using the caldav namespace resolver. Returns the first
+ * result.
+ *
+ * @param {Element} aNode           The context node to search from
+ * @param {String} aExpr            The XPath expression to search for
+ * @param {?XPathResult} aType      (optional) Force a result type, must be an XPathResult constant
+ * @return {?Element}               The found element, or null.
+ */
+function caldavXPathFirst(aNode, aExpr, aType) {
+  return cal.xml.evalXPathFirst(aNode, aExpr, caldavNSResolver, aType);
+}
--- a/calendar/providers/caldav/moz.build
+++ b/calendar/providers/caldav/moz.build
@@ -8,15 +8,21 @@ DIRS += ['public']
 EXTRA_JS_MODULES += [
     'CalDavCalendar.jsm',
 ]
 
 XPCOM_MANIFESTS += [
     'components.conf',
 ]
 
+EXTRA_JS_MODULES.caldav += [
+    'modules/calDavRequest.jsm',
+    'modules/calDavSession.jsm',
+    'modules/calDavUtils.jsm',
+]
+
 # These files go in components so they can be packaged correctly.
 FINAL_TARGET_FILES.components += [
     'calDavRequestHandlers.js',
 ]
 
 with Files('**'):
     BUG_COMPONENT = ('Calendar', 'Provider: CalDAV')
--- a/calendar/test/unit/head_consts.js
+++ b/calendar/test/unit/head_consts.js
@@ -191,36 +191,46 @@ function ics_unfoldline(aLine) {
  * `;
  *
  * @param strings       The string fragments from the template string
  * @param ...values     The interpolated values
  * @return              The interpolated, dedented string
  */
 function dedent(strings, ...values) {
   let parts = [];
-
   // Perform variable interpolation
+  let minIndent = Infinity;
   for (let [i, string] of strings.entries()) {
-    parts.push(string);
-    if (i < values.length) {
-      parts.push(values[i]);
+    let innerparts = string.split("\n");
+    if (i == 0) {
+      innerparts.shift();
+    }
+    if (i == strings.length - 1) {
+      innerparts.pop();
     }
+    for (let [j, ip] of innerparts.entries()) {
+      let match = ip.match(/^(\s*)\S*/);
+      if (j != 0) {
+        minIndent = Math.min(minIndent, match[1].length);
+      }
+    }
+    parts.push(innerparts);
   }
-  let lines = parts.join("").split("\n");
 
-  // The first and last line is empty as in above example.
-  lines.shift();
-  lines.pop();
-
-  let minIndent = lines.reduce((min, line) => {
-    let match = line.match(/^(\s*)\S*/);
-    return Math.min(min, match[1].length);
-  }, Infinity);
-
-  return lines.map(line => line.substr(minIndent)).join("\n");
+  return parts
+    .map((part, i) => {
+      return (
+        part
+          .map((line, j) => {
+            return j == 0 && i > 0 ? line : line.substr(minIndent);
+          })
+          .join("\n") + (i < values.length ? values[i] : "")
+      );
+    })
+    .join("");
 }
 
 /**
  * Read a JSON file and return the JS object
  */
 function readJSONFile(aFile) {
   let stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(Ci.nsIFileInputStream);
   try {
@@ -275,8 +285,32 @@ function do_calendar_startup(callback) {
     Services.obs.addObserver(obs, "calendar-startup-done");
     if (this._profileInitialized) {
       Services.obs.notifyObservers(null, "profile-after-change", "xpcshell-do-get-profile");
     } else {
       do_get_profile(true);
     }
   }
 }
+
+/**
+ * Monkey patch the function with the name x on obj and overwrite it with func.
+ * The first parameter of this function is the original function that can be
+ * called at any time.
+ *
+ * @param obj           The object the function is on.
+ * @param name          The string name of the function.
+ * @param func          The function to monkey patch with.
+ */
+function monkeyPatch(obj, x, func) {
+  let old = obj[x];
+  obj[x] = function() {
+    let parent = old.bind(obj);
+    let args = Array.from(arguments);
+    args.unshift(parent);
+    try {
+      return func.apply(obj, args);
+    } catch (e) {
+      Cu.reportError(e);
+      throw e;
+    }
+  };
+}
new file mode 100644
--- /dev/null
+++ b/calendar/test/unit/test_caldav_requests.js
@@ -0,0 +1,890 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+
+ChromeUtils.import("resource://testing-common/httpd.js");
+ChromeUtils.import("resource://testing-common/MockRegistrar.jsm");
+
+ChromeUtils.import("resource:///modules/caldav/calDavSession.jsm");
+ChromeUtils.import("resource:///modules/caldav/calDavRequest.jsm");
+ChromeUtils.import("resource:///modules/caldav/calDavUtils.jsm");
+
+Components.utils.importGlobalProperties(["URL"]);
+
+class LowerMap extends Map {
+  get(key) {
+    return super.get(key.toLowerCase());
+  }
+}
+
+var gServer;
+
+var MockConflictPrompt = {
+  _origFunc: null,
+  overwrite: false,
+  register: function() {
+    if (!this._origFunc) {
+      this._origFunc = cal.provider.promptOverwrite;
+      cal.provider.promptOverwrite = (aMode, aItem) => {
+        return this.overwrite;
+      };
+    }
+  },
+
+  unregister: function() {
+    if (this._origFunc) {
+      cal.provider.promptOverwrite = this._origFunc;
+      this._origFunc = null;
+    }
+  },
+};
+
+class MockAlertsService {
+  QueryInterface(aIID) {
+    return cal.generateClassQI(this, aIID, [Ci.nsIAlertsService]);
+  }
+  showAlertNotification() {}
+}
+
+function replaceAlertsService() {
+  let originalAlertsServiceCID = MockRegistrar.register(
+    "@mozilla.org/alerts-service;1",
+    MockAlertsService
+  );
+  registerCleanupFunction(() => {
+    MockRegistrar.unregister(originalAlertsServiceCID);
+  });
+}
+
+var gMockCalendar = { name: "xpcshell" };
+
+class CalDavServer {
+  constructor(calendarId) {
+    this.server = new HttpServer();
+    this.calendarId = calendarId;
+    this.session = new CalDavSession(this.calendarId, "xpcshell");
+    this.serverRequests = {};
+
+    this.server.registerPrefixHandler(
+      "/principals/",
+      this.router.bind(this, this.principals.bind(this))
+    );
+    this.server.registerPrefixHandler(
+      "/calendars/",
+      this.router.bind(this, this.calendars.bind(this))
+    );
+    this.server.registerPrefixHandler(
+      "/requests/",
+      this.router.bind(this, this.requests.bind(this))
+    );
+  }
+
+  start() {
+    this.server.start(-1);
+    registerCleanupFunction(() => this.server.stop(() => {}));
+  }
+
+  reset() {
+    this.serverRequests = {};
+  }
+
+  uri(path) {
+    let base = Services.io.newURI(`http://localhost:${this.server.identity.primaryPort}/`);
+    return Services.io.newURI(path, null, base);
+  }
+
+  router(nextHandler, request, response) {
+    try {
+      let method = request.method;
+      let parameters = new Map(request.queryString.split("&").map(part => part.split("=", 2)));
+      let available = request.bodyInputStream.available();
+      let body =
+        available > 0 ? NetUtil.readInputStreamToString(request.bodyInputStream, available) : null;
+
+      let headers = new LowerMap();
+      for (let hdr of XPCOMUtils.IterSimpleEnumerator(request.headers, Ci.nsISupportsString)) {
+        headers.set(hdr.data, request.getHeader(hdr.data));
+      }
+
+      return nextHandler(request, response, method, headers, parameters, body);
+    } catch (e) {
+      info("Server Error: " + e.fileName + ":" + e.lineNumber + ": " + e + "\n");
+      return null;
+    }
+  }
+
+  resetClient(client) {
+    MockConflictPrompt.unregister();
+    cal.getCalendarManager().unregisterCalendar(client);
+  }
+
+  waitForLoad(aCalendar) {
+    return new Promise((resolve, reject) => {
+      let observer = cal.createAdapter(Components.interfaces.calIObserver, {
+        onLoad: function() {
+          let uncached = aCalendar.wrappedJSObject.mUncachedCalendar.wrappedJSObject;
+          aCalendar.removeObserver(observer);
+
+          if (Components.isSuccessCode(uncached._lastStatus)) {
+            resolve(aCalendar);
+          } else {
+            reject(uncached._lastMessage);
+          }
+        },
+      });
+      aCalendar.addObserver(observer);
+    });
+  }
+
+  getClient() {
+    let uri = this.uri("/calendars/xpcshell/events");
+    let calmgr = cal.getCalendarManager();
+    let client = calmgr.createCalendar("caldav", uri);
+    let uclient = client.wrappedJSObject;
+    client.name = "xpcshell";
+    client.setProperty("cache.enabled", true);
+
+    // Make sure we catch the last error message in case sync fails
+    monkeyPatch(uclient, "replayChangesOn", (protofunc, aListener) => {
+      protofunc({
+        onResult: function(operation, detail) {
+          uclient._lastStatus = operation.status;
+          uclient._lastMessage = detail;
+          aListener.onResult(operation, detail);
+        },
+      });
+    });
+
+    calmgr.registerCalendar(client);
+
+    let cachedCalendar = calmgr.getCalendarById(client.id);
+    return this.waitForLoad(cachedCalendar);
+  }
+
+  principals(request, response, method, headers, parameters, body) {
+    this.serverRequests.principals = { method, headers, parameters, body };
+
+    if (method == "REPORT" && request.path == "/principals/") {
+      response.setHeader("Content-Type", "application/xml");
+      response.write(dedent`
+        <?xml version="1.0" encoding="utf-8" ?>
+        <D:multistatus xmlns:D="DAV:" xmlns:B="http://BigCorp.com/ns/">
+          <D:response>
+            <D:href>http://www.example.com/users/jdoe</D:href>
+            <D:propstat>
+              <D:prop>
+                <D:displayname>John Doe</D:displayname>
+                <B:department>Widget Sales</B:department>
+                <B:phone>234-4567</B:phone>
+                <B:office>209</B:office>
+              </D:prop>
+              <D:status>HTTP/1.1 200 OK</D:status>
+            </D:propstat>
+            <D:propstat>
+              <D:prop>
+                <B:salary/>
+              </D:prop>
+              <D:status>HTTP/1.1 403 Forbidden</D:status>
+            </D:propstat>
+          </D:response>
+          <D:response>
+            <D:href>http://www.example.com/users/zsmith</D:href>
+            <D:propstat>
+              <D:prop>
+                <D:displayname>Zygdoebert Smith</D:displayname>
+                <B:department>Gadget Sales</B:department>
+                <B:phone>234-7654</B:phone>
+                <B:office>114</B:office>
+              </D:prop>
+              <D:status>HTTP/1.1 200 OK</D:status>
+            </D:propstat>
+            <D:propstat>
+              <D:prop>
+                <B:salary/>
+              </D:prop>
+              <D:status>HTTP/1.1 403 Forbidden</D:status>
+            </D:propstat>
+          </D:response>
+        </D:multistatus>
+      `);
+      response.setStatusLine(null, 207, "Multistatus");
+    } else if (method == "PROPFIND" && request.path == "/principals/xpcshell/user/") {
+      response.setHeader("Content-Type", "application/xml");
+      response.write(dedent`
+        <?xml version="1.0" encoding="utf-8"?>
+        <D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
+          <D:response>
+            <D:href>${this.uri("/principals/xpcshell/user").spec}</D:href>
+            <D:propstat>
+              <D:prop>
+                <C:calendar-home-set>
+                  <D:href>${this.uri("/calendars/xpcshell/user/").spec}</D:href>
+                </C:calendar-home-set>
+                <C:calendar-user-address-set>
+                  <D:href>mailto:xpcshell@example.com</D:href>
+                </C:calendar-user-address-set>
+                <C:schedule-inbox-URL>
+                  <D:href>${this.uri("/calendars/xpcshell/inbox").spec}/</D:href>
+                </C:schedule-inbox-URL>
+                <C:schedule-outbox-URL>
+                  <D:href>${this.uri("/calendars/xpcshell/outbox").spec}</D:href>
+                </C:schedule-outbox-URL>
+              </D:prop>
+              <D:status>HTTP/1.1 200 OK</D:status>
+            </D:propstat>
+          </D:response>
+        </D:multistatus>
+      `);
+      response.setStatusLine(null, 207, "Multistatus");
+    }
+  }
+
+  calendars(request, response, method, headers, parameters, body) {
+    this.serverRequests.calendars = { method, headers, parameters, body };
+
+    if (
+      method == "PROPFIND" &&
+      request.path.startsWith("/calendars/xpcshell/events") &&
+      headers.get("depth") == 0
+    ) {
+      response.setHeader("Content-Type", "application/xml");
+      response.write(dedent`
+        <?xml version="1.0" encoding="utf-8" ?>
+        <D:multistatus ${xmlns("D", "C", "CS")} xmlns:R="http://www.foo.bar/boxschema/">
+          <D:response>
+            <D:href>${request.path}</D:href>
+            <D:propstat>
+              <D:prop>
+                <D:resourcetype>
+                  <D:collection/>
+                  <C:calendar/>
+                </D:resourcetype>
+                <R:plain-text-prop>hello, world</R:plain-text-prop>
+                <D:principal-collection-set>
+                  <D:href>${this.uri("/principals/").spec}</D:href>
+                  <D:href>${this.uri("/principals/subthing/").spec}</D:href>
+                </D:principal-collection-set>
+                <D:current-user-principal>
+                  <D:href>${this.uri("/principals/xpcshell/user").spec}</D:href>
+                </D:current-user-principal>
+                <D:supported-report-set>
+                  <D:supported-report>
+                    <D:report>
+                      <D:principal-property-search/>
+                    </D:report>
+                  </D:supported-report>
+                  <D:supported-report>
+                    <D:report>
+                      <C:calendar-multiget/>
+                    </D:report>
+                  </D:supported-report>
+                  <D:supported-report>
+                    <D:report>
+                      <D:sync-collection/>
+                    </D:report>
+                  </D:supported-report>
+                </D:supported-report-set>
+                <C:supported-calendar-component-set>
+                  <C:comp name="VEVENT"/>
+                  <C:comp name="VTODO"/>
+                </C:supported-calendar-component-set>
+                <C:schedule-inbox-URL>
+                  <D:href>${this.uri("/calendars/xpcshell/inbox").spec}</D:href>
+                </C:schedule-inbox-URL>
+                <C:schedule-outbox-URL>
+                  ${this.uri("/calendars/xpcshell/outbox").spec}
+                </C:schedule-outbox-URL>
+                <CS:getctag>1413647159-1007960</CS:getctag>
+              </D:prop>
+              <D:status>HTTP/1.1 200 OK</D:status>
+            </D:propstat>
+            <D:propstat>
+              <D:prop>
+                <R:obscure-thing-not-found/>
+              </D:prop>
+              <D:status>HTTP/1.1 404 Not Found</D:status>
+            </D:propstat>
+          </D:response>
+        </D:multistatus>
+      `);
+      response.setStatusLine(null, 207, "Multistatus");
+    } else if (method == "POST" && request.path == "/calendars/xpcshell/outbox") {
+      response.setHeader("Content-Type", "application/xml");
+      response.write(dedent`
+        <?xml version="1.0" encoding="utf-8" ?>
+        <C:schedule-response ${xmlns("D", "C")}>
+          <D:response>
+            <D:href>mailto:recipient1@example.com</D:href>
+            <D:request-status>2.0;Success</D:request-status>
+          </D:response>
+          <D:response>
+            <D:href>mailto:recipient2@example.com</D:href>
+            <D:request-status>2.0;Success</D:request-status>
+          </D:response>
+        </C:schedule-response>
+      `);
+      response.setStatusLine(null, 200, "OK");
+    } else if (method == "POST" && request.path == "/calendars/xpcshell/outbox2") {
+      response.setHeader("Content-Type", "application/xml");
+      response.write(dedent`
+        <?xml version="1.0" encoding="utf-8" ?>
+        <C:schedule-response ${xmlns("D", "C")}>
+          <D:response>
+            <D:recipient>
+              <D:href>mailto:recipient1@example.com</D:href>
+            </D:recipient>
+            <D:request-status>2.0;Success</D:request-status>
+            <C:calendar-data>
+        BEGIN:VCALENDAR
+        PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
+        VERSION:2.0
+        METHOD:REQUEST
+        BEGIN:VFREEBUSY
+        DTSTART;VALUE=DATE:20180102
+        DTEND;VALUE=DATE:20180126
+        ORGANIZER:mailto:xpcshell@example.com
+        ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL:mail
+          to:recipient@example.com
+        FREEBUSY;FBTYPE=FREE:20180103/20180117
+        FREEBUSY;FBTYPE=BUSY:20180118/P7D
+        END:VFREEBUSY
+        END:VCALENDAR
+            </C:calendar-data>
+          </D:response>
+        </C:schedule-response>
+      `);
+      response.setStatusLine(null, 200, "OK");
+    } else if (method == "OPTIONS" && request.path == "/calendars/xpcshell/") {
+      response.setHeader(
+        "DAV",
+        "1, 2, 3, access-control, extended-mkcol, resource-sharing, calendar-access, calendar-auto-schedule, calendar-query-extended, calendar-availability, calendarserver-sharing, inbox-availability"
+      );
+      response.setStatusLine(null, 200, "OK");
+    } else if (method == "REPORT" && request.path == "/calendars/xpcshell/events/") {
+      response.setHeader("Content-Type", "application/xml");
+      let bodydom = cal.xml.parseString(body);
+      let report = bodydom.documentElement.localName;
+      if (report == "sync-collection") {
+        response.write(dedent`
+          <?xml version="1.0" encoding="utf-8" ?>
+          <D:multistatus ${xmlns("D")}>
+            <D:response>
+              <D:href>${this.uri("/calendars/xpcshell/events/test.ics").spec}</D:href>
+              <D:propstat>
+                <D:prop>
+                  <D:getcontenttype>text/calendar; charset=utf-8; component=VEVENT</D:getcontenttype>
+                  <D:getetag>"2decee6ffb701583398996bfbdacb8eec53edf94"</D:getetag>
+                </D:prop>
+                <D:status>HTTP/1.1 200 OK</D:status>
+              </D:propstat>
+            </D:response>
+          </D:multistatus>
+        `);
+      } else if (report == "calendar-multiget") {
+        let event = cal.createEvent();
+        event.startDate = cal.dtz.now();
+        event.endDate = cal.dtz.now();
+        response.write(dedent`
+          <?xml version="1.0" encoding="utf-8"?>
+          <D:multistatus ${xmlns("D", "C")}>
+            <D:response>
+              <D:href>${this.uri("/calendars/xpcshell/events/test.ics").spec}</D:href>
+              <D:propstat>
+                <D:prop>
+                  <D:getetag>"2decee6ffb701583398996bfbdacb8eec53edf94"</D:getetag>
+                  <C:calendar-data>${event.icalString}</C:calendar-data>
+                </D:prop>
+              </D:propstat>
+            </D:response>
+          </D:multistatus>
+        `);
+      }
+      response.setStatusLine(null, 207, "Multistatus");
+    } else {
+      console.log("XXX: " + method, request.path, [...headers.entries()]);
+    }
+  }
+
+  requests(request, response, method, headers, parameters, body) {
+    // ["", "requests", "generic"] := /requests/generic
+    let parts = request.path.split("/");
+    let id = parts[2];
+    let status = parseInt(parts[3] || "", 10) || 200;
+
+    if (id == "redirected") {
+      response.setHeader("Location", "/requests/redirected-target", false);
+      status = 302;
+    } else if (id == "dav") {
+      response.setHeader("DAV", "1, calendar-schedule, calendar-auto-schedule");
+    }
+
+    this.serverRequests[id] = { method, headers, parameters, body };
+
+    for (let [hdr, value] of headers.entries()) {
+      response.setHeader(hdr, "response-" + value, false);
+    }
+
+    response.setHeader("Content-Type", "application/xml");
+    response.write(`<response id="${id}">xpc</response>`);
+    response.setStatusLine(null, status, null);
+  }
+}
+
+function run_test() {
+  Preferences.set("calendar.debug.log", true);
+  Preferences.set("calendar.debug.log.verbose", true);
+  cal.console.maxLogLevel = "debug";
+  replaceAlertsService();
+
+  // TODO: make do_calendar_startup to work with this test and replace the startup code here
+  do_get_profile();
+  do_test_pending();
+
+  cal.getCalendarManager().startup({
+    onResult: function() {
+      gServer = new CalDavServer("xpcshell@example.com");
+      gServer.start();
+      cal.getTimezoneService().startup({
+        onResult: function() {
+          run_next_test();
+          do_test_finished();
+        },
+      });
+    },
+  });
+}
+
+add_task(async function test_caldav_session() {
+  gServer.reset();
+
+  let prepared = 0;
+  let redirected = 0;
+  let completed = 0;
+  let restart = false;
+
+  gServer.session.authAdapters.localhost = {
+    async prepareRequest(aChannel) {
+      prepared++;
+    },
+
+    async prepareRedirect(aOldChannel, aNewChannel) {
+      redirected++;
+    },
+
+    async completeRequest(aResponse) {
+      completed++;
+      if (restart) {
+        restart = false;
+        return CalDavSession.RESTART_REQUEST;
+      }
+      return null;
+    },
+  };
+
+  // First a simple request
+  let uri = gServer.uri("/requests/session");
+  let request = new GenericRequest(gServer.session, gMockCalendar, "HEAD", uri);
+  await request.commit();
+
+  equal(prepared, 1);
+  equal(redirected, 0);
+  equal(completed, 1);
+
+  // Now a redirect
+  prepared = redirected = completed = 0;
+
+  uri = gServer.uri("/requests/redirected");
+  request = new GenericRequest(gServer.session, gMockCalendar, "HEAD", uri);
+  await request.commit();
+
+  equal(prepared, 1);
+  equal(redirected, 1);
+  equal(completed, 1);
+
+  // Now with restarting the request
+  prepared = redirected = completed = 0;
+  restart = true;
+
+  uri = gServer.uri("/requests/redirected");
+  request = new GenericRequest(gServer.session, gMockCalendar, "HEAD", uri);
+  await request.commit();
+
+  equal(prepared, 2);
+  equal(redirected, 2);
+  equal(completed, 2);
+});
+
+/**
+ * This test covers both GenericRequest and the base class CalDavRequest/CalDavResponse
+ */
+add_task(async function test_generic_request() {
+  gServer.reset();
+  let uri = gServer.uri("/requests/generic");
+  let headers = { "X-Hdr": "exists" };
+  let request = new GenericRequest(
+    gServer.session,
+    gMockCalendar,
+    "PUT",
+    uri,
+    headers,
+    "<body>xpc</body>",
+    "text/plain"
+  );
+
+  strictEqual(request.uri.spec, uri.spec);
+  strictEqual(request.session.id, gServer.session.id);
+  strictEqual(request.calendar, gMockCalendar);
+  strictEqual(request.uploadData, "<body>xpc</body>");
+  strictEqual(request.contentType, "text/plain");
+  strictEqual(request.response, null);
+  strictEqual(request.getHeader("X-Hdr"), null); // Only works after commit
+
+  let response = await request.commit();
+
+  ok(!!request.response);
+  equal(request.getHeader("X-Hdr"), "exists");
+
+  equal(response.uri.spec, uri.spec);
+  ok(!response.redirected);
+  equal(response.status, 200);
+  equal(response.statusCategory, 2);
+  ok(response.ok);
+  ok(!response.clientError);
+  ok(!response.conflict);
+  ok(!response.notFound);
+  ok(!response.serverError);
+  equal(response.text, '<response id="generic">xpc</response>');
+  equal(response.xml.documentElement.localName, "response");
+  equal(response.getHeader("X-Hdr"), "response-exists");
+
+  let serverResult = gServer.serverRequests.generic;
+
+  equal(serverResult.method, "PUT");
+  equal(serverResult.headers.get("x-hdr"), "exists");
+  equal(serverResult.headers.get("content-type"), "text/plain");
+  equal(serverResult.body, "<body>xpc</body>");
+});
+
+add_task(async function test_generic_redirected_request() {
+  gServer.reset();
+  let uri = gServer.uri("/requests/redirected");
+  let headers = {
+    Depth: 1,
+    Originator: "o",
+    Recipient: "r",
+    "If-None-Match": "*",
+    "If-Match": "123",
+  };
+  let request = new GenericRequest(
+    gServer.session,
+    gMockCalendar,
+    "PUT",
+    uri,
+    headers,
+    "<body>xpc</body>",
+    "text/plain"
+  );
+
+  let response = await request.commit();
+
+  ok(response.redirected);
+  equal(response.status, 200);
+  equal(response.text, '<response id="redirected-target">xpc</response>');
+  equal(response.xml.documentElement.getAttribute("id"), "redirected-target");
+
+  ok(gServer.serverRequests.redirected);
+  ok(gServer.serverRequests["redirected-target"]);
+
+  let results = gServer.serverRequests.redirected;
+  equal(results.headers.get("Depth"), 1);
+  equal(results.headers.get("Originator"), "o");
+  equal(results.headers.get("Recipient"), "r");
+  equal(results.headers.get("If-None-Match"), "*");
+  equal(results.headers.get("If-Match"), "123");
+
+  results = gServer.serverRequests["redirected-target"];
+  equal(results.headers.get("Depth"), 1);
+  equal(results.headers.get("Originator"), "o");
+  equal(results.headers.get("Recipient"), "r");
+  equal(results.headers.get("If-None-Match"), "*");
+  equal(results.headers.get("If-Match"), "123");
+
+  equal(response.lastRedirectStatus, 302);
+});
+
+add_task(async function test_item_request() {
+  gServer.reset();
+  let uri = gServer.uri("/requests/item/201");
+  let icalString = "BEGIN:VEVENT\r\nUID:123\r\nEND:VEVENT";
+  let componentString = `BEGIN:VCALENDAR\r\nPRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN\r\nVERSION:2.0\r\n${icalString}\r\nEND:VCALENDAR\r\n`;
+  let request = new ItemRequest(
+    gServer.session,
+    gMockCalendar,
+    uri,
+    cal.createEvent(icalString),
+    "*"
+  );
+  let response = await request.commit();
+
+  equal(response.status, 201);
+  ok(response.ok);
+
+  let serverResult = gServer.serverRequests.item;
+
+  equal(serverResult.method, "PUT");
+  equal(serverResult.body, componentString);
+  equal(serverResult.headers.get("If-None-Match"), "*");
+  ok(!serverResult.headers.has("If-Match"));
+
+  // Now the same with 204 No Content and an etag
+  gServer.reset();
+  uri = gServer.uri("/requests/item/204");
+  request = new ItemRequest(
+    gServer.session,
+    gMockCalendar,
+    uri,
+    cal.createEvent(icalString),
+    "123123"
+  );
+  response = await request.commit();
+
+  equal(response.status, 204);
+  ok(response.ok);
+
+  serverResult = gServer.serverRequests.item;
+
+  equal(serverResult.method, "PUT");
+  equal(serverResult.body, componentString);
+  equal(serverResult.headers.get("If-Match"), "123123");
+  ok(!serverResult.headers.has("If-None-Match"));
+
+  // Now the same with 200 OK and no etag
+  gServer.reset();
+  uri = gServer.uri("/requests/item/200");
+  request = new ItemRequest(gServer.session, gMockCalendar, uri, cal.createEvent(icalString));
+  response = await request.commit();
+
+  equal(response.status, 200);
+  ok(response.ok);
+
+  serverResult = gServer.serverRequests.item;
+
+  equal(serverResult.method, "PUT");
+  equal(serverResult.body, componentString);
+  ok(!serverResult.headers.has("If-Match"));
+  ok(!serverResult.headers.has("If-None-Match"));
+});
+
+add_task(async function test_delete_item_request() {
+  gServer.reset();
+  let uri = gServer.uri("/requests/deleteitem");
+  let request = new DeleteItemRequest(gServer.session, gMockCalendar, uri, "*");
+
+  strictEqual(request.uploadData, null);
+  strictEqual(request.contentType, null);
+
+  let response = await request.commit();
+
+  equal(response.status, 200);
+  ok(response.ok);
+
+  let serverResult = gServer.serverRequests.deleteitem;
+
+  equal(serverResult.method, "DELETE");
+  equal(serverResult.headers.get("If-Match"), "*");
+  ok(!serverResult.headers.has("If-None-Match"));
+
+  // Now the same with no etag, and a (valid) 404 response
+  gServer.reset();
+  uri = gServer.uri("/requests/deleteitem/404");
+  request = new DeleteItemRequest(gServer.session, gMockCalendar, uri);
+  response = await request.commit();
+
+  equal(response.status, 404);
+  ok(response.ok);
+
+  serverResult = gServer.serverRequests.deleteitem;
+
+  equal(serverResult.method, "DELETE");
+  ok(!serverResult.headers.has("If-Match"));
+  ok(!serverResult.headers.has("If-None-Match"));
+});
+
+add_task(async function test_propfind_request() {
+  gServer.reset();
+  let uri = gServer.uri("/calendars/xpcshell/events");
+  let props = [
+    "D:principal-collection-set",
+    "D:current-user-principal",
+    "D:supported-report-set",
+    "C:supported-calendar-component-set",
+    "C:schedule-inbox-URL",
+    "C:schedule-outbox-URL",
+    "R:obscure-thing-not-found",
+  ];
+  let request = new PropfindRequest(gServer.session, gMockCalendar, uri, props);
+  let response = await request.commit();
+
+  equal(response.status, 207);
+  ok(response.ok);
+
+  let results = gServer.serverRequests.calendars;
+
+  ok(
+    results.body.match(/<D:prop>\s*<D:principal-collection-set\/>\s*<D:current-user-principal\/>/)
+  );
+
+  equal(Object.keys(response.data).length, 1);
+  ok(!!response.data[uri.filePath]);
+  ok(!!response.firstProps);
+
+  let resprops = response.firstProps;
+
+  deepEqual(resprops["D:principal-collection-set"], [
+    gServer.uri("/principals/").spec,
+    gServer.uri("/principals/subthing/").spec,
+  ]);
+  equal(resprops["D:current-user-principal"], gServer.uri("/principals/xpcshell/user").spec);
+
+  deepEqual(
+    [...resprops["D:supported-report-set"].values()],
+    ["D:principal-property-search", "C:calendar-multiget", "D:sync-collection"]
+  );
+
+  deepEqual([...resprops["C:supported-calendar-component-set"].values()], ["VEVENT", "VTODO"]);
+  equal(resprops["C:schedule-inbox-URL"], gServer.uri("/calendars/xpcshell/inbox").spec);
+  equal(resprops["C:schedule-outbox-URL"], gServer.uri("/calendars/xpcshell/outbox").spec);
+  strictEqual(resprops["R:obscure-thing-not-found"], null);
+  equal(resprops["R:plain-text-prop"], "hello, world");
+});
+
+add_task(async function test_davheader_request() {
+  gServer.reset();
+  let uri = gServer.uri("/requests/dav");
+  let request = new DAVHeaderRequest(gServer.session, gMockCalendar, uri);
+  let response = await request.commit();
+
+  let serverResult = gServer.serverRequests.dav;
+
+  equal(serverResult.method, "OPTIONS");
+  deepEqual([...response.features], ["calendar-schedule", "calendar-auto-schedule"]);
+  strictEqual(response.version, 1);
+});
+
+add_task(async function test_propsearch_request() {
+  gServer.reset();
+  let uri = gServer.uri("/principals/");
+  let props = ["D:displayname", "B:department", "B:phone", "B:office"];
+  let request = new PrincipalPropertySearchRequest(
+    gServer.session,
+    gMockCalendar,
+    uri,
+    "doE",
+    "D:displayname",
+    props
+  );
+  let response = await request.commit();
+
+  equal(response.status, 207);
+  ok(response.ok);
+
+  equal(response.data["http://www.example.com/users/jdoe"]["D:displayname"], "John Doe");
+
+  ok(gServer.serverRequests.principals.body.includes("<D:match>doE</D:match>"));
+  ok(gServer.serverRequests.principals.body.match(/<D:prop>\s*<D:displayname\/>\s*<\/D:prop>/));
+  ok(
+    gServer.serverRequests.principals.body.match(/<D:prop>\s*<D:displayname\/>\s*<B:department\/>/)
+  );
+});
+
+add_task(async function test_outbox_request() {
+  gServer.reset();
+  let icalString = "BEGIN:VEVENT\r\nUID:123\r\nEND:VEVENT";
+  let uri = gServer.uri("/calendars/xpcshell/outbox");
+  let request = new OutboxRequest(
+    gServer.session,
+    gMockCalendar,
+    uri,
+    "xpcshell@example.com",
+    ["recipient1@example.com", "recipient2@example.com"],
+    "REPLY",
+    cal.createEvent(icalString)
+  );
+  let response = await request.commit();
+
+  equal(response.status, 200);
+  ok(response.ok);
+
+  let results = gServer.serverRequests.calendars;
+
+  ok(results.body.includes("METHOD:REPLY"));
+  equal(results.method, "POST");
+  equal(results.headers.get("Originator"), "xpcshell@example.com");
+  equal(results.headers.get("Recipient"), "recipient1@example.com, recipient2@example.com");
+});
+
+add_task(async function test_freebusy_request() {
+  gServer.reset();
+  let uri = gServer.uri("/calendars/xpcshell/outbox2");
+  let request = new FreeBusyRequest(
+    gServer.session,
+    gMockCalendar,
+    uri,
+    "mailto:xpcshell@example.com",
+    "mailto:recipient@example.com",
+    cal.createDateTime("20180101"),
+    cal.createDateTime("20180201")
+  );
+
+  let response = await request.commit();
+
+  equal(response.status, 200);
+  ok(response.ok);
+
+  let results = gServer.serverRequests.calendars;
+  equal(
+    ics_unfoldline(
+      results.body
+        .replace(/\r\n/g, "\n")
+        .replace(/(UID|DTSTAMP):[^\n]+\n/g, "")
+        .trim()
+    ),
+    dedent`
+      BEGIN:VCALENDAR
+      PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
+      VERSION:2.0
+      METHOD:REQUEST
+      BEGIN:VFREEBUSY
+      DTSTART;VALUE=DATE:20180101
+      DTEND;VALUE=DATE:20180201
+      ORGANIZER:mailto:xpcshell@example.com
+      ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL:mailto:recipient@example.com
+      END:VFREEBUSY
+      END:VCALENDAR
+    `
+  );
+  equal(results.method, "POST");
+  equal(results.headers.get("Content-Type"), "text/calendar; charset=utf-8");
+  equal(results.headers.get("Originator"), "mailto:xpcshell@example.com");
+  equal(results.headers.get("Recipient"), "mailto:recipient@example.com");
+
+  let first = response.firstRecipient;
+  strictEqual(first, response.data["mailto:recipient1@example.com"]);
+
+  equal(first.status, "2.0;Success");
+  deepEqual(first.intervals.map(interval => interval.type), ["UNKNOWN", "FREE", "BUSY", "UNKNOWN"]);
+  deepEqual(
+    first.intervals.map(interval => interval.begin.icalString + ":" + interval.end.icalString),
+    ["20180101:20180102", "20180103:20180117", "20180118:20180125", "20180126:20180201"]
+  );
+});
+
+add_task(async function test_caldav_client() {
+  let client = await gServer.getClient();
+  let pclient = cal.async.promisifyCalendar(client);
+
+  let items = await pclient.getAllItems();
+  equal(items.length, 1);
+});
--- a/calendar/test/unit/xpcshell-shared.ini
+++ b/calendar/test/unit/xpcshell-shared.ini
@@ -14,16 +14,17 @@
 [test_bug485571.js]
 [test_bug486186.js]
 [test_bug494140.js]
 [test_bug523860.js]
 [test_bug653924.js]
 [test_bug668222.js]
 [test_bug759324.js]
 [test_calmgr.js]
+[test_caldav_requests.js]
 [test_itip_utils.js]
 [test_data_bags.js]
 [test_datetime.js]
 [test_datetime_before_1970.js]
 [test_datetimeformatter.js]
 [test_deleted_items.js]
 [test_duration.js]
 [test_extract.js]