1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5const EXPORTED_SYMBOLS = ["CalItipMessageSender"];
6
7const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
8const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
9const { CalItipOutgoingMessage } = ChromeUtils.import(
10  "resource:///modules/CalItipOutgoingMessage.jsm"
11);
12
13/**
14 * CalItipMessageSender is responsible for sending out the appropriate iTIP
15 * messages when changes have been made to an invitation event.
16 */
17class CalItipMessageSender {
18  /**
19   * A list of CalItipOutgoingMessages to send out.
20   */
21  pendingMessages = [];
22
23  /**
24   * @param {?calIItemBase} originalItem - The original invitation item before
25   *  it is modified.
26   *
27   * @param {?calIAttendee} invitedAttendee - For incomming invitations, this is
28   *  the attendee that was invited (corresponding to an installed identity).
29   */
30  constructor(originalItem, invitedAttendee) {
31    this.originalItem = originalItem;
32    this.invitedAttendee = invitedAttendee;
33  }
34
35  /**
36   * Provides the count of CalItipOutgoingMessages ready to be sent.
37   */
38  get pendingMessageCount() {
39    return this.pendingMessages.length;
40  }
41
42  /**
43   * Detects whether the passed invitation item has been modified from the
44   * original (attendees added/removed, item deleted etc.) thus requiring iTIP
45   * messages to be sent.
46   *
47   * This method should be called before send().
48   *
49   * @param {Number} opType - Type of operation - (e.g. ADD, MODIFY or DELETE)
50   * @param {calIItemBase} item - The updated item.
51   * @param {?object} extResponse - An object to provide additional
52   *  parameters for sending itip messages as response mode, comments or a
53   *  subset of recipients.
54   * @param {number} extResponse.responseMode - Response mode as defined for
55   *  autoResponse of calIItipItem.
56   *
57   *  The default mode is USER (which will trigger displaying the previously
58   *  known popup to ask the user whether to send)
59   *
60   * @returns {number} - The number of messages to be sent.
61   */
62  detectChanges(opType, item, extResponse = null) {
63    let { originalItem, invitedAttendee } = this;
64
65    // balance out parts of the modification vs delete confusion, deletion of occurrences
66    // are notified as parent modifications and modifications of occurrences are notified
67    // as mixed new-occurrence, old-parent (IIRC).
68    if (originalItem && item.recurrenceInfo) {
69      if (originalItem.recurrenceId && !item.recurrenceId) {
70        // sanity check: assure item doesn't refer to the master
71        item = item.recurrenceInfo.getOccurrenceFor(originalItem.recurrenceId);
72        cal.ASSERT(item, "unexpected!");
73        if (!item) {
74          return this.pendingMessageCount;
75        }
76      }
77
78      if (originalItem.recurrenceInfo && item.recurrenceInfo) {
79        // check whether the two differ only in EXDATEs
80        let clonedItem = item.clone();
81        let exdates = [];
82        for (let ritem of clonedItem.recurrenceInfo.getRecurrenceItems()) {
83          let wrappedRItem = cal.wrapInstance(ritem, Ci.calIRecurrenceDate);
84          if (
85            ritem.isNegative &&
86            wrappedRItem &&
87            !originalItem.recurrenceInfo.getRecurrenceItems().some(recitem => {
88              let wrappedR = cal.wrapInstance(recitem, Ci.calIRecurrenceDate);
89              return (
90                recitem.isNegative && wrappedR && wrappedR.date.compare(wrappedRItem.date) == 0
91              );
92            })
93          ) {
94            exdates.push(wrappedRItem);
95          }
96        }
97        if (exdates.length > 0) {
98          // check whether really only EXDATEs have been added:
99          let recInfo = clonedItem.recurrenceInfo;
100          exdates.forEach(recInfo.deleteRecurrenceItem, recInfo);
101          if (cal.item.compareContent(clonedItem, originalItem)) {
102            // transition into "delete occurrence(s)"
103            // xxx todo: support multiple
104            item = originalItem.recurrenceInfo.getOccurrenceFor(exdates[0].date);
105            originalItem = null;
106            opType = Ci.calIOperationListener.DELETE;
107          }
108        }
109      }
110    }
111
112    // for backward compatibility, we assume USER mode if not set otherwise
113    let autoResponse = { mode: Ci.calIItipItem.USER };
114    if (extResponse && extResponse.hasOwnProperty("responseMode")) {
115      switch (extResponse.responseMode) {
116        case Ci.calIItipItem.AUTO:
117        case Ci.calIItipItem.NONE:
118        case Ci.calIItipItem.USER:
119          autoResponse.mode = extResponse.responseMode;
120          break;
121        default:
122          cal.ERROR(
123            "cal.itip.checkAndSend(): Invalid value " +
124              extResponse.responseMode +
125              " provided for responseMode attribute in argument extResponse." +
126              " Falling back to USER mode.\r\n" +
127              cal.STACK(20)
128          );
129      }
130    } else if ((originalItem && originalItem.getAttendees().length) || item.getAttendees().length) {
131      // let's log something useful to notify addon developers or find any
132      // missing pieces in the conversions if the current or original item
133      // has attendees - the latter is to prevent logging if creating events
134      // by click and slide in day or week views
135      cal.LOG(
136        "cal.itip.checkAndSend: no response mode provided, " +
137          "falling back to USER mode.\r\n" +
138          cal.STACK(20)
139      );
140    }
141    if (autoResponse.mode == Ci.calIItipItem.NONE) {
142      // we stop here and don't send anything if the user opted out before
143      return this.pendingMessageCount;
144    }
145
146    if (invitedAttendee) {
147      // actually is an invitation copy, fix attendee list to send REPLY
148      /* We check if the attendee id matches one of of the
149       * userAddresses. If they aren't equal, it means that
150       * someone is accepting invitations on behalf of an other user. */
151      if (item.calendar.aclEntry) {
152        let userAddresses = item.calendar.aclEntry.getUserAddresses();
153        if (
154          userAddresses.length > 0 &&
155          !cal.email.attendeeMatchesAddresses(invitedAttendee, userAddresses)
156        ) {
157          invitedAttendee = invitedAttendee.clone();
158          invitedAttendee.setProperty("SENT-BY", "mailto:" + userAddresses[0]);
159        }
160      }
161      if (item.organizer) {
162        let origInvitedAttendee = originalItem && originalItem.getAttendeeById(invitedAttendee.id);
163
164        if (opType == Ci.calIOperationListener.DELETE) {
165          // in case the attendee has just deleted the item, we want to send out a DECLINED REPLY:
166          origInvitedAttendee = invitedAttendee;
167          invitedAttendee = invitedAttendee.clone();
168          invitedAttendee.participationStatus = "DECLINED";
169        }
170
171        // We want to send a REPLY send if:
172        // - there has been a PARTSTAT change
173        // - in case of an organizer SEQUENCE bump we'd go and reconfirm our PARTSTAT
174        if (
175          !origInvitedAttendee ||
176          origInvitedAttendee.participationStatus != invitedAttendee.participationStatus ||
177          (originalItem && cal.itip.getSequence(item) != cal.itip.getSequence(originalItem))
178        ) {
179          item = item.clone();
180          item.removeAllAttendees();
181          item.addAttendee(invitedAttendee);
182          // we remove X-MS-OLK-SENDER to avoid confusing Outlook 2007+ (w/o Exchange)
183          // about the notification sender (see bug 603933)
184          item.deleteProperty("X-MS-OLK-SENDER");
185
186          // Do not send the X-MOZ-INVITED-ATTENDEE property.
187          item.deleteProperty("X-MOZ-INVITED-ATTENDEE");
188
189          // if the event was delegated to the replying attendee, we may also notify also
190          // the delegator due to chapter 3.2.2.3. of RfC 5546
191          let replyTo = [];
192          let delegatorIds = invitedAttendee.getProperty("DELEGATED-FROM");
193          if (
194            delegatorIds &&
195            Services.prefs.getBoolPref("calendar.itip.notifyDelegatorOnReply", false)
196          ) {
197            let getDelegator = function(aDelegatorId) {
198              let delegator = originalItem.getAttendeeById(aDelegatorId);
199              if (delegator) {
200                replyTo.push(delegator);
201              }
202            };
203            // Our backends currently do not support multi-value params. libical just
204            // swallows any value but the first, while ical.js fails to parse the item
205            // at all. Single values are handled properly by both backends though.
206            // Once bug 1206502 lands, ical.js will handle multi-value params, but
207            // we end up in different return types of getProperty. A native exposure of
208            // DELEGATED-FROM and DELEGATED-TO in calIAttendee may change this.
209            if (Array.isArray(delegatorIds)) {
210              for (let delegatorId of delegatorIds) {
211                getDelegator(delegatorId);
212              }
213            } else if (typeof delegatorIds == "string") {
214              getDelegator(delegatorIds);
215            }
216          }
217          replyTo.push(item.organizer);
218          this.pendingMessages.push(
219            new CalItipOutgoingMessage("REPLY", replyTo, item, invitedAttendee, autoResponse)
220          );
221        }
222      }
223      return this.pendingMessageCount;
224    }
225
226    if (item.getProperty("X-MOZ-SEND-INVITATIONS") != "TRUE") {
227      // Only send invitations/cancellations
228      // if the user checked the checkbox
229      this.pendingMessages = [];
230      return this.pendingMessageCount;
231    }
232
233    // special handling for invitation with event status cancelled
234    if (item.getAttendees().length > 0 && item.getProperty("STATUS") == "CANCELLED") {
235      if (cal.itip.getSequence(item) > 0) {
236        // make sure we send a cancellation and not an request
237        opType = Ci.calIOperationListener.DELETE;
238      } else {
239        // don't send an invitation, if the event was newly created and has status cancelled
240        this.pendingMessages = [];
241        return this.pendingMessageCount;
242      }
243    }
244
245    if (opType == Ci.calIOperationListener.DELETE) {
246      this.pendingMessages.push(
247        new CalItipOutgoingMessage("CANCEL", item.getAttendees(), item, null, autoResponse)
248      );
249      return this.pendingMessageCount;
250    } // else ADD, MODIFY:
251
252    let originalAtt = originalItem ? originalItem.getAttendees() : [];
253    let itemAtt = item.getAttendees();
254    let canceledAttendees = [];
255    let addedAttendees = [];
256
257    if (itemAtt.length > 0 || originalAtt.length > 0) {
258      let attMap = {};
259      for (let att of originalAtt) {
260        attMap[att.id.toLowerCase()] = att;
261      }
262
263      for (let att of itemAtt) {
264        if (att.id.toLowerCase() in attMap) {
265          // Attendee was in original item.
266          delete attMap[att.id.toLowerCase()];
267        } else {
268          // Attendee only in new item
269          addedAttendees.push(att);
270        }
271      }
272
273      for (let id in attMap) {
274        let cancAtt = attMap[id];
275        canceledAttendees.push(cancAtt);
276      }
277    }
278
279    // Check to see if some part of the item was updated, if so, re-send REQUEST
280    if (!originalItem || cal.itip.compare(item, originalItem) > 0) {
281      // REQUEST
282      // check whether it's a simple UPDATE (no SEQUENCE change) or real (RE)REQUEST,
283      // in case of time or location/description change.
284      let isMinorUpdate =
285        originalItem && cal.itip.getSequence(item) == cal.itip.getSequence(originalItem);
286
287      if (
288        !isMinorUpdate ||
289        !cal.item.compareContent(stripUserData(item), stripUserData(originalItem))
290      ) {
291        let requestItem = item.clone();
292        if (!requestItem.organizer) {
293          requestItem.organizer = cal.itip.createOrganizer(requestItem.calendar);
294        }
295
296        // Fix up our attendees for invitations using some good defaults
297        let recipients = [];
298        let reqItemAtt = requestItem.getAttendees();
299        if (!isMinorUpdate) {
300          requestItem.removeAllAttendees();
301        }
302        for (let attendee of reqItemAtt) {
303          if (!isMinorUpdate) {
304            attendee = attendee.clone();
305            if (!attendee.role) {
306              attendee.role = "REQ-PARTICIPANT";
307            }
308            attendee.participationStatus = "NEEDS-ACTION";
309            attendee.rsvp = "TRUE";
310            requestItem.addAttendee(attendee);
311          }
312          recipients.push(attendee);
313        }
314
315        // if send out should be limited to newly added attendees and no major
316        // props (attendee is not such) have changed, only the respective attendee
317        // is added to the recipient list while the attendee information in the
318        // ical is left to enable the new attendee to see who else is attending
319        // the event (if not prevented otherwise)
320        if (
321          isMinorUpdate &&
322          addedAttendees.length > 0 &&
323          Services.prefs.getBoolPref("calendar.itip.updateInvitationForNewAttendeesOnly", false)
324        ) {
325          recipients = addedAttendees;
326        }
327
328        if (recipients.length > 0) {
329          this.pendingMessages.push(
330            new CalItipOutgoingMessage("REQUEST", recipients, requestItem, null, autoResponse)
331          );
332        }
333      }
334    }
335
336    // Cancel the event for all canceled attendees
337    if (canceledAttendees.length > 0) {
338      let cancelItem = originalItem.clone();
339      cancelItem.removeAllAttendees();
340      for (let att of canceledAttendees) {
341        cancelItem.addAttendee(att);
342      }
343      this.pendingMessages.push(
344        new CalItipOutgoingMessage("CANCEL", canceledAttendees, cancelItem, null, autoResponse)
345      );
346    }
347    return this.pendingMessageCount;
348  }
349
350  /**
351   * Sends the iTIP message using the item's calendar transport. This method
352   * should be called after detectChanges().
353   *
354   * @param {calIItipTransport} [transport] - An optional transport to use
355   *  instead of the one provided by the item's calendar.
356   *
357   * @return {boolean} - True, if the message could be sent.
358   */
359  send(transport) {
360    return this.pendingMessages.every(msg => msg.send(transport));
361  }
362}
363
364/**
365 * Strips user specific data, e.g. categories and alarm settings and returns the stripped item.
366 *
367 * @param {calIItemBase} item_ - The item to strip data from
368 * @return {calIItemBase} - The stripped item
369 */
370function stripUserData(item_) {
371  let item = item_.clone();
372  let stamp = item.stampTime;
373  let lastModified = item.lastModifiedTime;
374  item.clearAlarms();
375  item.alarmLastAck = null;
376  item.setCategories([]);
377  item.deleteProperty("RECEIVED-SEQUENCE");
378  item.deleteProperty("RECEIVED-DTSTAMP");
379  for (let [name] of item.properties) {
380    let pname = name;
381    if (pname.substr(0, "X-MOZ-".length) == "X-MOZ-") {
382      item.deleteProperty(name);
383    }
384  }
385  item.getAttendees().forEach(att => {
386    att.deleteProperty("RECEIVED-SEQUENCE");
387    att.deleteProperty("RECEIVED-DTSTAMP");
388  });
389
390  // according to RfC 6638, the following items must not be exposed in client side
391  // scheduling messages, so let's remove it if present
392  let removeSchedulingParams = aCalUser => {
393    aCalUser.deleteProperty("SCHEDULE-AGENT");
394    aCalUser.deleteProperty("SCHEDULE-FORCE-SEND");
395    aCalUser.deleteProperty("SCHEDULE-STATUS");
396  };
397  item.getAttendees().forEach(removeSchedulingParams);
398  if (item.organizer) {
399    removeSchedulingParams(item.organizer);
400  }
401
402  item.setProperty("DTSTAMP", stamp);
403  item.setProperty("LAST-MODIFIED", lastModified); // need to be last to undirty the item
404  return item;
405}
406