Bug 1225784 - Deal properly with incoming counter proposals;r=philipp a=philipp
authorMakeMyDay <makemyday@gmx-topmail.de>
Sun, 04 Dec 2016 13:35:52 +0100
changeset 26846 f73933c09b8406eb8e2bbd8e69ff508997c28268
parent 26845 45107f9831576c6b4944ffe88c61bcfee8104ee7
child 26847 1f54af80506e7716e6c0d223a0a4dfdc50b17ca1
push id1834
push userclokep@gmail.com
push dateMon, 23 Jan 2017 21:48:40 +0000
treeherdercomm-beta@293cffe83e59 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersphilipp, philipp
bugs1225784
Bug 1225784 - Deal properly with incoming counter proposals;r=philipp a=philipp
calendar/base/content/calendar-item-editing.js
calendar/base/modules/calItipUtils.jsm
calendar/base/modules/calUtils.jsm
calendar/base/public/calIItipItem.idl
calendar/base/src/calItipItem.js
calendar/base/src/calUtils.js
calendar/base/themes/common/dialogs/calendar-event-dialog.css
calendar/itip/calItipEmailTransport.js
calendar/lightning/components/lightningTextCalendarConverter.js
calendar/lightning/content/imip-bar-overlay.xul
calendar/lightning/content/imip-bar.js
calendar/lightning/content/lightning-item-iframe.js
calendar/lightning/content/lightning-item-iframe.xul
calendar/lightning/modules/ltnInvitationUtils.jsm
calendar/lightning/themes/linux/lightning.css
calendar/lightning/themes/osx/lightning.css
calendar/lightning/themes/windows/lightning.css
calendar/locales/en-US/chrome/calendar/calendar-event-dialog.dtd
calendar/locales/en-US/chrome/lightning/lightning.properties
calendar/test/unit/test_calitiputils.js
calendar/test/unit/test_calutils.js
calendar/test/unit/test_ltninvitationutils.js
calendar/test/unit/xpcshell-shared.ini
--- a/calendar/base/content/calendar-item-editing.js
+++ b/calendar/base/content/calendar-item-editing.js
@@ -264,17 +264,17 @@ function createEventWithDialog(calendar,
         event = cal.createEvent();
 
         let refDate = currentView().initialized && currentView().selectedDay.clone();
         setDefaultItemValues(event, calendar, startDate, endDate, refDate, aForceAllday);
         if (summary) {
             event.title = summary;
         }
     }
-    openEventDialog(event, event.calendar, "new", onNewEvent, null);
+    openEventDialog(event, event.calendar, "new", onNewEvent);
 }
 
 /**
  * Creates a task with the calendar event dialog.
  *
  * @param calendar      (optional) The calendar to create the task in
  * @param dueDate       (optional) The task's due date.
  * @param summary       (optional) The task's title.
@@ -321,18 +321,31 @@ function createTodoWithDialog(calendar, 
  * Modifies the passed event in the event dialog.
  *
  * @param aItem                 The item to modify.
  * @param job                   (optional) The job object that controls this
  *                                           modification.
  * @param aPromptOccurrence     If the user should be prompted to select if the
  *                                parent item or occurrence should be modified.
  * @param initialDate           (optional) The initial date for new task datepickers
+ * @param aCounterProposal      (optional) An object representing the counterproposal
+ *        {
+ *            {JsObject} result: {
+ *                type: {String} "OK"|"OUTDATED"|"NOTLATESTUPDATE"|"ERROR"|"NODIFF"
+ *                descr: {String} a technical description of the problem if type is ERROR or NODIFF,
+ *                                otherwise an empty string 
+ *            },
+ *            (empty if result.type = "ERROR"|"NODIFF"){Array} differences: [{
+ *                property: {String} a property that is subject to the proposal
+ *                proposed: {String} the proposed value
+ *                original: {String} the original value
+ *            }]
+ *        }
  */
-function modifyEventWithDialog(aItem, job, aPromptOccurrence, initialDate) {
+function modifyEventWithDialog(aItem, job=null, aPromptOccurrence, initialDate=null, aCounterProposal) {
     let dlg = cal.findItemWindow(aItem);
     if (dlg) {
         dlg.focus();
         disposeJob(job);
         return;
     }
 
     let onModifyItem = function(item, calendar, originalItem, listener) {
@@ -341,33 +354,36 @@ function modifyEventWithDialog(aItem, jo
 
     let item = aItem;
     let response;
     if (aPromptOccurrence !== false) {
         [item, , response] = promptOccurrenceModification(aItem, true, "edit");
     }
 
     if (item && (response || response === undefined)) {
-        openEventDialog(item, item.calendar, "modify", onModifyItem, job, initialDate);
+        openEventDialog(item, item.calendar, "modify", onModifyItem, job, initialDate,
+                        aCounterProposal);
     } else {
         disposeJob(job);
     }
 }
 
 /**
  * Opens the event dialog with the given item (task OR event)
  *
  * @param calendarItem      The item to open the dialog with
  * @param calendar          The calendar to open the dialog with.
  * @param mode              The operation the dialog should do ("new", "modify")
  * @param callback          The callback to call when the dialog has completed.
  * @param job               (optional) The job object for the modification.
  * @param initialDate       (optional) The initial date for new task datepickers
+ * @param counterProposal   (optional) An object representing the counterproposal - see
+ *                                     description for modifyEventWithDialog()
  */
-function openEventDialog(calendarItem, calendar, mode, callback, job, initialDate) {
+function openEventDialog(calendarItem, calendar, mode, callback, job=null, initialDate=null, counterProposal) {
     let dlg = cal.findItemWindow(calendarItem);
     if (dlg) {
         dlg.focus();
         disposeJob(job);
         return;
     }
 
     // Set up some defaults
@@ -433,16 +449,17 @@ function openEventDialog(calendarItem, c
     // Setup the window arguments
     let args = {};
     args.calendarEvent = calendarItem;
     args.calendar = calendar;
     args.mode = mode;
     args.onOk = callback;
     args.job = job;
     args.initialStartDateValue = initialDate || getDefaultStartDate();
+    args.counterProposal = counterProposal;
     args.inTab = Preferences.get("calendar.item.editInTab", false);
     args.useNewItemUI = Preferences.get("calendar.item.useNewItemUI", false);
 
     // this will be called if file->new has been selected from within the dialog
     args.onNewEvent = function(opcalendar) {
         createEventWithDialog(opcalendar, null, null);
     };
     args.onNewTodo = function(opcalendar) {
--- a/calendar/base/modules/calItipUtils.jsm
+++ b/calendar/base/modules/calItipUtils.jsm
@@ -75,37 +75,67 @@ cal.itip = {
                 dtstamp = item.stampTime;
             }
         }
 
         return dtstamp;
     },
 
     /**
-     * Compares sequences and/or stamps of two parties; returns -1, 0, +1.
+     * Compares sequences and/or stamps of two items
+     *
+     * @param {calIEvent|calIToDo|calIAttendee} aItem1
+     * @param {calIEvent|calIToDo|calIAttendee} aItem2
+     * @return {Integer} +1 if item2 is newer, -1 if item1 is newer or 0 if both are equal
      */
-    compare: function(item1, item2) {
-        let seq1 = cal.itip.getSequence(item1);
-        let seq2 = cal.itip.getSequence(item2);
+    compare: function(aItem1, aItem2) {
+        let comp = cal.itip.compareSequence(aItem1, aItem2);
+        if (comp == 0) {
+            comp = cal.itip.compareStamp(aItem1, aItem2);
+        }
+        return comp;
+    },
+
+    /**
+     * Compares sequences of two items
+     *
+     * @param {calIEvent|calIToDo|calIAttendee} aItem1
+     * @param {calIEvent|calIToDo|calIAttendee} aItem2
+     * @return {Integer} +1 if item2 is newer, -1 if item1 is newer or 0 if both are equal
+     */
+    compareSequence: function(aItem1, aItem2) {
+        let seq1 = cal.itip.getSequence(aItem1);
+        let seq2 = cal.itip.getSequence(aItem2);
         if (seq1 > seq2) {
             return 1;
         } else if (seq1 < seq2) {
             return -1;
         } else {
-            let st1 = cal.itip.getStamp(item1);
-            let st2 = cal.itip.getStamp(item2);
-            if (st1 && st2) {
-                return st1.compare(st2);
-            } else if (!st1 && st2) {
-                return -1;
-            } else if (st1 && !st2) {
-                return 1;
-            } else {
-                return 0;
-            }
+            return 0;
+        }
+    },
+
+    /**
+     * Compares stamp of two items
+     *
+     * @param {calIEvent|calIToDo|calIAttendee} aItem1
+     * @param {calIEvent|calIToDo|calIAttendee} aItem2
+     * @return {Integer} +1 if item2 is newer, -1 if item1 is newer or 0 if both are equal
+     */
+    compareStamp: function(aItem1, aItem2) {
+        let st1 = cal.itip.getStamp(aItem1);
+        let st2 = cal.itip.getStamp(aItem2);
+        if (st1 && st2) {
+            return st1.compare(st2);
+        } else if (!st1 && st2) {
+            return -1;
+        } else if (st1 && !st2) {
+            return 1;
+        } else {
+            return 0;
         }
     },
 
     /**
      * Checks if the given calendar is a scheduling calendar. This means it
      * needs an organizer id and an itip transport. It should also be writable.
      *
      * @param calendar    The calendar to check
@@ -122,16 +152,19 @@ cal.itip = {
      *
      * Given an nsIMsgDBHdr and an imipMethod, set up the given itip item.
      *
      * @param itipItem    The item to set up
      * @param imipMethod  The received imip method
      * @param aMsgHdr     Information about the received email
      */
     initItemFromMsgData: function(itipItem, imipMethod, aMsgHdr) {
+        // set the sender of the itip message
+        itipItem.sender = cal.itip.getMessageSender(aMsgHdr);
+
         // Get the recipient identity and save it with the itip item.
         itipItem.identity = cal.itip.getMessageRecipient(aMsgHdr);
 
         // We are only called upon receipt of an invite, so ensure that isSend
         // is false.
         itipItem.isSend = false;
 
         // XXX Get these from preferences
@@ -204,16 +237,18 @@ cal.itip = {
         }
 
         switch (method) {
             case "REFRESH": return _gs("imipBarRefreshText");
             case "REQUEST": return _gs("imipBarRequestText");
             case "PUBLISH": return _gs("imipBarPublishText");
             case "CANCEL": return _gs("imipBarCancelText");
             case "REPLY": return _gs("imipBarReplyText");
+            case "COUNTER": return _gs("imipBarCounterText");
+            case "DECLINECOUNTER": return _gs("imipBarDeclineCounterText");
             default:
                 cal.ERROR("Unknown iTIP method: " + method);
                 return _gs("imipBarUnsupportedText");
         }
     },
 
     /**
      * Scope: iTIP message receiver
@@ -237,41 +272,70 @@ cal.itip = {
             return cal.calGetString("lightning", strName, null, "lightning");
         }
         let imipLabel = null;
         if (itipItem.receivedMethod) {
             imipLabel = cal.itip.getMethodText(itipItem.receivedMethod);
         }
         let data = { label: imipLabel, buttons: [], hideMenuItems: [] };
 
+        let disallowedCounter = false;
+        if (foundItems && foundItems.length) {
+             let disallow = foundItems[0].getProperty("X-MICROSOFT-DISALLOW-COUNTER");
+             disallowedCounter = disallow && disallow == "TRUE";
+        }
         if (rc == Components.interfaces.calIErrors.CAL_IS_READONLY) {
             // No writable calendars, tell the user about it
             data.label = _gs("imipBarNotWritable");
         } else if (Components.isSuccessCode(rc) && !actionFunc) {
             // This case, they clicked on an old message that has already been
             // added/updated, we want to tell them that.
             data.label = _gs("imipBarAlreadyProcessedText");
             if (foundItems && foundItems.length) {
-                // Not a real method, but helps us decide
                 data.buttons.push("imipDetailsButton");
+                if (itipItem.receivedMethod == "COUNTER" && itipItem.sender) {
+                    if (disallowedCounter) {
+                        data.label = _gs("imipBarDisallowedCounterText");
+                    } else {
+                        let comparison;
+                        for (let item of itipItem.getItemList({})) {
+                            let attendees = cal.getAttendeesBySender(
+                                    item.getAttendees({}),
+                                    itipItem.sender
+                            );
+                            if (attendees.length == 1) {
+                                let replyer = foundItems[0].getAttendeeById(attendees[0].id);
+                                comparison = cal.itip.compareSequence(item, foundItems[0]);
+                                if (comparison == 1) {
+                                    data.label = _gs("imipBarCounterErrorText");
+                                    break;
+                                } else if (comparison == -1) {
+                                    data.label = _gs("imipBarCounterPreviousVersionText");
+                                }
+                            }
+                        }
+                    }
+                }
             } else if (itipItem.receivedMethod == "REPLY") {
                 // The item has been previously removed from the available calendars or the calendar
                 // containing the item is not available
                 let delmgr = Components.classes["@mozilla.org/calendar/deleted-items-manager;1"]
                                        .getService(Components.interfaces.calIDeletedItems);
                 let delTime = null;
                 let items = itipItem.getItemList({});
                 if (items && items.length) {
                     delTime = delmgr.getDeletedDate(items[0].id);
                 }
                 if (delTime) {
                     data.label = _gs("imipBarReplyToRecentlyRemovedItem", [delTime.toString()]);
                 } else {
                     data.label = _gs("imipBarReplyToNotExistingItem");
                 }
+            } else if (itipItem.receivedMethod == "DECLINECOUNTER") {
+                data.label = _gs("imipBarDeclineCounterText");
             }
         } else if (Components.isSuccessCode(rc)) {
             cal.LOG("iTIP options on: " + actionFunc.method);
             switch (actionFunc.method) {
                 case "PUBLISH:UPDATE":
                 case "REQUEST:UPDATE-MINOR":
                     data.label = _gs("imipBarUpdateText");
                     // falls through
@@ -314,29 +378,55 @@ cal.itip = {
                 case "CANCEL": {
                     data.buttons.push("imipDeleteButton");
                     break;
                 }
                 case "REFRESH": {
                     data.buttons.push("imipReconfirmButton");
                     break;
                 }
+                case "COUNTER": {
+                    if (disallowedCounter) {
+                        data.label = _gs("imipBarDisallowedCounterText");
+                    }
+                    data.buttons.push("imipDeclineCounterButton");
+                    data.buttons.push("imipRescheduleButton");
+                    break;
+                }
                 default:
                     data.label = _gs("imipBarUnsupportedText");
                     break;
             }
         } else {
             data.label = _gs("imipBarUnsupportedText");
         }
 
         return data;
     },
 
     /**
      * Scope: iTIP message receiver
+     * Retrieves the message sender.
+     *
+     * @param {nsIMsgHdr} aMsgHdr     The message header to check.
+     * @return                        The email address of the intended recipient.
+     */
+    getMessageSender: function(aMsgHdr) {
+        let author = (aMsgHdr && aMsgHdr.author) || "";
+        let compFields = Components.classes["@mozilla.org/messengercompose/composefields;1"]
+                                   .createInstance(Components.interfaces.nsIMsgCompFields);
+        let addresses = compFields.splitRecipients(author, true, {});
+        if (addresses.length != 1) {
+            cal.LOG("No unique email address for lookup in message.\r\n" + cal.STACK(20));
+        }
+        return addresses[0] || null;
+    },
+
+    /**
+     * Scope: iTIP message receiver
      *
      * Retrieves the intended recipient for this message.
      *
      * @param aMsgHdr     The message to check.
      * @return            The email of the intended recipient.
      */
     getMessageRecipient: function(aMsgHdr) {
         if (!aMsgHdr) {
@@ -420,16 +510,18 @@ cal.itip = {
         switch (aMethod) {
             // methods that don't require the calendar chooser:
             case "REFRESH":
             case "REQUEST:UPDATE":
             case "REQUEST:UPDATE-MINOR":
             case "PUBLISH:UPDATE":
             case "REPLY":
             case "CANCEL":
+            case "COUNTER":
+            case "DECLINECOUNTER":
                 needsCalendar = false;
                 break;
             default:
                 needsCalendar = true;
                 break;
         }
 
         if (needsCalendar) {
@@ -500,23 +592,27 @@ cal.itip = {
      *                    * REFRESH -- send the latest item (sent by attendee(s))
      *                    * PUBLISH -- initial publish, no reply (sent by organizer)
      *                    * PUBLISH:UPDATE -- update of a published item (sent by organizer)
      *                    * REQUEST -- initial invitation (sent by organizer)
      *                    * REQUEST:UPDATE -- rescheduling invitation, has major change (sent by organizer)
      *                    * REQUEST:UPDATE-MINOR -- update of invitation, minor change (sent by organizer)
      *                    * REPLY -- invitation reply (sent by attendee(s))
      *                    * CANCEL -- invitation cancel (sent by organizer)
+     *                    * COUNTER -- counterproposal (sent by attendee)
+     *                    * DECLINECOUNTER -- denial of a counterproposal (sent by organizer)
      */
     processItipItem: function(itipItem, optionsFunc) {
         switch (itipItem.receivedMethod.toUpperCase()) {
             case "REFRESH":
             case "PUBLISH":
             case "REQUEST":
             case "CANCEL":
+            case "COUNTER":
+            case "DECLINECOUNTER":
             case "REPLY": {
                 // Per iTIP spec (new Draft 4), multiple items in an iTIP message MUST have
                 // same ID, this simplifies our searching, we can just look for Item[0].id
                 let itemList = itipItem.getItemList({});
                 if (!itipItem.targetCalendar) {
                     optionsFunc(itipItem, Components.interfaces.calIErrors.CAL_IS_READONLY);
                 } else if (itemList.length > 0) {
                     ItipItemFinderFactory.findItem(itemList[0].id, itipItem, optionsFunc);
@@ -826,19 +922,20 @@ cal.itip = {
 
         return newItem;
     },
 
     /**
      * Returns a copy of an itipItem with modified properties and items build from scratch
      * Use itipItem.clone() instead if only a simple copy is required
      *
-     * @param aItipItem     ItipItem to derive a new one from
-     * @param aItems        List of items to be contained in the new itipItem
-     * @param aProps        List of properties to be different in the new itipItem
+     * @param  {calIItipItem} aItipItem  ItipItem to derive a new one from
+     * @param  {Array}        aItems     calIEvent or calITodo items to be contained in the new itipItem
+     * @param  {JsObject}     aProps     Properties to be different in the new itipItem
+     * @return {calIItipItem}
      */
     getModifiedItipItem: function(aItipItem, aItems=[], aProps={}) {
         let itipItem = Components.classes["@mozilla.org/calendar/itip-item;1"]
                                  .createInstance(Components.interfaces.calIItipItem);
         let serializedItems = "";
         for (let item of aItems) {
             serializedItems += cal.getSerializedItem(item);
         }
@@ -848,16 +945,30 @@ cal.itip = {
         itipItem.identity = ("identity" in aProps) ? aProps.identity : aItipItem.identity;
         itipItem.isSend = ("isSend" in aProps) ? aProps.isSend : aItipItem.isSend;
         itipItem.localStatus = ("localStatus" in aProps) ? aProps.localStatus : aItipItem.localStatus;
         itipItem.receivedMethod = ("receivedMethod" in aProps) ? aProps.receivedMethod : aItipItem.receivedMethod;
         itipItem.responseMethod = ("responseMethod" in aProps) ? aProps.responseMethod : aItipItem.responseMethod;
         itipItem.targetCalendar = ("targetCalendar" in aProps) ? aProps.targetCalendar : aItipItem.targetCalendar;
 
         return itipItem;
+    },
+
+    /**
+     * A shortcut to send DECLINECOUNTER messages - for everything else use cal.itip.checkAndSend
+     *
+     * @param aItem iTIP item to be sent
+     * @param aMethod iTIP method
+     * @param aRecipientsList an array of calIAttendee objects the message should be sent to
+     * @param aAutoResponse an inout object whether the transport should ask before sending
+     */
+    sendDeclineCounterMessage: function(aItem, aMethod, aRecipientsList, aAutoResponse) {
+        if (aMethod == "DECLINECOUNTER") {
+            return sendMessage(aItem, aMethod, aRecipientsList, aAutoResponse);
+        }
     }
 };
 
 /** local to this module file
  * Sets the received info either on the passed attendee or item object.
  *
  * @param item either  calIAttendee or calIItemBase
  * @param itipItemItem received iTIP item
@@ -999,17 +1110,17 @@ function createOrganizer(aCalendar) {
  */
 function sendMessage(aItem, aMethod, aRecipientsList, autoResponse) {
     if (aRecipientsList.length == 0) {
         return false;
     }
     let calendar = cal.wrapInstance(aItem.calendar, Components.interfaces.calISchedulingSupport);
     if (calendar) {
         if (calendar.QueryInterface(Components.interfaces.calISchedulingSupport)
-                          .canNotify(aMethod, aItem)) {
+                    .canNotify(aMethod, aItem)) {
             // provider will handle that, so we return - we leave it also to the provider to
             // deal with user canceled notifications (if possible), so set the return value
             // to true as false would prevent any further notification within this cycle
             return true;
         }
     }
 
     let aTransport = aItem.calendar.getProperty("itip.transport");
@@ -1208,17 +1319,17 @@ ItipItemFinder.prototype = {
                     } else {
                         this.mFoundItems.splice(idx, 1);
                     }
                     found = true;
                     break;
                 }
             }
 
-            // If it hasn't been found and there isto add a item, add it to the end
+            // If it hasn't been found and there is to add a item, add it to the end
             if (!found && aNewItem) {
                 this.mFoundItems.push(aNewItem);
             }
             this.processFoundItems();
         }
     },
 
     onAddItem: function(aItem) {
@@ -1258,16 +1369,18 @@ ItipItemFinder.prototype = {
                 //           implicitly on the parent (ics/memory/storage calls modifyException),
                 //           the generation check will fail. We should really consider to allow
                 //           deletion/modification/addition of occurrences directly on the providers,
                 //           which would ease client code a lot.
                 case "REFRESH":
                 case "PUBLISH":
                 case "REQUEST":
                 case "REPLY":
+                case "COUNTER":
+                case "DECLINECOUNTER":
                     for (let itipItemItem of this.mItipItem.getItemList({})) {
                         for (let item of this.mFoundItems) {
                             let rid = itipItemItem.recurrenceId; //  XXX todo support multiple
                             if (rid) { // actually applies to individual occurrence(s)
                                 if (item.recurrenceInfo) {
                                     item = item.recurrenceInfo.getOccurrenceFor(rid);
                                     if (!item) {
                                         continue;
@@ -1275,17 +1388,18 @@ ItipItemFinder.prototype = {
                                 } else { // the item has been rescheduled with master:
                                     itipItemItem = itipItemItem.parentItem;
                                 }
                             }
 
                             switch (method) {
                                 case "REFRESH": { // xxx todo test
                                     let attendees = itipItemItem.getAttendees({});
-                                    cal.ASSERT(attendees.length == 1, "invalid number of attendees in REFRESH!");
+                                    cal.ASSERT(attendees.length == 1,
+                                               "invalid number of attendees in REFRESH!");
                                     if (attendees.length > 0) {
                                         let action = function(opListener) {
                                             if (!item.organizer) {
                                                 let org = createOrganizer(item.calendar);
                                                 if (org) {
                                                     item = item.clone();
                                                     item.organizer = org;
                                                 }
@@ -1363,35 +1477,75 @@ ItipItemFinder.prototype = {
                                                 newItem.addAttendee(att);
                                                 return newItem.calendar.modifyItem(
                                                     newItem, item, new ItipOpListener(opListener, item));
                                             });
                                         }
                                     }
                                     break;
                                 }
+                                case "DECLINECOUNTER":
+                                    // nothing to do right now, but once countering is implemented,
+                                    // we probably need some action here to remove the proposal from
+                                    // the countering attendee's calendar
+                                    break;
+                                case "COUNTER":
                                 case "REPLY": {
                                     let attendees = itipItemItem.getAttendees({});
-                                    cal.ASSERT(attendees.length == 1, "invalid number of attendees in REPLY!");
-                                    if (attendees.length > 0 &&
-                                        (item.calendar.getProperty("itip.disableRevisionChecks") ||
-                                         (cal.itip.compare(itipItemItem, item.getAttendeeById(attendees[0].id)) > 0))) {
-                                        // accepts REPLYs from previously uninvited attendees:
+                                    if (method == "REPLY") {
+                                        cal.ASSERT(
+                                            attendees.length == 1,
+                                            "invalid number of attendees in REPLY!"
+                                        );
+                                    } else {
+                                        attendees = cal.getAttendeesBySender(
+                                            attendees,
+                                            this.mItipItem.sender
+                                        );
+                                        cal.ASSERT(
+                                            attendees.length == 1,
+                                            "ambiguous resolution of replying attendee in COUNTER!"
+                                        );
+                                    }
+                                    // we get the attendee from the event stored in the calendar
+                                    let replyer = item.getAttendeeById(attendees[0].id);
+                                    if (!replyer && method == "REPLY") {
+                                        // We accepts REPLYs also from previously uninvited
+                                        // attendees, so we always have one for REPLY
+                                        replyer = attendees[0];
+                                    }
+                                    let noCheck = item.calendar.getProperty(
+                                        "itip.disableRevisionChecks");
+                                    let revCheck = false;
+                                    if (replyer && !noCheck) {
+                                        revCheck = cal.itip.compare(itipItemItem, replyer) > 0;
+                                        if (revCheck && method == "COUNTER") {
+                                            revCheck = cal.itip.compareSequence(itipItemItem, item) == 0;
+                                        }
+                                    }
+
+                                    if (replyer && (noCheck || revCheck)) {
                                         let newItem = item.clone();
-                                        let att = item.getAttendeeById(attendees[0].id) || attendees[0];
-                                        newItem.removeAttendee(att);
-                                        att = att.clone();
-                                        setReceivedInfo(att, itipItemItem);
-                                        att.participationStatus = attendees[0].participationStatus;
-                                        newItem.addAttendee(att);
+                                        newItem.removeAttendee(replyer);
+                                        replyer = replyer.clone();
+                                        setReceivedInfo(replyer, itipItemItem);
+                                        let newPS = itipItemItem.getAttendeeById(replyer.id)
+                                                                .participationStatus;
+                                        replyer.participationStatus = newPS;
+                                        newItem.addAttendee(replyer);
 
                                         // Make sure the provider-specified properties are copied over
                                         copyProviderProperties(this.mItipItem, itipItemItem, newItem);
 
                                         let action = function(opListener) {
+                                            // n.b.: this will only be processed in case of reply or
+                                            // declining the counter request - of sending the
+                                            // appropriate reply will be taken care within the
+                                            // opListener (defined in imip-bar.js)
+                                            // TODO: move that from imip-bar.js to here
                                             return newItem.calendar.modifyItem(
                                                 newItem, item,
                                                 newItem.calendar.getProperty("itip.notify-replies")
                                                 ? new ItipOpListener(opListener, item)
                                                 : opListener);
                                         };
                                         operations.push(action);
                                     }
@@ -1479,16 +1633,17 @@ ItipItemFinder.prototype = {
                                                             ? new ItipOpListener(opListener, null)
                                                             : opListener);
                         };
                         operations.push(action);
                         break;
                     }
                     case "CANCEL": // has already been processed
                     case "REPLY": // item has been previously removed from the calendar
+                    case "COUNTER": // the item has been previously removed form the calendar
                         break;
                     default:
                         rc = Components.results.NS_ERROR_NOT_IMPLEMENTED;
                         break;
                 }
             }
         }
 
--- a/calendar/base/modules/calUtils.jsm
+++ b/calendar/base/modules/calUtils.jsm
@@ -390,16 +390,43 @@ var cal = {
         let calendar = cal.wrapInstance(aCalendar, Components.interfaces.calISchedulingSupport);
         if (calendar) {
             invitedAttendee = calendar.getInvitedAttendee(aItem);
         }
         return invitedAttendee;
     },
 
     /**
+     * Returns all attendees from given set of attendees matching based on the attendee id
+     * or a sent-by parameter compared to the specified email address
+     *
+     * @param  {Array}  aAttendees      An array of calIAttendee objects
+     * @param  {String} aEmailAddress   A string containing the email address for lookup
+     * @return {Array}                  Returns an array of matching attendees
+     */
+    getAttendeesBySender: function(aAttendees, aEmailAddress) {
+        let attendees = [];
+        // we extract the email address to make it work also for a raw header value
+        let compFields = Components.classes["@mozilla.org/messengercompose/composefields;1"]
+                                   .createInstance(Components.interfaces.nsIMsgCompFields);
+        let addresses = compFields.splitRecipients(aEmailAddress, true, {});
+        if (addresses.length == 1) {
+            let searchFor = cal.prependMailTo(addresses[0]);
+            aAttendees.forEach(aAttendee => {
+                if ([aAttendee.id, aAttendee.getProperty("SENT-BY")].includes(searchFor)) {
+                    attendees.push(aAttendee);
+                }
+            });
+        } else {
+            cal.WARN("No unique email address for lookup!");
+        }
+        return attendees;
+    },
+
+    /**
      * Returns a wellformed email string like 'attendee@example.net',
      * 'Common Name <attendee@example.net>' or '"Name, Common" <attendee@example.net>'
      *
      * @param  {calIAttendee}  aAttendee - the attendee to check
      * @param  {boolean}       aIncludeCn - whether or not to return also the CN if available
      * @return {string}        valid email string or an empty string in case of error
      */
     getAttendeeEmail: function(aAttendee, aIncludeCn) {
--- a/calendar/base/public/calIItipItem.idl
+++ b/calendar/base/public/calIItipItem.idl
@@ -9,17 +9,17 @@ interface calIItemBase;
 interface calICalendar;
 interface nsISimpleEnumerator;
 
 /** 
  * calIItipItem is an interface used to carry information between the mime
  * parser, the imip-bar UI, and the iTIP processor. It encapsulates a list of
  * calIItemBase objects and provides specialized iTIP methods for those items.
  */
-[scriptable, uuid(f41392ab-dcad-4bad-818f-b3d1631c4d93)]
+[scriptable, uuid(7539c158-c30d-41d0-90e9-41d315ac3eb1)]
 interface calIItipItem : nsISupports
 {
     /**
      * Initializes the item with an ics string
      * @param - in parameter - AString of ical Data
      */
     void init(in  AUTF8String icalData);
 
@@ -35,16 +35,22 @@ interface calIItipItem : nsISupports
      * the item directly to the email subsystem so that communication can be
      * initiated. For example, if you are Sending a REQUEST, you would set
      * this flag, and send the iTIP Item into the iTIP processor, which would
      * handle everything else.
      */
     attribute boolean isSend;
 
     /**
+     * Attribute: sender - set to the email address of the sender if part of an
+     * iMIP communication.
+     */
+    attribute AUTF8String sender;
+
+    /**
      * Attribute: receivedMethod - method the iTIP item had upon reciept
      */
     attribute AUTF8String receivedMethod;
 
     /**
      * Attribute: responseMethod - method that the protocol handler (or the
      * user) decides to use to respond to the iTIP item (could be COUNTER,
      * REPLY, DECLINECOUNTER, etc)
--- a/calendar/base/src/calItipItem.js
+++ b/calendar/base/src/calItipItem.js
@@ -23,16 +23,24 @@ calItipItem.prototype = {
     QueryInterface: XPCOMUtils.generateQI(calItipItemInterfaces),
     classInfo: XPCOMUtils.generateCI({
         classID: calItipItemClassID,
         contractID: "@mozilla.org/calendar/itip-item;1",
         classDescription: "Calendar iTIP item",
         interfaces: calItipItemInterfaces
     }),
 
+    mSender: null,
+    get sender() {
+        return this.mSender;
+    },
+    set sender(aValue) {
+        return (this.mSender = aValue);
+    },
+
     mIsSend: false,
     get isSend() {
         return this.mIsSend;
     },
     set isSend(aValue) {
         return (this.mIsSend = aValue);
     },
 
@@ -159,16 +167,17 @@ calItipItem.prototype = {
         let newItem = new calItipItem();
         newItem.mItemList = this.mItemList.map(item => item.clone());
         newItem.mReceivedMethod = this.mReceivedMethod;
         newItem.mResponseMethod = this.mResponseMethod;
         newItem.mAutoResponse = this.mAutoResponse;
         newItem.mTargetCalendar = this.mTargetCalendar;
         newItem.mIdentity = this.mIdentity;
         newItem.mLocalStatus = this.mLocalStatus;
+        newItem.mSender = this.mSender;
         newItem.mIsSend = this.mIsSend;
         newItem.mIsInitialized = this.mIsInitialized;
         return newItem;
     },
 
     /**
      * This returns both the array and the number of items. An easy way to
      * call it is: let itemArray = itipItem.getItemList({ });
--- a/calendar/base/src/calUtils.js
+++ b/calendar/base/src/calUtils.js
@@ -1693,17 +1693,17 @@ function calIterateEmailIdentities(func)
  * @param aSecondItem       The item to compare to.
  * @param aIgnoreProps      (optional) An array of parameters to ignore.
  * @param aIgnoreParams     (optional) An object describing which parameters to
  *                                     ignore.
  * @return                  True, if items match.
  */
 function compareItemContent(aFirstItem, aSecondItem, aIgnoreProps, aIgnoreParams) {
     let ignoreProps = arr2hash(aIgnoreProps ||
-        ["SEQUENCE", "DTSTAMP", "LAST-MODIFIED", "X-MOZ-GENERATION",
+        ["SEQUENCE", "DTSTAMP", "LAST-MODIFIED", "X-MOZ-GENERATION", "X-MICROSOFT-DISALLOW-COUNTER",
          "X-MOZ-SEND-INVITATIONS", "X-MOZ-SEND-INVITATIONS-UNDISCLOSED"]);
 
     let ignoreParams = aIgnoreParams ||
         { ATTENDEE: ["CN"], ORGANIZER: ["CN"] };
     for (let x in ignoreParams) {
         ignoreParams[x] = arr2hash(ignoreParams[x]);
     }
 
--- a/calendar/base/themes/common/dialogs/calendar-event-dialog.css
+++ b/calendar/base/themes/common/dialogs/calendar-event-dialog.css
@@ -29,16 +29,37 @@ dialog[systemcolors] {
 
 #calendar-event-dialog .todo-only,
 #calendar-task-dialog .event-only,
 #calendar-event-dialog-inner .todo-only,
 #calendar-task-dialog-inner .event-only {
     display: none;
 }
 
+/*--------------------------------------------------------------------
+ *   Event dialog counter box section
+ *-------------------------------------------------------------------*/
+
+#counter-proposal-box {
+    background-color: rgb(186, 238, 255);
+    border-bottom: 1px solid #444444;
+}
+
+#counter-proposal-property-values > description {
+    margin-bottom: 2px;
+}
+
+#counter-proposal-summary {
+    font-weight: bold;
+}
+
+.counter-buttons {
+    max-height: 25px;
+}
+
 #yearly-period-of-label,
 label.label {
     text-align: right;
 }
 
 #item-calendar,
 #item-categories,
 #item-repeat,
--- a/calendar/itip/calItipEmailTransport.js
+++ b/calendar/itip/calItipEmailTransport.js
@@ -107,21 +107,36 @@ calItipEmailTransport.prototype = {
                 body = cal.calGetString(
                     "lightning",
                     "itipCancelBody",
                     [item.organizer ? item.organizer.toString() : "", summary],
                     "lightning"
                 );
                 break;
             }
+            case "DECLINECOUNTER": {
+                subject = cal.calGetString(
+                    "lightning",
+                    "itipDeclineCounterSubject",
+                    [summary],
+                    "lightning"
+                );
+                body = cal.calGetString(
+                    "lightning",
+                    "itipDeclineCounterBody",
+                    [item.organizer ? item.organizer.toString() : "", summary],
+                    "lightning"
+                );
+                break;
+            }
             case "REPLY": {
                 // Get my participation status
                 let att = cal.getInvitedAttendee(item, aItipItem.targetCalendar);
                 if (!att && aItipItem.identity) {
-                    att = item.getAttendeeById("mailto:" + aItipItem.identity);
+                    att = item.getAttendeeById(cal.prependMailTo(aItipItem.identity));
                 }
                 if (!att) { // should not happen anymore
                     return false;
                 }
 
                 // work around BUG 351589, the below just removes RSVP:
                 aItipItem.setAttendeeStatus(att.id, att.participationStatus);
                 let myPartStat = att.participationStatus;
@@ -303,17 +318,17 @@ calItipEmailTransport.prototype = {
                                             .createInstance(Components.interfaces.nsIMsgSend);
                     msgSend.sendMessageFile(identity,
                                             account.key,
                                             composeFields,
                                             mailFile,
                                             true  /* deleteSendFileOnCompletion */,
                                             false /* digest_p */,
                                             (Services.io.offline ? Components.interfaces.nsIMsgSend.nsMsgQueueForLater
-                                                                 : Components.interfaces.nsIMsgSend.nsMsgDeliverNow),
+                                                    : Components.interfaces.nsIMsgSend.nsMsgDeliverNow),
                                             null  /* nsIMsgDBHdr msgToReplace */,
                                             null  /* nsIMsgSendListener aListener */,
                                             null  /* nsIMsgStatusFeedback aStatusFeedback */,
                                             ""    /* password */);
                     return true;
                 }
                 break;
             }
--- a/calendar/lightning/components/lightningTextCalendarConverter.js
+++ b/calendar/lightning/components/lightningTextCalendarConverter.js
@@ -48,47 +48,43 @@ ltnMimeConverter.prototype = {
             }
         }
         if (!event) {
             return "";
         }
 
         let itipItem = null;
         let msgOverlay = "";
+        let msgWindow = null;
 
-        try {
-            itipItem = Components.classes["@mozilla.org/calendar/itip-item;1"]
-                                 .createInstance(Components.interfaces.calIItipItem);
-            itipItem.init(data);
+        itipItem = Components.classes["@mozilla.org/calendar/itip-item;1"]
+                             .createInstance(Components.interfaces.calIItipItem);
+        itipItem.init(data);
+
+        // this.uri is the message URL that we are processing.
+        // We use it to get the nsMsgHeaderSink to store the calItipItem.
+        if (this.uri) {
+            try {
+                let msgUrl = this.uri.QueryInterface(Components.interfaces.nsIMsgMailNewsUrl);
+                msgWindow = msgUrl.msgWindow;
+                itipItem.sender = msgUrl.mimeHeaders.extractHeader("From", false);
+            } catch (exc) {
+                // msgWindow is optional in some scenarios
+                // (e.g. gloda in action, throws NS_ERROR_INVALID_POINTER then)
+            }
+        }
+
+        if (msgWindow) {
             let dom = ltn.invitation.createInvitationOverlay(event, itipItem);
             msgOverlay = cal.xml.serializeDOM(dom);
 
-            // this.uri is the message URL that we are processing.
-            // We use it to get the nsMsgHeaderSink to store the calItipItem.
-            if (this.uri) {
-                let msgWindow = null;
-                try {
-                    let msgUrl = this.uri.QueryInterface(Components.interfaces.nsIMsgMailNewsUrl);
-                    msgWindow = msgUrl.msgWindow;
-                } catch (exc) {
-                    // msgWindow is optional in some scenarios
-                    // (e.g. gloda in action, throws NS_ERROR_INVALID_POINTER then)
-                }
+            let sinkProps = msgWindow.msgHeaderSink.properties;
+            sinkProps.setPropertyAsInterface("itipItem", itipItem);
+            sinkProps.setPropertyAsAUTF8String("msgOverlay", msgOverlay);
 
-                if (msgWindow) {
-                    let sinkProps = msgWindow.msgHeaderSink.properties;
-                    sinkProps.setPropertyAsInterface("itipItem", itipItem);
-                    sinkProps.setPropertyAsAUTF8String("msgOverlay", msgOverlay);
-
-                    // Notify the observer that the itipItem is available
-                    Services.obs.notifyObservers(null, "onItipItemCreation", 0);
-                }
-            }
-        } catch (e) {
-            cal.ERROR("[ltnMimeConverter] convertToHTML: " + e);
+            // Notify the observer that the itipItem is available
+            Services.obs.notifyObservers(null, "onItipItemCreation", 0);
         }
-
-        // Create the HTML string for display
         return msgOverlay;
     }
 };
 
 this.NSGetFactory = XPCOMUtils.generateNSGetFactory([ltnMimeConverter]);
--- a/calendar/lightning/content/imip-bar-overlay.xul
+++ b/calendar/lightning/content/imip-bar-overlay.xul
@@ -78,16 +78,32 @@
               <!-- show event/invitation details -->
               <toolbarbutton id="imipDetailsButton"
                              label="&lightning.imipbar.btnDetails.label;"
                              tooltiptext="&lightning.imipbar.btnDetails.tooltiptext;"
                              class="toolbarbutton-1 msgHeaderView-button imipDetailsButton"
                              oncommand="ltnImipBar.executeAction('X-SHOWDETAILS')"
                              hidden="true"/>
 
+              <!-- decline counter -->
+              <toolbarbutton id="imipDeclineCounterButton"
+                             label="&lightning.imipbar.btnDeclineCounter.label;"
+                             tooltiptext="&lightning.imipbar.btnDeclineCounter.tooltiptext;"
+                             class="toolbarbutton-1 msgHeaderView-button imipDeclineCounterButton"
+                             oncommand="ltnImipBar.executeAction('X-DECLINECOUNTER')"
+                             hidden="true"/>
+
+              <!-- reschedule -->
+              <toolbarbutton id="imipRescheduleButton"
+                             label="&lightning.imipbar.btnReschedule.label;"
+                             tooltiptext="&lightning.imipbar.btnReschedule.tooltiptext;"
+                             class="toolbarbutton-1 msgHeaderView-button imipRescheduleButton"
+                             oncommand="ltnImipBar.executeAction('X-RESCHEDULE')"
+                             hidden="true"/>
+
               <!-- add published events -->
               <toolbarbutton id="imipAddButton"
                              label="&lightning.imipbar.btnAdd.label;"
                              tooltiptext="&lightning.imipbar.btnAdd.tooltiptext;"
                              class="toolbarbutton-1 msgHeaderView-button imipAddButton"
                              oncommand="ltnImipBar.executeAction()"
                              hidden="true"/>
 
--- a/calendar/lightning/content/imip-bar.js
+++ b/calendar/lightning/content/imip-bar.js
@@ -205,17 +205,17 @@ var ltnImipBar = {
                 return false;
             }
             let author = aMsgHdr.mime2DecodedAuthor;
             let isSentFolder = aMsgHdr.folder.flags & nsMsgFolderFlags.SentMail;
             if (author && isSentFolder) {
                 let accounts = MailServices.accounts;
                 for (let identity in fixIterator(accounts.allIdentities,
                                                  Components.interfaces.nsIMsgIdentity)) {
-                    if (author.includes(identity.email) && !identity.fccReplyFollowParent) {
+                    if (author.includes(identity.email) && !identity.fccReplyFollowsParent) {
                         return true;
                     }
                 }
             }
             return false;
         };
 
         // We override the bar label for sent out invitations and in case the event does not exist
@@ -274,30 +274,71 @@ var ltnImipBar = {
             }
         }
         msgWindow.displayHTMLInMessagePane("", msgOverlay, false);
     },
 
     executeAction: function(partStat, extendResponse) {
         function _execAction(aActionFunc, aItipItem, aWindow, aPartStat) {
             if (cal.itip.promptCalendar(aActionFunc.method, aItipItem, aWindow)) {
+                let isDeclineCounter = aPartStat == "X-DECLINECOUNTER";
                 // filter out fake partstats
                 if (aPartStat.startsWith("X-")) {
-                    partstat = "";
+                    partStat = "";
                 }
                 // hide the buttons now, to disable pressing them twice...
                 if (aPartStat == partStat) {
                     ltnImipBar.resetButtons();
                 }
 
                 let opListener = {
                     QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]),
                     onOperationComplete: function(aCalendar, aStatus, aOperationType, aId, aDetail) {
+                        if (Components.isSuccessCode(aStatus) && isDeclineCounter) {
+                            // TODO: move the DECLINECOUNTER stuff to actionFunc
+                            aItipItem.getItemList({}).forEach(aItem => {
+                                // we can rely on the received itipItem to reply at this stage
+                                // already, the checks have been done in cal.itip.processFoundItems
+                                // when setting up the respective aActionFunc
+                                let attendees = cal.getAttendeesBySender(
+                                    aItem.getAttendees({}),
+                                    aItipItem.sender
+                                );
+                                let status = true;
+                                if (attendees.length == 1 && ltnImipBar.foundItems &&
+                                    ltnImipBar.foundItems.length) {
+                                    // we must return a message with the same sequence number as the
+                                    // counterproposal - to make it easy, we simply use the received
+                                    // item and just remove a comment, if any
+                                    try {
+                                        let item = aItem.clone();
+                                        item.calendar = ltnImipBar.foundItems[0].calendar;
+                                        item.deleteProperty("COMMENT");
+                                        // once we have full support to deal with for multiple items
+                                        // in a received invitation message, we should send this
+                                        // from outside outside of the forEach context
+                                        status = cal.itip.sendDeclineCounterMessage(
+                                            item,
+                                            "DECLINECOUNTER",
+                                            attendees,
+                                            { value: false }
+                                        );
+                                    } catch (e) {
+                                        cal.ERROR(e);
+                                        status = false;
+                                    }
+                                } else {
+                                    status = false;
+                                }
+                                if (!status) {
+                                    cal.ERROR("Failed to send DECLINECOUNTER reply!");
+                                }
+                            });
+                        }
                         // For now, we just state the status for the user something very simple
-                        let imipBar = document.getElementById("imip-bar");
                         let label = cal.itip.getCompleteText(aStatus, aOperationType);
                         imipBar.setAttribute("label", label);
 
                         if (!Components.isSuccessCode(aStatus)) {
                             showError(label);
                         }
                     },
                     onGetResult: function(aCalendar, aStatus, aItemType, aDetail, aCount, aItems) {
@@ -309,29 +350,80 @@ var ltnImipBar = {
                 } catch (exc) {
                     Components.utils.reportError(exc);
                 }
                 return true;
             }
             return false;
         }
 
+        let imipBar = document.getElementById("imip-bar");
         if (partStat == null) {
             partStat = "";
         }
-        if (partStat == "X-SHOWDETAILS") {
+        if (partStat == "X-SHOWDETAILS" || partStat == "X-RESCHEDULE") {
+            let counterProposal;
             let items = ltnImipBar.foundItems;
             if (items && items.length) {
                 let item = items[0].isMutable ? items[0] : items[0].clone();
-                modifyEventWithDialog(item);
+
+                if (partStat == "X-RESCHEDULE") {
+                    // TODO most of the following should be moved to the actionFunc defined in
+                    // calItipUtils
+                    let proposedItem = ltnImipBar.itipItem.getItemList({})[0];
+                    let proposedRID = proposedItem.getProperty("RECURRENCE-ID");
+                    if (proposedRID) {
+                        // if this is a counterproposal for a specific occurrence, we use
+                        // that to compare with
+                        item = item.recurrenceInfo.getOccurrenceFor(proposedRID).clone();
+                    }
+                    let parsedProposal = ltn.invitation.parseCounter(proposedItem, item);
+                    let potentialProposers = cal.getAttendeesBySender(
+                        proposedItem.getAttendees({}),
+                        ltnImipBar.itipItem.sender
+                    );
+                    let proposingAttendee = potentialProposers.length == 1 ?
+                                            potentialProposers[0] : null;
+                    if (proposingAttendee &&
+                        ["OK", "OUTDATED", "NOTLATESTUPDATE"].includes(parsedProposal.result.type)) {
+                        counterProposal = {
+                            attendee: proposingAttendee,
+                            proposal: parsedProposal.differences,
+                            oldVersion: parsedProposal.result == "OLDVERSION" ||
+                                        parsedProposal.result == "NOTLATESTUPDATE",
+                            onReschedule: () => {
+                                imipBar.setAttribute(
+                                    "label",
+                                    ltn.getString("lightning", "imipBarCounterPreviousVersionText")
+                                );
+                                // TODO: should we hide the buttons in this case, too?
+                            }
+                        };
+                    } else {
+                        imipBar.setAttribute(
+                            "label",
+                            ltn.getString("lightning", "imipBarCounterErrorText")
+                        );
+                        ltnImipBar.resetButtons();
+                        if (proposingAttendee) {
+                            cal.LOG(parsedProposal.result.descr);
+                        } else {
+                            cal.LOG("Failed to identify the sending attendee of the counterproposal.");
+                        }
+
+                        return false;
+                    }
+                }
+                // if this a rescheduling operation, we suppress the occurrence prompt here
+                modifyEventWithDialog(item, null, partStat != "X-RESCHEDULE", null, counterProposal);
             }
         } else {
             if (extendResponse) {
                 // Open an extended response dialog to enable the user to add a comment, make a
-                // counter proposal, delegate the event or interact in another way.
+                // counterproposal, delegate the event or interact in another way.
                 // Instead of a dialog, this might be implemented as a separate container inside the
                 // imip-overlay as proposed in bug 458578
                 //
                 // If implemented as a dialog, the OL compatibility decision should be incorporated
                 // therein too and the itipItems's autoResponse set to auto subsequently
                 // to prevent a second popup during imip transport processing.
             }
             let delmgr = Components.classes["@mozilla.org/calendar/deleted-items-manager;1"]
--- a/calendar/lightning/content/lightning-item-iframe.js
+++ b/calendar/lightning/content/lightning-item-iframe.js
@@ -368,16 +368,21 @@ function onLoad() {
         if (!gNewItemUI) {
             setElementValue("completed-date-picker", initialDatesValue);
             setElementValue("todo-entrydate", initialDatesValue);
             setElementValue("todo-duedate", initialDatesValue);
         }
     }
     loadDialog(window.calendarItem);
 
+    if (args.counterProposal) {
+        window.counterProposal = args.counterProposal;
+        displayCounterProposal();
+    }
+
     gMainWindow.setCursor("auto");
 
     if (typeof ToolbarIconColor !== "undefined") {
         ToolbarIconColor.init();
     }
 
     if (!gNewItemUI) {
         document.getElementById("item-title").focus();
@@ -730,34 +735,41 @@ function loadDialog(aItem) {
         // figure out what the title of the dialog should be and set it
         // tabs already have their title set
         if (!gInTab) {
             updateTitle();
         }
 
         let notifyCheckbox = document.getElementById("notify-attendees-checkbox");
         let undiscloseCheckbox = document.getElementById("undisclose-attendees-checkbox");
+        let disallowcounterCheckbox = document.getElementById("disallow-counter-checkbox");
         if (canNotifyAttendees(aItem.calendar, aItem)) {
             // visualize that the server will send out mail:
             notifyCheckbox.checked = true;
-            // hide undisclosure control as this a client only feature
+            // hide these controls as this a client only feature
             undiscloseCheckbox.disabled = true;
         } else {
             let itemProp = aItem.getProperty("X-MOZ-SEND-INVITATIONS");
             notifyCheckbox.checked = (aItem.calendar.getProperty("imip.identity") &&
                                       ((itemProp === null)
                                        ? Preferences.get("calendar.itip.notify", true)
                                        : (itemProp == "TRUE")));
             let undiscloseProp = aItem.getProperty("X-MOZ-SEND-INVITATIONS-UNDISCLOSED");
             undiscloseCheckbox.checked = (undiscloseProp === null)
                                          ? false // default value as most common within organizations
                                          : (undiscloseProp == "TRUE");
             // disable checkbox, if notifyCheckbox is not checked
             undiscloseCheckbox.disabled = (notifyCheckbox.checked == false);
         }
+        // this may also be a server exposed calendar property from exchange servers - if so, this
+        // probably should overrule the client-side config option
+        let disallowCounterProp = aItem.getProperty("X-MICROSOFT-DISALLOW-COUNTER");
+        disallowcounterCheckbox.checked = disallowCounterProp == "TRUE";
+        // if we're in reschedule mode, it's pointless to enable the control
+        disallowcounterCheckbox.disabled = !!window.counterProposal;
 
         updateAttendees();
         updateRepeat(true);
         updateReminder(true);
     }
 
     // Status
     if (cal.isEvent(aItem)) {
@@ -2923,17 +2935,30 @@ function saveItem() {
             item.deleteProperty("X-MOZ-SEND-INVITATIONS");
         } else {
             item.setProperty("X-MOZ-SEND-INVITATIONS", notifyCheckbox.checked ? "TRUE" : "FALSE");
         }
         let undiscloseCheckbox = document.getElementById("undisclose-attendees-checkbox");
         if (undiscloseCheckbox.disabled) {
             item.deleteProperty("X-MOZ-SEND-INVITATIONS-UNDISCLOSED");
         } else {
-            item.setProperty("X-MOZ-SEND-INVITATIONS-UNDISCLOSED", undiscloseCheckbox.checked ? "TRUE" : "FALSE");
+            item.setProperty("X-MOZ-SEND-INVITATIONS-UNDISCLOSED",
+                             undiscloseCheckbox.checked ? "TRUE" : "FALSE");
+        }
+        let disallowcounterCheckbox = document.getElementById("disallow-counter-checkbox");
+        let xProp = window.calendarItem.getProperty("X-MICROSOFT-DISALLOW-COUNTER");
+        // we want to leave an existing x-prop in case the checkbox is disabled as we need to
+        // roundtrip x-props that are not exclusively under our control
+        if (!disallowcounterCheckbox.disabled) {
+            // we only set the prop if we need to
+            if (disallowcounterCheckbox.checked) {
+                item.setProperty("X-MICROSOFT-DISALLOW-COUNTER", "TRUE");
+            } else if (xProp) {
+                item.setProperty("X-MICROSOFT-DISALLOW-COUNTER", "FALSE");
+            }
         }
     }
 
     // We check if the organizerID is different from our
     // calendar-user-address-set. The organzerID is the owner of the calendar.
     // If it's different, that is because someone is acting on behalf of
     // the organizer.
     if (item.organizer && item.calendar.aclEntry) {
@@ -2988,17 +3013,17 @@ function onCommandSave(aIsClosing) {
     // the call is complete? This might help when the user tries to save twice
     // before the call is complete. In that case, we do need a progress bar and
     // the ability to cancel the operation though.
     let listener = {
         QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]),
         onOperationComplete: function(aCalendar, aStatus, aOpType, aId, aItem) {
             // Check if the current window has a calendarItem first, because in case of undo
             // window refers to the main window and we would get a 'calendarItem is undefined' warning.
-            if ("calendarItem" in window) {
+            if (!aIsClosing && "calendarItem" in window) {
                 // If we changed the calendar of the item, onOperationComplete will be called multiple
                 // times. We need to make sure we're receiving the update on the right calendar.
                 if ((!window.calendarItem.id ||aId == window.calendarItem.id) &&
                     (aCalendar.id == window.calendarItem.calendar.id) &&
                     Components.isSuccessCode(aStatus)) {
                     if (window.calendarItem.recurrenceId) {
                         // TODO This workaround needs to be removed in bug 396182
                         // We are editing an occurrence. Make sure that the returned
@@ -3012,24 +3037,24 @@ function onCommandSave(aIsClosing) {
                     }
 
                     // We now have an item, so we must change to an edit.
                     window.mode = "modify";
                     updateTitle();
                     eventDialogCalendarObserver.observe(window.calendarItem.calendar);
                 }
             }
+            // this triggers the update of the imipbar in case this is a rescheduling case
+            if (window.counterProposal && window.counterProposal.onReschedule) {
+                window.counterProposal.onReschedule();
+            }
         },
         onGetResult: function() {}
     };
-
-    // Let the caller decide how to handle the modified/added item. Only pass
-    // the above item if we are not closing, otherwise the listener will be
-    // missing its window afterwards.
-    window.onAcceptCallback(item, calendar, originalItem, !aIsClosing && listener);
+    window.onAcceptCallback(item, calendar, originalItem, listener);
 }
 
 /**
  * This function is called when the user chooses to delete an Item
  * from the Event/Task dialog
  *
  */
 function onCommandDeleteItem() {
@@ -3814,17 +3839,17 @@ function capSupported(aCap) {
  * @return          The values for this capability
  */
 function capValues(aCap, aDefault) {
     let calendar = getCurrentCalendar();
     let vals = calendar.getProperty("capabilities." + aCap + ".values");
     return (vals === null ? aDefault : vals);
 }
 
- /**
+/**
  * Checks the until date just entered in the datepicker in order to avoid
  * setting a date earlier than the start date.
  * Restores the previous correct date; sets the warning flag to prevent closing
  * the dialog when the user enters a wrong until date.
  */
 function checkUntilDate() {
     let repeatUntilDate = getElementValue("repeat-until-datepicker");
     if (repeatUntilDate == "forever") {
@@ -3858,8 +3883,203 @@ function checkUntilDate() {
         };
         setTimeout(callback, 1);
     } else {
         // Valid date: set the time equal to start date time.
         gUntilDate = untilDate;
         updateUntildateRecRule();
     }
 }
+
+/**
+ * Displays a counterproposal if any
+ */
+function displayCounterProposal() {
+    if (!window.counterProposal || !window.counterProposal.attendee ||
+        !window.counterProposal.proposal) {
+        return;
+    }
+
+    let propLabels = document.getElementById("counter-proposal-property-labels");
+    let propValues = document.getElementById("counter-proposal-property-values");
+    let idCounter = 0;
+    let comment;
+
+    for (let proposal of window.counterProposal.proposal) {
+        if (proposal.property == "COMMENT") {
+            if (proposal.proposed && !proposal.original) {
+                comment = proposal.proposed;
+            }
+        } else {
+            let label = lookupCounterLabel(proposal);
+            let value = formatCounterValue(proposal);
+            if (label && value) {
+                // setup label node
+                let propLabel = propLabels.firstChild.cloneNode(false);
+                propLabel.id = propLabel.id + "-" + idCounter;
+                propLabel.control = propLabel.control + "-" + idCounter;
+                propLabel.removeAttribute("collapsed");
+                propLabel.value = label;
+                // setup value node
+                let propValue = propValues.firstChild.cloneNode(false);
+                propValue.id = propLabel.control;
+                propValue.removeAttribute("collapsed");
+                propValue.value = value;
+                // append nodes
+                propLabels.appendChild(propLabel);
+                propValues.appendChild(propValue);
+                idCounter++;
+            }
+        }
+    }
+
+    let attendeeId = window.counterProposal.attendee.CN ||
+                     cal.removeMailTo(window.counterProposal.attendee.id || "");
+    let partStat = window.counterProposal.attendee.participationStatus;
+    if (partStat == "DECLINED") {
+        partStat = "counterSummaryDeclined";
+    } else if (partStat == "TENTATIVE") {
+        partStat = "counterSummaryTentative";
+    } else if (partStat == "ACCEPTED") {
+        partStat = "counterSummaryAccepted";
+    } else if (partStat == "DELEGATED") {
+        partStat = "counterSummaryDelegated";
+    } else if (partStat == "NEEDS-ACTION") {
+        partStat = "counterSummaryNeedsAction";
+    } else {
+        cal.LOG("Unexpected partstat " + partStat + " detected.");
+        // we simply reset partStat not display the summary text of the counter box
+        // to avoid the window of death
+        partStat = null;
+    }
+
+    if (idCounter > 0) {
+        if (partStat && attendeeId.length) {
+            document.getElementById("counter-proposal-summary").value = cal.calGetString(
+                "calendar-event-dialog",
+                partStat,
+                [attendeeId]
+            );
+            document.getElementById("counter-proposal-summary").removeAttribute("collapsed");
+        }
+        if (comment) {
+            document.getElementById("counter-proposal-comment").value = comment;
+            document.getElementById("counter-proposal-box").removeAttribute("collapsed");
+        }
+        document.getElementById("counter-proposal-box").removeAttribute("collapsed");
+
+        if (window.counterProposal.oldVersion) {
+            // this is a counterproposal to a previous version of the event - we should notify the
+            // user accordingly
+            notifyUser(
+                "counterProposalOnPreviousVersion",
+                cal.calGetString("calendar-event-dialog", "counterOnPreviousVersionNotification"),
+                "warn"
+            );
+        }
+        if (window.calendarItem.getProperty("X-MICROSOFT-DISALLOW-COUNTER") == "TRUE") {
+            // this is a counterproposal although the user disallowed countering when sending the
+            // invitation, so we notify the user accordingly
+            notifyUser(
+                "counterProposalOnCounteringDisallowed",
+                cal.calGetString("calendar-event-dialog", "counterOnCounterDisallowedNotification"),
+                "warn"
+            );
+        }
+    }
+}
+
+/**
+ * Get the property label to display for a counterproposal based on the respective label used in
+ * the dialog
+ *
+ * @param   {JSObject}     aProperty  The property to check for a label
+ * @returns {String|null}             The label to display or null if no such label
+ */
+function lookupCounterLabel(aProperty) {
+    let nodeIds = getPropertyMap();
+    let labels = nodeIds.has(aProperty.property) &&
+                document.getElementsByAttribute("control", nodeIds.get(aProperty.property));
+    let labelValue;
+    if (labels && labels.length) {
+        // as label control assignment should be unique, we can just take the first result
+        labelValue = labels[0].value;
+    } else {
+        cal.LOG("Unsupported property " + aProperty.property + " detected when setting up counter " +
+                "box labels.");
+    }
+    return labelValue;
+}
+
+/**
+ * Get the property value to display for a counterproposal as currently supported
+ *
+ * @param   {JSObject}     aProperty  The property to check for a label
+ * @returns {String|null}             The value to display or null if the property is not supported
+ */
+function formatCounterValue(aProperty) {
+    const dateProps = ["DTSTART", "DTEND"];
+    const stringProps = ["SUMMARY", "LOCATION"];
+
+    let val;
+    if (dateProps.includes(aProperty.property)) {
+        let localTime = aProperty.proposed.getInTimezone(cal.calendarDefaultTimezone());
+        let formatter = getDateFormatter();
+        val = formatter.formatDateTime(localTime);
+        if (gTimezonesEnabled) {
+            let tzone = localTime.timezone.displayName || localTime.timezone.tzid;
+            val += " " + tzone;
+        }
+    } else if (stringProps.includes(aProperty.property)) {
+        val = aProperty.proposed;
+    } else {
+        cal.LOG("Unsupported property " + aProperty.property +
+                " detected when setting up counter box values.");
+    }
+    return val;
+}
+
+/**
+ * Get a map of porperty names and labels of currently supported properties
+ *
+ * @returns {Map}
+ */
+function getPropertyMap() {
+    let map = new Map();
+    map.set("SUMMARY", "item-title");
+    map.set("LOCATION", "item-location");
+    map.set("DTSTART", "event-starttime");
+    map.set("DTEND", "event-endtime");
+    return map;
+}
+
+/**
+ * Applies the proposal or original data to the respective dialog fields
+ *
+ * @param {String} aType Either 'proposed' or 'original'
+ */
+function applyValues(aType) {
+    if (!window.counterProposal || (aType != "proposed" && aType != "original")) {
+        return;
+    }
+    let originalBtn = document.getElementById("counter-original-btn");
+    if (originalBtn.disabled) {
+        // The button is disbled when opening the dialog/tab, which makes it more obvious to the
+        // user that he/she needs to apply the proposal values prior to saving & sending.
+        // Once that happened, we leave both options to the user without toogling the button states
+        // to avoid needing to listen to manual changes to do that correctly
+        originalBtn.removeAttribute("disabled");
+    }
+    let nodeIds = getPropertyMap();
+    window.counterProposal.proposal.forEach(aProperty => {
+        if (aProperty.property != "COMMENT") {
+            let valueNode = nodeIds.has(aProperty.property) &&
+                            document.getElementById(nodeIds.get(aProperty.property));
+            if (valueNode) {
+                if (["DTSTART", "DTEND"].includes(aProperty.property)) {
+                    valueNode.value = cal.dateTimeToJsDate(aProperty[aType]);
+                } else {
+                    valueNode.value = aProperty[aType];
+                }
+            }
+        }
+    });
+}
--- a/calendar/lightning/content/lightning-item-iframe.xul
+++ b/calendar/lightning/content/lightning-item-iframe.xul
@@ -74,18 +74,71 @@
     <command id="cmd_copyAttachment"
              oncommand="copyAttachment()"/>
     <command id="cmd_deleteAttachment"
              disable-on-readonly="true"
              oncommand="deleteAttachment()"/>
     <command id="cmd_deleteAllAttachments"
              disable-on-readonly="true"
              oncommand="deleteAllAttachments()"/>
+    <command id="cmd_applyProposal"
+             disable-on-readonly="true"
+             oncommand="applyValues('proposed')"/>
+    <command id="cmd_applyOriginal"
+             disable-on-readonly="true"
+             oncommand="applyValues('original')"/>
   </commandset>
 
+  <!-- Counter information section -->
+  <hbox id="counter-proposal-box"
+        collapsed="true">
+    <vbox>
+      <description id="counter-proposal-summary"
+                   collapsed="true"
+                   crop="end" />
+      <hbox id="counter-proposal">
+        <vbox id="counter-proposal-property-labels">
+          <label id="counter-proposal-property-label"
+                 control="counter-proposal-property-value"
+                 collapsed="true"
+                 value="" />
+        </vbox>
+        <vbox id="counter-proposal-property-values">
+          <description id="counter-proposal-property-value"
+                       crop="end"
+                       collapsed="true"
+                       value="" />
+        </vbox>
+      </hbox>
+      <description id="counter-proposal-comment"
+                   collapsed="true"
+                   crop="end" />
+    </vbox>
+    <spacer flex="1" />
+    <vbox id ="counter-buttons">
+      <button id="counter-proposal-btn"
+              label="&counter.button.proposal.label;"
+              crop="end"
+              command="cmd_applyProposal"
+              orient="horizontal"
+              class="counter-buttons"
+              accesskey="&counter.button.proposal.accesskey;"
+              tooltip="&counter.button.proposal.tooltip2;" />
+      <button id="counter-original-btn"
+              label="&counter.button.original.label;"
+              crop="end"
+              command="cmd_applyOriginal"
+              orient="horizontal"
+              disabled="true"
+              class="counter-buttons"
+              accesskey="&counter.button.original.accesskey;"
+              tooltip="&counter.button.original.tooltip2;" />
+    </vbox>
+  </hbox>
+
   <notificationbox id="event-dialog-notifications" notificationside="top"/>
 
   <grid id="event-grid"
         flex="1"
         style="padding-top: 8px; padding-bottom: 10px; padding-inline-start: 8px; padding-inline-end: 10px;">
     <columns id="event-grid-columns">
         <column id="event-description-column"/>
         <column id="event-controls-column" flex="1"/>
@@ -567,16 +620,21 @@
                           accesskey="&event.attendees.notify.accesskey;"
                           oncommand="changeUndiscloseCheckboxStatus();"
                           pack="start"/>
                 <checkbox id="undisclose-attendees-checkbox"
                           label="&event.attendees.notifyundisclosed.label;"
                           accesskey="&event.attendees.notifyundisclosed.accesskey;"
                           tooltiptext="&event.attendees.notifyundisclosed.tooltip;"
                           pack="start"/>
+                <checkbox id="disallow-counter-checkbox"
+                          label="&event.attendees.disallowcounter.label;"
+                          accesskey="&event.attendees.disallowcounter.accesskey;"
+                          tooltiptext="&event.attendees.disallowcounter.tooltip;"
+                          pack="start"/>
               </hbox>
             </vbox>
           </tabpanel>
         </tabpanels>
       </tabbox>
 
       <separator id="event-grid-link-separator"
                  class="groove"
--- a/calendar/lightning/modules/ltnInvitationUtils.jsm
+++ b/calendar/lightning/modules/ltnInvitationUtils.jsm
@@ -32,34 +32,44 @@ ltn.invitation = {
                                            "itipRequestBody",
                                            [organizerString, summary]);
                     break;
                 case "CANCEL":
                     header = ltn.getString("lightning",
                                            "itipCancelBody",
                                            [organizerString, summary]);
                     break;
-                case "REPLY": {
-                    // This is a reply received from someone else, there should
-                    // be just one attendee, the attendee that replied. If
-                    // there is more than one attendee, just take the first so
-                    // code doesn't break here.
+                case "COUNTER":
+                    // falls through
+                case "REPLY":
                     let attendees = item.getAttendees({});
-                    if (attendees && attendees.length >= 1) {
-                        let sender = attendees[0];
-                        let statusString = sender.participationStatus == "DECLINED"
-                                               ? "itipReplyBodyDecline"
-                                               : "itipReplyBodyAccept";
-
-                        header = ltn.getString("lightning", statusString, [sender.toString()]);
+                    let sender = cal.getAttendeesBySender(attendees, aItipItem.sender);
+                    if (sender.length == 1) {
+                        if (aItipItem.responseMethod == "COUNTER") {
+                            header = cal.calGetString("lightning",
+                                                      "itipCounterBody",
+                                                      [sender[0].toString(), summary],
+                                                      "lightning");
+                        } else {
+                            let statusString = (sender[0].participationStatus == "DECLINED" ?
+                                                "itipReplyBodyDecline" : "itipReplyBodyAccept");
+                            header = cal.calGetString("lightning",
+                                                      statusString,
+                                                      [sender[0].toString()],
+                                                      "lightning");
+                        }
                     } else {
                         header = "";
                     }
                     break;
-                }
+                case "DECLINECOUNTER":
+                    header = ltn.getString("lightning",
+                                           "itipDeclineCounterBody",
+                                           [organizerString, summary]);
+                    break;
             }
         }
 
         if (!header) {
             header = ltn.getString("lightning", "imipHtml.header", null);
         }
 
         return header;
@@ -499,10 +509,80 @@ ltn.invitation = {
         return MailServices.mimeConverter
                            .encodeMimePartIIStr_UTF8(aHeader,
                                                      aIsEmail,
                                                      "UTF-8",
                                                      fieldNameLen,
                                                      Components.interfaces
                                                                .nsIMimeConverter
                                                                .MIME_ENCODED_WORD_SIZE);
+    },
+
+    /**
+     * Parses a counterproposal to extract differences to the existing event
+     * @param  {calIEvent|calITodo} aProposedItem  The counterproposal
+     * @param  {calIEvent|calITodo} aExistingItem  The item to compare with
+     * @return {JSObject}                          Objcet of result and differences of parsing
+     * @return {String} JsObject.result.type       Parsing result: OK|OLDVERSION|ERROR|NODIFF
+     * @return {String} JsObject.result.descr      Parsing result description
+     * @return {Array}  JsObject.differences       Array of objects consisting of property, proposed
+     *                                                 and original properties.
+     * @return {String} JsObject.comment           A comment of the attendee, if any
+     */
+    parseCounter: function(aProposedItem, aExistingItem) {
+        let isEvent = cal.isEvent(aProposedItem);
+        // atm we only support a subset of properties, for a full list see RfC 5546 section 3.2.7
+        let properties = ["SUMMARY", "LOCATION", "DTSTART", "DTEND", "COMMENT"];
+        if (!isEvent) {
+            cal.LOG("Parsing of counterproposals is currently only supported for events.");
+            properties = [];
+        }
+
+        let diff = [];
+        let status = { descr: "", type: "OK" };
+        // As required in https://tools.ietf.org/html/rfc5546#section-3.2.7 a valid counterproposal
+        // is referring to as existing UID and must include the same sequence number and organizer as
+        // the original request being countered
+        if (aProposedItem.id == aExistingItem.id &&
+            aProposedItem.organizer && aExistingItem.organizer &&
+            aProposedItem.organizer.id == aExistingItem.organizer.id) {
+            let proposedSequence = aProposedItem.getProperty("SEQUENCE") || 0;
+            let existingSequence = aExistingItem.getProperty("SEQUENCE") || 0;
+            if (existingSequence >= proposedSequence) {
+                if (existingSequence > proposedSequence) {
+                    // in this case we prompt the organizer with the additional information that the
+                    // received proposal refers to an outdated version of the event
+                    status.descr = "This is a counterproposal to an already rescheduled event.";
+                    status.type = "OUTDATED";
+                } else if (aProposedItem.stampTime.compare(aExistingItem.stampTime) == -1) {
+                    // now this is the same sequence but the proposal is not based on the latest
+                    // update of the event - updated events may have minor changes, while for major
+                    // ones there has been a rescheduling
+                    status.descr = "This is a counterproposal not based on the latest event update.";
+                    status.type = "NOTLATESTUPDATE";
+                }
+                for (let prop of properties) {
+                    let newValue = aProposedItem.getProperty(prop) || null;
+                    let oldValue = aExistingItem.getProperty(prop) || null;
+                    if ((["DTSTART", "DTEND"].includes(prop) && newValue.toString() != oldValue.toString()) ||
+                        (!["DTSTART", "DTEND"].includes(prop) && newValue != oldValue)) {
+                        diff.push({
+                            property: prop,
+                            proposed: newValue,
+                            original: oldValue
+                        });
+                    }
+                }
+            } else {
+                status.descr = "Invalid sequence number in counterproposal.";
+                status.type = "ERROR";
+            }
+        } else {
+            status.descr = "Mismatch of uid or organizer in counterproposal.";
+            status.type = "ERROR";
+        }
+        if (status.type != "ERROR" && !diff.length) {
+            status.descr = "No difference in counterproposal detected.";
+            status.type = "NODIFF";
+        }
+        return { result: status, differences: diff };
     }
 };
--- a/calendar/lightning/themes/linux/lightning.css
+++ b/calendar/lightning/themes/linux/lightning.css
@@ -185,16 +185,17 @@ radio[pane=paneLightning] {
 }
 
 /* ::: imip button icons ::: */
 .imipAcceptRecurrencesButton,
 .imipAcceptButton {
   list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#complete);
 }
 
+.imipDeclineCounterButton,
 .imipDeclineRecurrencesButton,
 .imipDeclineButton {
   list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#decline);
 }
 
 .imipTentativeRecurrencesButton,
 .imipTentativeButton {
   list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#tentative);
@@ -203,16 +204,17 @@ radio[pane=paneLightning] {
 .imipDetailsButton {
   list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#find);
 }
 
 .imipAddButton {
   list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#newevent);
 }
 
+.imipRescheduleButton,
 .imipUpdateButton {
   list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#synchronize);
 }
 
 .imipDeleteButton {
   list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#delete);
 }
 
--- a/calendar/lightning/themes/osx/lightning.css
+++ b/calendar/lightning/themes/osx/lightning.css
@@ -261,16 +261,17 @@ toolbar[bighttext] #task-tab-button {
 
 /* ::: imip button icons ::: */
 @media not all and (-moz-mac-yosemite-theme) {
   .imipAcceptRecurrencesButton,
   .imipAcceptButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar-osxlion.svg#complete);
   }
 
+  .imipDeclineCounterButton,
   .imipDeclineRecurrencesButton,
   .imipDeclineButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar-osxlion.svg#decline);
   }
 
   .imipTentativeRecurrencesButton,
   .imipTentativeButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar-osxlion.svg#tentative);
@@ -279,16 +280,17 @@ toolbar[bighttext] #task-tab-button {
   .imipDetailsButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar-osxlion.svg#find);
   }
 
   .imipAddButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar-osxlion.svg#newevent);
   }
 
+  .imipRescheduleButton,
   .imipUpdateButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar-osxlion.svg#synchronize);
   }
 
   .imipDeleteButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar-osxlion.svg#delete);
   }
 
@@ -298,16 +300,17 @@ toolbar[bighttext] #task-tab-button {
 }
 
 @media (-moz-mac-yosemite-theme) {
   .imipAcceptRecurrencesButton,
   .imipAcceptButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#complete-flat);
   }
 
+  .imipDeclineCounterButton,
   .imipDeclineRecurrencesButton,
   .imipDeclineButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#decline-flat);
   }
 
   .imipTentativeRecurrencesButton,
   .imipTentativeButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#tentative-flat);
@@ -316,16 +319,17 @@ toolbar[bighttext] #task-tab-button {
   .imipDetailsButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#find-flat);
   }
 
   .imipAddButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#newevent-flat);
   }
 
+  .imipRescheduleButton,
   .imipUpdateButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#synchronize-flat);
   }
 
   .imipDeleteButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#delete);
   }
 
--- a/calendar/lightning/themes/windows/lightning.css
+++ b/calendar/lightning/themes/windows/lightning.css
@@ -239,30 +239,32 @@ radio[pane=paneLightning] {
   }
 
   /* ::: imip button icons ::: */
   .imipAcceptButton,
   .imipAcceptRecurrencesButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#complete);
   }
 
+  .imipDeclineCounterButton,
   .imipDeclineButton,
   .imipDeclineRecurrencesButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#decline);
   }
 
   .imipTentativeButton,
   .imipTentativeRecurrencesButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#tentative);
   }
 
   .imipAddButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#newevent);
   }
 
+  .imipRescheduleButton,
   .imipUpdateButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#synchronize);
   }
 
   .imipDetailsButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#find);
   }
 
@@ -356,30 +358,32 @@ radio[pane=paneLightning] {
   }
 
   /* ::: imip button icons ::: */
   .imipAcceptButton,
   .imipAcceptRecurrencesButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#complete-flat);
   }
 
+  .imipDeclineCounterButton,
   .imipDeclineButton,
   .imipDeclineRecurrencesButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#decline-flat);
   }
 
   .imipTentativeButton,
   .imipTentativeRecurrencesButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#tentative-flat);
   }
 
   .imipAddButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#newevent-flat);
   }
 
+  .imipRescheduleButton,
   .imipUpdateButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#synchronize-flat);
   }
 
   .imipDetailsButton {
     list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#find-flat);
   }
 
--- a/calendar/locales/en-US/chrome/calendar/calendar-event-dialog.dtd
+++ b/calendar/locales/en-US/chrome/calendar/calendar-event-dialog.dtd
@@ -35,17 +35,17 @@
    - they still fit in. -->
 <!ENTITY event.attendees.notify.label                "Notify attendees">
 <!ENTITY event.attendees.notify.accesskey            "f">
 <!ENTITY event.attendees.notifyundisclosed.label     "Separate invitation per attendee">
 <!ENTITY event.attendees.notifyundisclosed.accesskey "x">
 <!ENTITY event.attendees.notifyundisclosed.tooltip   "This option sends one invitation email per attendee. Each invitation only contains the recipient attendee so that other attendee identities are not disclosed.">
 <!ENTITY event.attendees.disallowcounter.label       "Disallow counter">
 <!ENTITY event.attendees.disallowcounter.accesskey   "a">
-<!ENTITY event.attendees.disallowcounter.tooltip     "Indicates that you will not accept counter proposals">
+<!ENTITY event.attendees.disallowcounter.tooltip     "Indicates that you will not accept counterproposals">
 
 <!-- Keyboard Shortcuts -->
 <!ENTITY event.dialog.new.event.key2              "I">
 <!ENTITY event.dialog.new.task.key2               "D">
 <!ENTITY event.dialog.new.message.key2            "N">
 <!ENTITY event.dialog.close.key                   "W">
 <!ENTITY event.dialog.save.key                    "S">
 <!ENTITY event.dialog.saveandclose.key            "L">
@@ -160,22 +160,28 @@
 <!ENTITY  event.toolbar.attendees.tooltip                 "Invite Attendees">
 <!ENTITY  event.toolbar.attachments.tooltip               "Add Attachments">
 <!ENTITY  event.toolbar.privacy.tooltip                   "Change Privacy">
 <!ENTITY  event.toolbar.priority.tooltip                  "Change Priority">
 <!ENTITY  event.toolbar.status.tooltip                    "Change Status">
 <!ENTITY  event.toolbar.freebusy.tooltip                  "Change Free/Busy time">
 
 <!-- Counter box -->
+<!-- LOCALIZATON NOTE(counter.button.*)
+   - This is only visible in the UI if you have received a counterproposal before and are going to
+   - reschedule the event from the imipbar in the email view. Clicking on the buttons will only
+   - populate the form fields in the dialog, there's no other immediate action on clicking like with
+   - the imip bar. Rescheduling will happen after clicking on save&close as usual. This screenshot
+   - illustrates how it might look like: https://bugzilla.mozilla.org/attachment.cgi?id=8810121 -->
 <!ENTITY counter.button.proposal.label                    "Apply proposal">
 <!ENTITY counter.button.proposal.accesskey                "p">
-<!ENTITY counter.button.proposal.tooltip                  "Apply the proposal data to the form.">
+<!ENTITY counter.button.proposal.tooltip2                 "Event fields will be filled in using the values from the counterproposal, only saving with or without additional changes will notify all attendees accordingly">
 <!ENTITY counter.button.original.label                    "Apply original data">
 <!ENTITY counter.button.original.accesskey                "r">
-<!ENTITY counter.button.original.tooltip                  "Apply the original data to the form.">
+<!ENTITY counter.button.original.tooltip2                 "The fields will be set to the values from the original event, before the counterproposal was made">
 
 <!-- Main page -->
 <!ENTITY event.title.textbox.label                        "Title:" >
 <!ENTITY event.title.textbox.accesskey                    "I">
 <!ENTITY event.location.label                             "Location:" >
 <!ENTITY event.location.accesskey                         "L">
 <!ENTITY event.categories.label                           "Category:">
 <!ENTITY event.categories.accesskey                       "y">
--- a/calendar/locales/en-US/chrome/lightning/lightning.properties
+++ b/calendar/locales/en-US/chrome/lightning/lightning.properties
@@ -113,21 +113,21 @@ imipHtml.attendeeUserType2.ROOM=%1$S (ro
 # imipHtml.attendeeRole2.*
 # %1$S - email address or common name <email address> representing an attendee of unknown type
 imipHtml.attendeeUserType2.UNKNOWN=%1$S
 
 imipAddedItemToCal2=The event has been added to your calendar.
 imipCanceledItem2=The event has been deleted from your calendar.
 imipUpdatedItem2=The event has been updated.
 imipBarCancelText=This message contains an event cancellation.
-imipBarCounterErrorText=This message contains a counter proposal to an invitation that cannot be processed.
-imipBarCounterPreviousVersionText=This message contains a counter proposal to a previous version of an invitation.
-imipBarCounterText=This message contains a counter proposal to an invitation.
-imipBarDisallowedCounterText=This message contains a counter proposal although you disallowed countering for this event.
-imipBarDeclineCounterText=This message contains a reply to your counter proposal.
+imipBarCounterErrorText=This message contains a counterproposal to an invitation that cannot be processed.
+imipBarCounterPreviousVersionText=This message contains a counterproposal to a previous version of an invitation.
+imipBarCounterText=This message contains a counterproposal to an invitation.
+imipBarDisallowedCounterText=This message contains a counterproposal although you disallowed countering for this event.
+imipBarDeclineCounterText=This message contains a reply to your counterproposal.
 imipBarRefreshText=This message asks for an event update.
 imipBarPublishText=This message contains an event.
 imipBarRequestText=This message contains an invitation to an event.
 imipBarSentText=This message contains a sent event.
 imipBarSentButRemovedText=This message contains a sent out event that is not in your calendar anymore.
 imipBarUpdateText=This message contains an update to an existing event.
 imipBarAlreadyProcessedText=This message contains an event that has already been processed.
 imipBarProcessedNeedsAction=This message contains an event that you have not yet responded to.
@@ -150,20 +150,20 @@ itipReplyBodyAccept=%1$S has accepted yo
 itipReplyBodyDecline=%1$S has declined your event invitation.
 itipReplySubjectAccept=Event Invitation Reply (Accepted): %1$S
 itipReplySubjectDecline=Event Invitation Reply (Declined): %1$S
 itipReplySubjectTentative=Event Invitation Reply (Tentative): %1$S
 itipRequestSubject=Event Invitation: %1$S
 itipRequestUpdatedSubject=Updated Event Invitation: %1$S
 itipRequestBody=%1$S has invited you to %2$S
 itipCancelSubject=Event Canceled: %1$S
-itipCancelBody=%1$S has canceled this event: « %2$S »
-itipCounterBody=%1$S has made a counter proposal for « %2$S »:
-itipDeclineCounterBody=%1$S has declined your counter proposal for « %2$S ».
-itipDeclineCounterSubject=Counter Proposal Declined: %1$S
+itipCancelBody=%1$S has canceled this event: %2$S
+itipCounterBody=%1$S has made a counterproposal for "%2$S":
+itipDeclineCounterBody=%1$S has declined your counterproposal for "%2$S".
+itipDeclineCounterSubject=Counterproposal Declined: %1$S
 
 confirmProcessInvitation=You have recently deleted this item, are you sure you want to process this invitation?
 confirmProcessInvitationTitle=Process Invitation?
 
 invitationsLink.label=Invitations: %1$S
 
 # LOCALIZATION_NOTE(binaryComponentKnown): This is shown when Lightning is
 # missing the binary component and knows how to calculate the expected version
new file mode 100644
--- /dev/null
+++ b/calendar/test/unit/test_calitiputils.js
@@ -0,0 +1,398 @@
+/* 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/. */
+
+Components.utils.import("resource://calendar/modules/calUtils.jsm");
+Components.utils.import("resource://calendar/modules/calItipUtils.jsm");
+Components.utils.import("resource://testing-common/mailnews/mailTestUtils.js");
+
+// tests for calItipUtils.jsm
+
+function run_test() {
+    getMessageSender_test();
+    getSequence_test();
+    getStamp_test();
+    compareSequence_test();
+    compareStamp_test();
+    compare_test();
+}
+
+/*
+ * Helper function to get an ics for testing sequence and stamp comparison
+ *
+ * @param {String} aAttendee              A serialized ATTENDEE property
+ * @param {String} aSequence              A serialized SEQUENCE property
+ * @param {String} aDtStamp               A serialized DTSTAMP property
+ * @param {String} aXMozReceivedSequence  A serialized X-MOZ-RECEIVED-SEQUENCE property
+ * @param {String} aXMozReceivedDtStamp   A serialized X-MOZ-RECEIVED-STAMP property
+ */
+function getSeqStampTestIcs(aProperties) {
+    // we make sure to have a dtstamp property to get a valid ics
+    let dtStamp = "20150909T181048Z";
+    let additionalProperties = "";
+    aProperties.forEach((aProp) => {
+        if (aProp.startsWith("DTSTAMP:")) {
+            dtStamp = aProp;
+        } else {
+            additionalProperties += "\r\n" + aProp;
+        }
+    });
+
+    return [
+        "BEGIN:VCALENDAR",
+        "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN",
+        "VERSION:2.0",
+        "METHOD:REQUEST",
+        "BEGIN:VTIMEZONE",
+        "TZID:Europe/Berlin",
+        "BEGIN:DAYLIGHT",
+        "TZOFFSETFROM:+0100",
+        "TZOFFSETTO:+0200",
+        "TZNAME:CEST",
+        "DTSTART:19700329T020000",
+        "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU",
+        "END:DAYLIGHT",
+        "BEGIN:STANDARD",
+        "TZOFFSETFROM:+0200",
+        "TZOFFSETTO:+0100",
+        "TZNAME:CET",
+        "DTSTART:19701025T030000",
+        "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU",
+        "END:STANDARD",
+        "END:VTIMEZONE",
+        "BEGIN:VEVENT",
+        "CREATED:20150909T180909Z",
+        "LAST-MODIFIED:20150909T181048Z",
+        dtStamp,
+        "UID:cb189fdc-ed47-4db6-a8d7-31a08802249d",
+        "SUMMARY:Test Event",
+        "ORGANIZER;RSVP=TRUE;CN=Organizer;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:organizer@example.net",
+        "ATTENDEE;RSVP=TRUE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:attende" +
+        "e@example.net" + additionalProperties,
+        "DTSTART;TZID=Europe/Berlin:20150909T210000",
+        "DTEND;TZID=Europe/Berlin:20150909T220000",
+        "TRANSP:OPAQUE",
+        "LOCATION:Room 1",
+        "DESCRIPTION:Let us get together",
+        "URL:http://www.example.com",
+        "ATTACH:http://www.example.com",
+        "END:VEVENT",
+        "END:VCALENDAR"].join("\r\n");
+}
+
+function getSeqStampTestItems(aTest) {
+    let items = [];
+    for (let input of aTest.input) {
+        if (input.item) {
+            // in this case, we need to return an event
+            let attendee = "";
+            if ("attendee" in input.item && input.item.attendee != {}) {
+                let att = cal.createAttendee();
+                att.id = input.item.attendee.id || "mailto:otherattendee@example.net";
+                if ("receivedSeq" in input.item.attendee && input.item.attendee.receivedSeq.length) {
+                    att.setProperty("RECEIVED-SEQUENCE", input.item.attendee.receivedSeq);
+                }
+                if ("receivedStamp" in input.item.attendee && input.item.attendee.receivedStamp.length) {
+                    att.setProperty("RECEIVED-DTSTAMP", input.item.attendee.receivedStamp);
+                }
+            }
+            let sequence = "";
+            if ("sequence" in input.item && input.item.sequence.length) {
+                sequence = "SEQUENCE:" + input.item.sequence;
+            }
+            let dtStamp = "DTSTAMP:20150909T181048Z";
+            if ("dtStamp" in input.item && input.item.dtStamp) {
+                dtStamp = "DTSTAMP:" + input.item.dtStamp;
+            }
+            let xMozReceivedSeq = "";
+            if ("xMozReceivedSeq" in input.item && input.item.xMozReceivedSeq.length) {
+                xMozReceivedSeq = "X-MOZ-RECEIVED-SEQUENCE:" + input.item.xMozReceivedSeq;
+            }
+            let xMozReceivedStamp = "";
+            if ("xMozReceivedStamp" in input.item && input.item.xMozReceivedStamp.length) {
+                xMozReceivedStamp = "X-MOZ-RECEIVED-DTSTAMP:" + input.item.xMozReceivedStamp;
+            }
+            let xMsAptSeq = "";
+            if ("xMsAptSeq" in input.item && input.item.xMsAptSeq.length) {
+                xMsAptSeq = "X-MICROSOFT-CDO-APPT-SEQUENCE:" + input.item.xMsAptSeq;
+            }
+            let testItem = cal.createEvent();
+            testItem.icalString = getSeqStampTestIcs([attendee, sequence, dtStamp, xMozReceivedSeq,
+                                                      xMozReceivedStamp, xMsAptSeq]);
+            items.push(testItem);
+        } else {
+            // in this case, we need to return an attendee
+            let att = cal.createAttendee();
+            att.id = input.attendee.id || "mailto:otherattendee@example.net";
+            if (input.attendee.receivedSeq && input.attendee.receivedSeq.length) {
+                att.setProperty("RECEIVED-SEQUENCE", input.attendee.receivedSeq);
+            }
+            if (input.attendee.receivedStamp && input.attendee.receivedStamp.length) {
+                att.setProperty("RECEIVED-DTSTAMP", input.attendee.receivedStamp);
+            }
+            items.push(att);
+        }
+    }
+    return items;
+}
+
+function getMessageSender_test() {
+    let data = [{
+        input: null,
+        expected: null
+    }, {
+        input: { },
+        expected: null
+    }, {
+        input: { author: "Sender 1 <sender1@example.net>" },
+        expected: "sender1@example.net"
+    }];
+    for (let i = 1; i <= data.length; i++) {
+        let test = data[i - 1];
+        equal(cal.itip.getMessageSender(test.input), test.expected, "(test #" + i + ")");
+    }
+}
+
+function getSequence_test() {
+    // assigning an empty string results in not having the property in the ics here
+    let data = [{
+        input: [{ item: { sequence: "", xMozReceivedSeq: "" } }],
+        expected: 0
+    }, {
+        input: [{ item: { sequence: "0", xMozReceivedSeq: "" } }],
+        expected: 0
+    }, {
+        input: [{ item: { sequence: "", xMozReceivedSeq: "0" } }],
+        expected: 0
+    }, {
+        input: [{ item: { sequence: "1", xMozReceivedSeq: "" } }],
+        expected: 1
+    }, {
+        input: [{ item: { sequence: "", xMozReceivedSeq: "1" } }],
+        expected: 1
+    }, {
+        input: [{ attendee: { receivedSeq: "" } }],
+        expected: 0
+    }, {
+        input: [{ attendee: { receivedSeq: "0" } }],
+        expected: 0
+    }, {
+        input: [{ attendee: { receivedSeq: "1" } }],
+        expected: 1
+    }];
+    for (let i = 1; i <= data.length; i++) {
+        let test = data[i - 1];
+        testItems = getSeqStampTestItems(test);
+        equal(cal.itip.getSequence(testItems[0], testItems[1]), test.expected, "(test #" + i + ")");
+    }
+}
+
+function getStamp_test() {
+    // assigning an empty string results in not having the property in the ics here. However, there
+    // must be always an dtStamp for item - if it's missing it will be set by the test code to make
+    // sure we get a valid ics
+    let data = [{
+        // !dtStamp && !xMozReceivedStamp => test default value
+        input: [{ item: { dtStamp: "", xMozReceivedStamp: "" } }],
+        expected: "20150909T181048Z"
+    }, {
+        // dtStamp && !xMozReceivedStamp => dtStamp
+        input: [{ item: { dtStamp: "20150910T181048Z", xMozReceivedStamp: "" } }],
+        expected: "20150910T181048Z"
+    }, {
+         // dtStamp && xMozReceivedStamp => xMozReceivedStamp
+        input: [{ item: { dtStamp: "20150909T181048Z", xMozReceivedStamp: "20150910T181048Z" } }],
+        expected: "20150910T181048Z"
+    }, {
+        input: [{ attendee: { receivedStamp: "" } }],
+        expected: null
+    }, {
+        input: [{ attendee: { receivedStamp: "20150910T181048Z" } }],
+        expected: "20150910T181048Z"
+    }];
+    for (let i = 1; i <= data.length; i++) {
+        let test = data[i - 1];
+        let result = cal.itip.getStamp(getSeqStampTestItems(test)[0]);
+        if (result) {
+            result = result.icalString;
+        }
+        equal(result, test.expected, "(test #" + i + ")");
+    }
+}
+
+function compareSequence_test() {
+    // it is sufficient to test here with sequence for items - full test coverage for
+    // x-moz-received-sequence is already provided by compareSequence_test
+    let data = [{
+        // item1.seq == item2.seq
+        input: [{ item: { sequence: "2" } },
+                { item: { sequence: "2" } }],
+        expected: 0
+    }, {
+        // item1.seq > item2.seq
+        input: [{ item: { sequence: "3" } },
+                { item: { sequence: "2" } }],
+        expected: 1
+    }, {
+        // item1.seq < item2.seq
+        input: [{ item: { sequence: "2" } },
+                { item: { sequence: "3" } }],
+        expected: -1
+    }, {
+        // attendee1.seq == attendee2.seq
+        input: [{ attendee: { receivedSeq: "2" } },
+                { attendee: { receivedSeq: "2" } }],
+        expected: 0
+    }, {
+        // attendee1.seq > attendee2.seq
+        input: [{ attendee: { receivedSeq: "3" } },
+                { attendee: { receivedSeq: "2" } }],
+        expected: 1
+    }, {
+        // attendee1.seq < attendee2.seq
+        input: [{ attendee: { receivedSeq: "2" } },
+                { attendee: { receivedSeq: "3" } }],
+        expected: -1
+    }, {
+        // item.seq == attendee.seq
+        input: [{ item: { sequence: "2" } },
+                { attendee: { receivedSeq: "2" } }],
+        expected: 0
+    }, {
+        // item.seq > attendee.seq
+        input: [{ item: { sequence: "3" } },
+                { attendee: { receivedSeq: "2" } }],
+        expected: 1
+    }, {
+        // item.seq < attendee.seq
+        input: [{ item: { sequence: "2" } },
+                { attendee: { receivedSeq: "3" } }],
+        expected: -1
+    }];
+    for (let i = 1; i <= data.length; i++) {
+        let test = data[i - 1];
+        testItems = getSeqStampTestItems(test);
+        equal(cal.itip.compareSequence(testItems[0], testItems[1]),
+              test.expected,
+              "(test #" + i + ")"
+        );
+    }
+}
+
+function compareStamp_test() {
+    // it is sufficient to test here with dtstamp for items - full test coverage for
+    // x-moz-received-stamp is already provided by compareStamp_test
+    let data = [{
+        // item1.stamp == item2.stamp
+        input: [{ item: { dtStamp: "20150910T181048Z" } },
+                { item: { dtStamp: "20150910T181048Z" } }],
+        expected: 0
+    }, {
+        // item1.stamp > item2.stamp
+        input: [{ item: { dtStamp: "20150911T181048Z" } },
+                { item: { dtStamp: "20150910T181048Z" } }],
+        expected: 1
+    }, {
+        // item1.stamp < item2.stamp
+        input: [{ item: { dtStamp: "20150910T181048Z" } },
+                { item: { dtStamp: "20150911T181048Z" } }],
+        expected: -1
+    }, {
+        // attendee1.stamp == attendee2.stamp
+        input: [{ attendee: { receivedStamp: "20150910T181048Z" } },
+                { attendee: { receivedStamp: "20150910T181048Z" } }],
+        expected: 0
+    }, {
+        // attendee1.stamp > attendee2.stamp
+        input: [{ attendee: { receivedStamp: "20150911T181048Z" } },
+                { attendee: { receivedStamp: "20150910T181048Z" } }],
+        expected: 1
+    }, {
+        // attendee1.stamp < attendee2.stamp
+        input: [{ attendee: { receivedStamp: "20150910T181048Z" } },
+                { attendee: { receivedStamp: "20150911T181048Z" } }],
+        expected: -1
+    }, {
+        // item.stamp == attendee.stamp
+        input: [{ item: { dtStamp: "20150910T181048Z" } },
+                { attendee: { receivedStamp: "20150910T181048Z" } }],
+        expected: 0
+    }, {
+        // item.stamp > attendee.stamp
+        input: [{ item: { dtStamp: "20150911T181048Z" } },
+                { attendee: { receivedStamp: "20150910T181048Z" } }],
+        expected: 1
+    }, {
+        // item.stamp < attendee.stamp
+        input: [{ item: { dtStamp: "20150910T181048Z" } },
+                { attendee: { receivedStamp: "20150911T181048Z" } }],
+        expected: -1
+    }];
+    for (let i = 1; i <= data.length; i++) {
+        let test = data[i - 1];
+        testItems = getSeqStampTestItems(test);
+        equal(cal.itip.compareStamp(testItems[0], testItems[1]),
+              test.expected,
+              "(test #" + i + ")"
+        );
+    }
+}
+
+function compare_test() {
+    // it is sufficient to test here with items only - full test coverage for attendees or
+    // item/attendee is already provided by compareSequence_test and compareStamp_test
+    let data = [{
+        // item1.seq == item2.seq && item1.stamp == item2.stamp
+        input: [{ item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+                { item: { sequence: "2", dtStamp: "20150910T181048Z" } }],
+        expected: 0
+    }, {
+        // item1.seq == item2.seq && item1.stamp > item2.stamp
+        input: [{ item: { sequence: "2", dtStamp: "20150911T181048Z" } },
+                { item: { sequence: "2", dtStamp: "20150910T181048Z" } }],
+        expected: 1
+    }, {
+        // item1.seq == item2.seq && item1.stamp < item2.stamp
+        input: [{ item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+                { item: { sequence: "2", dtStamp: "20150911T181048Z" } }],
+        expected: -1
+    }, {
+        // item1.seq > item2.seq && item1.stamp == item2.stamp
+        input: [{ item: { sequence: "3", dtStamp: "20150910T181048Z" } },
+                { item: { sequence: "2", dtStamp: "20150910T181048Z" } }],
+        expected: 1
+    }, {
+        // item1.seq > item2.seq && item1.stamp > item2.stamp
+        input: [{ item: { sequence: "3", dtStamp: "20150911T181048Z" } },
+                { item: { sequence: "2", dtStamp: "20150910T181048Z" } }],
+        expected: 1
+    }, {
+        // item1.seq > item2.seq && item1.stamp < item2.stamp
+        input: [{ item: { sequence: "3", dtStamp: "20150910T181048Z" } },
+                { item: { sequence: "2", dtStamp: "20150911T181048Z" } }],
+        expected: 1
+    }, {
+        // item1.seq < item2.seq && item1.stamp == item2.stamp
+        input: [{ item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+                { item: { sequence: "3", dtStamp: "20150910T181048Z" } }],
+        expected: -1
+    }, {
+        // item1.seq < item2.seq && item1.stamp > item2.stamp
+        input: [{ item: { sequence: "2", dtStamp: "20150911T181048Z" } },
+                { item: { sequence: "3", dtStamp: "20150910T181048Z" } }],
+        expected: -1
+    }, {
+        // item1.seq < item2.seq && item1.stamp < item2.stamp
+        input: [{ item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+                { item: { sequence: "3", dtStamp: "20150911T181048Z" } }],
+        expected: -1
+    }];
+    for (let i = 1; i <= data.length; i++) {
+        let test = data[i - 1];
+        testItems = getSeqStampTestItems(test);
+        equal(cal.itip.compare(testItems[0], testItems[1]),
+              test.expected,
+              "(test #" + i + ")"
+        );
+    }
+}
--- a/calendar/test/unit/test_calutils.js
+++ b/calendar/test/unit/test_calutils.js
@@ -1,25 +1,26 @@
 /* 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/. */
 
 Components.utils.import("resource://calendar/modules/calUtils.jsm");
 
+// tests for calUtils.jsm
+
 function run_test() {
     getAttendeeEmail_test();
+    getAttendeesBySender_test();
     getRecipientList_test();
     prependMailTo_test();
     removeMailTo_test();
     resolveDelegation_test();
     validateRecipientList_test();
 }
 
-// tests for calUtils.jsm
-
 function getAttendeeEmail_test() {
     let data = [{
         input: { id: "mailto:first.last@example.net", cname: "Last, First", email: null, useCn: true },
         expected: "\"Last, First\" <first.last@example.net>"
     }, {
         input: { id: "mailto:first.last@example.net", cname: "Last; First", email: null, useCn: true },
         expected: "\"Last; First\" <first.last@example.net>"
     }, {
@@ -54,16 +55,84 @@ function getAttendeeEmail_test() {
         }
         if (test.input.email) {
             attendee.setProperty("EMAIL", test.input.email);
         }
         equal(cal.getAttendeeEmail(attendee, test.input.useCn), test.expected, "(test #" + i + ")");
     }
 }
 
+function getAttendeesBySender_test() {
+    let data = [{
+        input: {
+            attendees: [{ id: "mailto:user1@example.net", sentBy: null },
+                        { id: "mailto:user2@example.net", sentBy: null }],
+            sender: "user1@example.net"
+        },
+        expected: ["mailto:user1@example.net"]
+    }, {
+        input: {
+            attendees: [{ id: "mailto:user1@example.net", sentBy: null },
+                        { id: "mailto:user2@example.net", sentBy: null }],
+            sender: "user3@example.net"
+        },
+        expected: []
+    }, {
+        input: {
+            attendees: [{ id: "mailto:user1@example.net", sentBy: "mailto:user3@example.net" },
+                        { id: "mailto:user2@example.net", sentBy: null }],
+            sender: "user3@example.net"
+        },
+        expected: ["mailto:user1@example.net"]
+    }, {
+        input: {
+            attendees: [{ id: "mailto:user1@example.net", sentBy: null },
+                        { id: "mailto:user2@example.net", sentBy: "mailto:user1@example.net" }],
+            sender: "user1@example.net"
+        },
+        expected: ["mailto:user1@example.net", "mailto:user2@example.net"]
+    }, {
+        input: { attendees: [], sender: "user1@example.net" },
+        expected: []
+    }, {
+        input: {
+            attendees: [{ id: "mailto:user1@example.net", sentBy: null },
+                        { id: "mailto:user2@example.net", sentBy: null }],
+            sender: ""
+        },
+        expected: []
+    }, {
+        input: {
+            attendees: [{ id: "mailto:user1@example.net", sentBy: null },
+                        { id: "mailto:user2@example.net", sentBy: null }],
+            sender: null
+        },
+        expected: []
+    }];
+
+    for (let i = 1; i <= data.length; i++) {
+        let test = data[i - 1];
+        let attendees = [];
+        for (let att of test.input.attendees) {
+            let attendee = cal.createAttendee();
+            attendee.id = att.id;
+            if (att.sentBy) {
+                attendee.setProperty("SENT-BY", att.sentBy);
+            }
+            attendees.push(attendee);
+        }
+        let detected = [];
+        cal.getAttendeesBySender(attendees, test.input.sender).forEach(att => {
+            detected.push(att.id);
+        });
+        ok(detected.every(aId => test.expected.includes(aId)), "(test #" + i + " ok1)");
+        ok(test.expected.every(aId => detected.includes(aId)), "(test #" + i + " ok2)");
+    }
+}
+
 function getRecipientList_test() {
     let data = [{
         input: [{ id: "mailto:first@example.net", cname: null },
                 { id: "mailto:second@example.net", cname: null },
                 { id: "mailto:third@example.net", cname: null }],
         expected: "first@example.net, second@example.net, third@example.net"
     }, {
         input: [{ id: "mailto:first@example.net", cname: "first example" },
--- a/calendar/test/unit/test_ltninvitationutils.js
+++ b/calendar/test/unit/test_ltninvitationutils.js
@@ -8,19 +8,19 @@ Components.utils.import("resource:///mod
 Components.utils.import("resource://gre/modules/Preferences.jsm");
 
 function run_test() {
     do_calendar_startup(run_next_test);
 }
 
 // tests for ltnInvitationUtils.jsm
 
-function getIcs() {
+function getIcs(aAsArray=false) {
     // we use an unfolded ics blueprint here to make replacing of properties easier
-    return [
+    let item = [
         "BEGIN:VCALENDAR",
         "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN",
         "VERSION:2.0",
         "METHOD:REQUEST",
         "BEGIN:VTIMEZONE",
         "TZID:Europe/Berlin",
         "BEGIN:DAYLIGHT",
         "TZOFFSETFROM:+0100",
@@ -43,96 +43,123 @@ function getIcs() {
         "DTSTAMP:20150909T181048Z",
         "UID:cb189fdc-ed47-4db6-a8d7-31a08802249d",
         "SUMMARY:Test Event",
         "ORGANIZER;RSVP=TRUE;CN=Organizer;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:organizer@example.net",
         "ATTENDEE;RSVP=TRUE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:attende" +
         "e@example.net",
         "DTSTART;TZID=Europe/Berlin:20150909T210000",
         "DTEND;TZID=Europe/Berlin:20150909T220000",
+        "SEQUENCE:1",
         "TRANSP:OPAQUE",
         "LOCATION:Room 1",
         "DESCRIPTION:Let us get together",
         "URL:http://www.example.com",
         "ATTACH:http://www.example.com",
         "END:VEVENT",
-        "END:VCALENDAR"].join("\r\n");
+        "END:VCALENDAR"];
+    if (!aAsArray) {
+        item = item.join("\r\n");
+    }
+    return item;
 }
 
 add_task(function* getItipHeader_test() {
     let data = [{
         input: {
             method: "METHOD:REQUEST\r\n",
-            attendee: null
+            attendees: [null]
         },
         expected: "Organizer has invited you to Test Event"
     }, {
         input: {
             method: "METHOD:CANCEL\r\n",
-            attendee: null
+            attendees: [null]
+        },
+        expected: "Organizer has canceled this event: Test Event"
+    }, {
+        input: {
+            method: "METHOD:DECLINECOUNTER\r\n",
+            attendees: ["ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=ACCEPTED;" +
+                        "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net"]
         },
-        expected: "Organizer has canceled this event: « Test Event »"
+        expected: "Organizer has declined your counterproposal for \"Test Event\"."
+    }, {
+        input: {
+            method: "METHOD:COUNTER\r\n",
+            attendees: ["ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=DECLINED;" +
+                        "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net"]
+        },
+        expected: "Attendee1 <attendee1@example.net> has made a counterproposal for \"Test Event\":"
     }, {
         input: {
             method: "METHOD:REPLY\r\n",
-            attendee: "ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=ACCEPTED;" +
-                      "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net"
+            attendees: ["ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=ACCEPTED;" +
+                        "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net"]
         },
         expected: "Attendee1 <attendee1@example.net> has accepted your event invitation."
     }, {
         input: {
             method: "METHOD:REPLY\r\n",
-            attendee: "ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=TENTATIVE;" +
-                      "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net"
+            attendees: ["ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=TENTATIVE;" +
+                        "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net"]
         },
         expected: "Attendee1 <attendee1@example.net> has accepted your event invitation."
     }, {
         input: {
             method: "METHOD:REPLY\r\n",
-            attendee: "ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=DECLINED;" +
-                      "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net"
+            attendees: ["ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=DECLINED;" +
+                        "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net"]
         },
         expected: "Attendee1 <attendee1@example.net> has declined your event invitation."
     }, {
         input: {
             method: "METHOD:REPLY\r\n",
-            attendee: ["ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=ACCEPTED;" +
-                       "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net",
-                       "ATTENDEE;RSVP=TRUE;CN=Attendee2;PARTSTAT=DECLINED;" +
-                       "ROLE=REQ-PARTICIPANT:mailto:attendee2@example.net"].join("\r\n")
+            attendees: ["ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=ACCEPTED;" +
+                        "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net",
+                        "ATTENDEE;RSVP=TRUE;CN=Attendee2;PARTSTAT=DECLINED;" +
+                        "ROLE=REQ-PARTICIPANT:mailto:attendee2@example.net"]
         },
         expected: "Attendee1 <attendee1@example.net> has accepted your event invitation."
     }, {
         input: {
             method: "METHOD:UNSUPPORTED\r\n",
-            attendee: null
+            attendees: [null]
         },
         expected: "Event Invitation"
     }, {
         input: {
             method: "",
-            attendee: ""
+            attendees: [""]
         },
         expected: "Event Invitation"
     }];
     let i = 0;
     for (let test of data) {
         i++;
         let itipItem = Components.classes["@mozilla.org/calendar/itip-item;1"]
                                  .createInstance(Components.interfaces.calIItipItem);
         let item = getIcs();
+        let sender;
         if (test.input.method || test.input.method == "") {
             item = item.replace(/METHOD:REQUEST\r\n/, test.input.method);
         }
-        if (test.input.attendee || test.input.attendee == "") {
-            item = item.replace(/(ATTENDEE.+(?:\r\n))/, test.input.attendee + "\r\n");
+        if (test.input.attendees.length) {
+            let attendees = test.input.attendees.filter(aAtt => !!aAtt).join("\r\n");
+            item = item.replace(/(ATTENDEE.+(?:\r\n))/, attendees + "\r\n");
+            if (test.input.attendees[0]) {
+                sender = cal.createAttendee();
+                sender.icalString = test.input.attendees[0];
+            }
         }
         itipItem.init(item);
-        equal(ltn.invitation.getItipHeader(itipItem), test.expected,
-              "(test #" + i + ")");
+        if (sender) {
+            itipItem.sender = sender.id;
+        }
+        equal(ltn.invitation.getItipHeader(itipItem), test.expected, "(test #" + i + ")");
     }
 });
 
 add_task(function* createInvitationOverlay_test() {
     let data = [{
         input: { description: "DESCRIPTION:Go to https://www.example.net if you can.\r\n" },
         expected: {
             node: "imipHtml-description-content",
@@ -767,8 +794,340 @@ add_task(function* getRfc5322FormattedDa
             Preferences.reset("calendar.timezone.local");
         }
         let date = test.date ? new Date(test.date) : null;
         let re = new RegExp(data.expected);
         ok(re.test(ltn.invitation.getRfc5322FormattedDate(date)), "(test #" + i + ")");
     }
     Preferences.set("calendar.timezone.local", timezone);
 });
+
+add_task(function* parseCounter_test() {
+    let data = [{
+        // #1: basic test to check all currently supported properties
+        input: {
+            existing: [],
+            proposed: [
+                {
+                    method: "METHOD:COUNTER"
+                }, {
+                    dtStart: "DTSTART;TZID=Europe/Berlin:20150910T210000"
+                }, {
+                    dtEnd: "DTEND;TZID=Europe/Berlin:20150910T220000"
+                }, {
+                    location: "LOCATION:Room 2"
+                }, {
+                    summary: "SUMMARY:Test Event 2"
+                }, {
+                    attendee: "ATTENDEE;CN=Attendee;PARTSTAT=DECLINED;ROLE=REQ-PARTICIPANT:" +
+                              "mailto:attendee@example.net"
+                }, {
+                    dtStamp: "DTSTAMP:20150909T182048Z"
+                }, {
+                    attach: "COMMENT:Sorry\, I cannot make it that time."
+                }
+            ]
+        },
+        expected: {
+            result: { descr: "", type: "OK" },
+            differences: [{
+                property: "SUMMARY",
+                proposed: "Test Event 2",
+                original: "Test Event"
+            }, {
+                property: "LOCATION",
+                proposed: "Room 2",
+                original: "Room 1"
+            }, {
+                property: "DTSTART",
+                proposed: ["Thursday, September 10, 2015 9:00 PM Europe/Berlin", // Automation Win
+                           "Thu 10 Sep 2015 9:00 PM Europe/Berlin", // Automation OSX
+                           "Thu 10 Sep 2015 09:00 PM Europe/Berlin", // Automation Linux
+                           "Thu 10 Sep 2015 21:00 Europe/Berlin"], // Local Win 24h
+                original: ["Wednesday, September 09, 2015 9:00 PM Europe/Berlin",
+                           "Wed 9 Sep 2015 9:00 PM Europe/Berlin",
+                           "Wed 9 Sep 2015 09:00 PM Europe/Berlin",
+                           "Wed 9 Sep 2015 21:00 Europe/Berlin"]
+            }, {
+                property: "DTEND",
+                proposed: ["Thursday, September 10, 2015 10:00 PM Europe/Berlin",
+                           "Thu 10 Sep 2015 10:00 PM Europe/Berlin",
+                           "Thu 10 Sep 2015 22:00 Europe/Berlin"],
+                original: ["Wednesday, September 09, 2015 10:00 PM Europe/Berlin",
+                           "Wed 9 Sep 2015 10:00 PM Europe/Berlin",
+                           "Wed 9 Sep 2015 22:00 Europe/Berlin"]
+            }, {
+                property: "COMMENT",
+                proposed: "Sorry\, I cannot make it that time.",
+                original: null
+            }]
+        }
+    }, {
+        // #2: test with an unsupported property has been changed
+        input: {
+            existing: [],
+            proposed: [
+                {
+                    method: "METHOD:COUNTER"
+                }, {
+                    attendee: "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" +
+                              "mailto:attendee@example.net"
+                }, {
+                    location: "LOCATION:Room 2"
+                }, {
+                    attach: "ATTACH:http://www.example2.com"
+                } ,{
+                    dtStamp: "DTSTAMP:20150909T182048Z"
+                }]
+        },
+        expected: {
+            result: { descr: "", type: "OK" },
+            differences: [{ property: "LOCATION", proposed: "Room 2", original: "Room 1" }]
+        }
+    }, {
+        // #3: proposed change not based on the latest update of the invitation
+        input: {
+            existing: [],
+            proposed: [
+                {
+                    method: "METHOD:COUNTER"
+                }, {
+                    attendee: "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" +
+                              "mailto:attendee@example.net"
+                }, {
+                    location: "LOCATION:Room 2"
+                }, {
+                    dtStamp: "DTSTAMP:20150909T171048Z"
+                }
+            ]
+        },
+        expected: {
+            result: {
+                descr: "This is a counterproposal not based on the latest event update.",
+                type: "NOTLATESTUPDATE"
+            },
+            differences: [{ property: "LOCATION", proposed: "Room 2", original: "Room 1" }]
+        }
+    }, {
+        // #4: proposed change based on a meanwhile reschuled invitation
+        input: {
+            existing: [],
+            proposed: [
+                {
+                    method: "METHOD:COUNTER"
+                }, {
+                    attendee: "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" +
+                              "mailto:attendee@example.net"
+                }, {
+                    location: "LOCATION:Room 2"
+                }, {
+                    sequence: "SEQUENCE:0"
+                }, {
+                    dtStamp: "DTSTAMP:20150909T182048Z"
+                }
+            ]
+        },
+        expected: {
+            result: {
+                descr: "This is a counterproposal to an already rescheduled event.",
+                type: "OUTDATED"
+            },
+            differences: [{ property: "LOCATION", proposed: "Room 2", original: "Room 1" }]
+        }
+    }, {
+        // #5: proposed change for an later sequence of the event
+        input: {
+            existing: [],
+            proposed: [
+                {
+                    method: "METHOD:COUNTER"
+                }, {
+                    attendee: "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" +
+                              "mailto:attendee@example.net"
+                }, {
+                    location: "LOCATION:Room 2"
+                }, {
+                    sequence: "SEQUENCE:2"
+                }, {
+                    dtStamp: "DTSTAMP:20150909T182048Z"
+                }
+            ]
+        },
+        expected: {
+            result: {
+                descr: "Invalid sequence number in counterproposal.",
+                type: "ERROR"
+            },
+            differences: []
+        }
+    }, {
+        // #6: proposal to a different event
+        input: {
+            existing: [],
+            proposed: [
+                {
+                    method: "METHOD:COUNTER"
+                }, {
+                    uid: "UID:cb189fdc-0000-0000-0000-31a08802249d"
+                }, {
+                    attendee: "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" +
+                              "mailto:attendee@example.net"
+                }, {
+                    location: "LOCATION:Room 2"
+                }, {
+                    dtStamp: "DTSTAMP:20150909T182048Z"
+                }
+            ]
+        },
+        expected: {
+            result: {
+                descr: "Mismatch of uid or organizer in counterproposal.",
+                type: "ERROR"
+            },
+            differences: []
+        }
+    }, {
+        // #7: proposal with a different organizer
+        input: {
+            existing: [],
+            proposed: [
+                {
+                    method: "METHOD:COUNTER"
+                }, {
+                    organizer: "ORGANIZER;RSVP=TRUE;CN=Organizer;PARTSTAT=ACCEPTED;ROLE=CHAI" +
+                               "R:mailto:organizer2@example.net"
+                }, {
+                    attendee: "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" +
+                              "mailto:attendee@example.net"
+                }, {
+                    dtStamp: "DTSTAMP:20150909T182048Z"
+                }
+            ]
+        },
+        expected: {
+            result: {
+                descr: "Mismatch of uid or organizer in counterproposal.",
+                type: "ERROR"
+            },
+            differences: []
+        }
+    }, {
+        // #8:counterproposal without any difference
+        input: {
+            existing: [],
+            proposed: [{ method: "METHOD:COUNTER" }] },
+        expected: {
+            result: {
+                descr: "No difference in counterproposal detected.",
+                type: "NODIFF"
+            },
+            differences: []
+        }
+    }];
+
+    let getItem = function(aProperties) {
+        let item = getIcs(true);
+
+        let modifyProperty = function(aRegex, aReplacement, aInVevent) {
+            let inVevent = false;
+            let i = 0;
+            item.forEach(aProp => {
+                if (aProp == "BEGIN:VEVENT" && !inVevent) {
+                    inVevent = true;
+                } else if (aProp == "END:VEVENT" && inVevent) {
+                    inVevent = false;
+                }
+                if ((aInVevent && inVevent) || !aInVevent) {
+                    item[i] = aProp.replace(aRegex, aReplacement);
+                }
+                i++;
+            });
+        };
+
+        if (aProperties) {
+            aProperties.forEach(aProp => {
+                if ("method" in aProp && aProp.method) {
+                    modifyProperty(/(METHOD.+)/, aProp.method, false);
+                } else if ("attendee" in aProp && aProp.attendee) {
+                    modifyProperty(/(ATTENDEE.+)/, aProp.attendee, true);
+                } else if ("attach" in aProp && aProp.attach) {
+                    modifyProperty(/(ATTACH.+)/, aProp.attach, true);
+                } else if ("summary" in aProp && aProp.summary) {
+                    modifyProperty(/(SUMMARY.+)/, aProp.summary, true);
+                } else if ("location" in aProp && aProp.location) {
+                    modifyProperty(/(LOCATION.+)/, aProp.location, true);
+                } else if ("dtStart" in aProp && aProp.dtStart) {
+                    modifyProperty(/(DTSTART.+)/, aProp.dtStart, true);
+                } else if ("dtEnd" in aProp && aProp.dtEnd) {
+                    modifyProperty(/(DTEND.+)/, aProp.dtEnd, true);
+                } else if ("sequence" in aProp && aProp.sequence) {
+                    modifyProperty(/(SEQUENCE.+)/, aProp.sequence, true);
+                } else if ("dtStamp" in aProp && aProp.dtStamp) {
+                    modifyProperty(/(DTSTAMP.+)/, aProp.dtStamp, true);
+                } else if ("organizer" in aProp && aProp.organizer) {
+                    modifyProperty(/(ORGANIZER.+)/, aProp.organizer, true);
+                } else if ("uid" in aProp && aProp.uid) {
+                    modifyProperty(/(UID.+)/, aProp.uid, true);
+                }
+            });
+        }
+        item = item.join("\r\n");
+        return createEventFromIcalString(item);
+    };
+
+    let formatDt = function (aDateTime) {
+        let datetime = getDateFormatter().formatDateTime(aDateTime);
+        return datetime += " " + aDateTime.timezone.displayName;
+    };
+
+    for (let i = 1; i <= data.length; i++) {
+        let test = data[i - 1];
+        let existingItem = getItem(test.input.existing);
+        let proposedItem = getItem(test.input.proposed);
+        let parsed = ltn.invitation.parseCounter(proposedItem, existingItem);
+
+        equal(parsed.result.type, test.expected.result.type, "(test #" + i + ": result.type)");
+        equal(parsed.result.descr, test.expected.result.descr, "(test #" + i + ": result.descr)");
+        let parsedProps = [];
+        let additionalProps = [];
+        let missingProps = [];
+        parsed.differences.forEach(aDiff => {
+            let expected = test.expected.differences.filter(bDiff => bDiff.property == aDiff.property);
+            if (expected.length == 1) {
+                if (["DTSTART", "DTEND"].includes(aDiff.property)) {
+                    let prop = aDiff.proposed ? formatDt(aDiff.proposed) : null;
+                    ok(
+                        prop && expected[0].proposed.includes(prop),
+                        "(test #" + i + ": difference " + aDiff.property + ": proposed '" + prop + "')"
+                    );
+                    prop = aDiff.original ? formatDt(aDiff.original) : null
+                    ok(
+                        prop && expected[0].original.includes(prop),
+                        "(test #" + i + ": difference " + aDiff.property + ": original '" + prop + "')"
+                    );
+                } else {
+                    equal(
+                        aDiff.proposed,
+                        expected[0].proposed,
+                        "(test #" + i + ": difference " + aDiff.property + ": proposed)"
+                    );
+                    equal(
+                        aDiff.original,
+                        expected[0].original,
+                        "(test #" + i + ": difference " + aDiff.property + ": original)"
+                    );
+                }
+                parsedProps.push(aDiff.property);
+            } else if (expected.length == 0) {
+                additionalProps.push(aDiff.property);
+            }
+        });
+        test.expected.differences.forEach(aDiff => {
+            if (!parsedProps.includes(aDiff.property)) {
+                missingProps.push(aDiff.property);
+            }
+        });
+        ok(additionalProps.length == 0, "(test #" + i + ": differences: check for unexpectedly "+
+                                        "occurring additional properties " + additionalProps + ")");
+        ok(missingProps.length == 0, "(test #" + i + ": differences: check for unexpectedly " +
+                                     "missing properties " + missingProps + ")");
+    }
+});
--- a/calendar/test/unit/xpcshell-shared.ini
+++ b/calendar/test/unit/xpcshell-shared.ini
@@ -14,16 +14,17 @@
 [test_bug486186.js]
 [test_bug494140.js]
 [test_bug523860.js]
 [test_bug653924.js]
 [test_bug668222.js]
 [test_bug759324.js]
 [test_calmgr.js]
 [test_calutils.js]
+[test_calitiputils.js]
 [test_datetime.js]
 [test_datetime_before_1970.js]
 [test_deleted_items.js]
 [test_duration.js]
 [test_extract.js]
 [test_freebusy.js]
 [test_freebusy_service.js]
 [test_gdata_provider.js]