summaryrefslogtreecommitdiffstats
path: root/comm/calendar/itip/CalItipMessageSender.jsm
blob: 373f9b728cfa50ea6899ab64571e1dcef431781e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
/* 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/. */

const EXPORTED_SYMBOLS = ["CalItipMessageSender"];

const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
const { CalItipOutgoingMessage } = ChromeUtils.import(
  "resource:///modules/CalItipOutgoingMessage.jsm"
);

/**
 * CalItipMessageSender is responsible for sending out the appropriate iTIP
 * messages when changes have been made to an invitation event.
 */
class CalItipMessageSender {
  /**
   * A list of CalItipOutgoingMessages to send out.
   */
  pendingMessages = [];

  /**
   * @param {?calIItemBase} originalItem - The original invitation item before
   *  it is modified.
   *
   * @param {?calIAttendee} invitedAttendee - For incoming invitations, this is
   *   the attendee that was invited (corresponding to an installed identity).
   *   For outgoing invitations, this should be `null`.
   */
  constructor(originalItem, invitedAttendee) {
    this.originalItem = originalItem;
    this.invitedAttendee = invitedAttendee;
  }

  /**
   * Provides the count of CalItipOutgoingMessages ready to be sent.
   */
  get pendingMessageCount() {
    return this.pendingMessages.length;
  }

  /**
   * Builds a list of iTIP messages to be sent as a result of operations on a
   * calendar item, based on the current user's role and any modifications to
   * the item.
   *
   * This method should be called before send().
   *
   * @param {number} opType - Type of operation - (e.g. ADD, MODIFY or DELETE)
   * @param {calIItemBase} item - The updated item.
   * @param {?object} extResponse - An object to provide additional
   *  parameters for sending itip messages as response mode, comments or a
   *  subset of recipients.
   * @param {number} extResponse.responseMode - Response mode as defined for
   *  autoResponse of calIItipItem.
   *
   *  The default mode is USER (which will trigger displaying the previously
   *  known popup to ask the user whether to send)
   *
   * @returns {number} - The number of messages to be sent.
   */
  buildOutgoingMessages(opType, item, extResponse = null) {
    let { originalItem, invitedAttendee } = this;

    // balance out parts of the modification vs delete confusion, deletion of occurrences
    // are notified as parent modifications and modifications of occurrences are notified
    // as mixed new-occurrence, old-parent (IIRC).
    if (originalItem && item.recurrenceInfo) {
      if (originalItem.recurrenceId && !item.recurrenceId) {
        // sanity check: assure item doesn't refer to the master
        item = item.recurrenceInfo.getOccurrenceFor(originalItem.recurrenceId);
        if (!item) {
          return this.pendingMessageCount;
        }
        // Use the calIAttendee instance from the occurrence in case there is a
        // difference in participationStatus between it and the parent.
        if (invitedAttendee) {
          invitedAttendee = item.getAttendeeById(invitedAttendee.id);
        }
      }

      if (originalItem.recurrenceInfo && item.recurrenceInfo) {
        // check whether the two differ only in EXDATEs
        let clonedItem = item.clone();
        let exdates = [];
        for (let ritem of clonedItem.recurrenceInfo.getRecurrenceItems()) {
          let wrappedRItem = cal.wrapInstance(ritem, Ci.calIRecurrenceDate);
          if (
            ritem.isNegative &&
            wrappedRItem &&
            !originalItem.recurrenceInfo.getRecurrenceItems().some(recitem => {
              let wrappedR = cal.wrapInstance(recitem, Ci.calIRecurrenceDate);
              return (
                recitem.isNegative && wrappedR && wrappedR.date.compare(wrappedRItem.date) == 0
              );
            })
          ) {
            exdates.push(wrappedRItem);
          }
        }
        if (exdates.length > 0) {
          // check whether really only EXDATEs have been added:
          let recInfo = clonedItem.recurrenceInfo;
          exdates.forEach(recInfo.deleteRecurrenceItem, recInfo);
          if (cal.item.compareContent(clonedItem, originalItem)) {
            // transition into "delete occurrence(s)"
            // xxx todo: support multiple
            item = originalItem.recurrenceInfo.getOccurrenceFor(exdates[0].date);
            originalItem = null;
            opType = Ci.calIOperationListener.DELETE;
          }
        }
      }
    }

    // for backward compatibility, we assume USER mode if not set otherwise
    let autoResponse = { mode: Ci.calIItipItem.USER };
    if (extResponse && extResponse.hasOwnProperty("responseMode")) {
      switch (extResponse.responseMode) {
        case Ci.calIItipItem.AUTO:
        case Ci.calIItipItem.NONE:
        case Ci.calIItipItem.USER:
          autoResponse.mode = extResponse.responseMode;
          break;
        default:
          cal.ERROR(
            "cal.itip.checkAndSend(): Invalid value " +
              extResponse.responseMode +
              " provided for responseMode attribute in argument extResponse." +
              " Falling back to USER mode.\r\n" +
              cal.STACK(20)
          );
      }
    } else if ((originalItem && originalItem.getAttendees().length) || item.getAttendees().length) {
      // let's log something useful to notify addon developers or find any
      // missing pieces in the conversions if the current or original item
      // has attendees - the latter is to prevent logging if creating events
      // by click and slide in day or week views
      cal.LOG(
        "cal.itip.checkAndSend: no response mode provided, " +
          "falling back to USER mode.\r\n" +
          cal.STACK(20)
      );
    }
    if (autoResponse.mode == Ci.calIItipItem.NONE) {
      // we stop here and don't send anything if the user opted out before
      return this.pendingMessageCount;
    }

    // If an "invited attendee" (i.e., the current user) is present, we assume
    // that this is an incoming invite and that we should send only a REPLY if
    // needed.
    if (invitedAttendee) {
      /* We check if the attendee id matches one of of the
       * userAddresses. If they aren't equal, it means that
       * someone is accepting invitations on behalf of an other user. */
      if (item.calendar.aclEntry) {
        let userAddresses = item.calendar.aclEntry.getUserAddresses();
        if (
          userAddresses.length > 0 &&
          !cal.email.attendeeMatchesAddresses(invitedAttendee, userAddresses)
        ) {
          invitedAttendee = invitedAttendee.clone();
          invitedAttendee.setProperty("SENT-BY", cal.email.prependMailTo(userAddresses[0]));
        }
      }

      if (item.organizer) {
        let origInvitedAttendee = originalItem && originalItem.getAttendeeById(invitedAttendee.id);

        if (opType == Ci.calIOperationListener.DELETE) {
          // in case the attendee has just deleted the item, we want to send out a DECLINED REPLY:
          origInvitedAttendee = invitedAttendee;
          invitedAttendee = invitedAttendee.clone();
          invitedAttendee.participationStatus = "DECLINED";
        }

        // We want to send a REPLY send if:
        // - there has been a PARTSTAT change
        // - in case of an organizer SEQUENCE bump we'd go and reconfirm our PARTSTAT
        if (
          !origInvitedAttendee ||
          origInvitedAttendee.participationStatus != invitedAttendee.participationStatus ||
          (originalItem && cal.itip.getSequence(item) != cal.itip.getSequence(originalItem))
        ) {
          item = item.clone();
          item.removeAllAttendees();
          item.addAttendee(invitedAttendee);
          // we remove X-MS-OLK-SENDER to avoid confusing Outlook 2007+ (w/o Exchange)
          // about the notification sender (see bug 603933)
          item.deleteProperty("X-MS-OLK-SENDER");

          // Do not send the X-MOZ-INVITED-ATTENDEE property.
          item.deleteProperty("X-MOZ-INVITED-ATTENDEE");

          // if the event was delegated to the replying attendee, we may also notify also
          // the delegator due to chapter 3.2.2.3. of RfC 5546
          let replyTo = [];
          let delegatorIds = invitedAttendee.getProperty("DELEGATED-FROM");
          if (
            delegatorIds &&
            Services.prefs.getBoolPref("calendar.itip.notifyDelegatorOnReply", false)
          ) {
            let getDelegator = function (aDelegatorId) {
              let delegator = originalItem.getAttendeeById(aDelegatorId);
              if (delegator) {
                replyTo.push(delegator);
              }
            };
            // Our backends currently do not support multi-value params. libical just
            // swallows any value but the first, while ical.js fails to parse the item
            // at all. Single values are handled properly by both backends though.
            // Once bug 1206502 lands, ical.js will handle multi-value params, but
            // we end up in different return types of getProperty. A native exposure of
            // DELEGATED-FROM and DELEGATED-TO in calIAttendee may change this.
            if (Array.isArray(delegatorIds)) {
              for (let delegatorId of delegatorIds) {
                getDelegator(delegatorId);
              }
            } else if (typeof delegatorIds == "string") {
              getDelegator(delegatorIds);
            }
          }
          replyTo.push(item.organizer);
          this.pendingMessages.push(
            new CalItipOutgoingMessage("REPLY", replyTo, item, invitedAttendee, autoResponse)
          );
        }
      }
      return this.pendingMessageCount;
    }

    if (item.getProperty("X-MOZ-SEND-INVITATIONS") != "TRUE") {
      // Only send invitations/cancellations
      // if the user checked the checkbox
      this.pendingMessages = [];
      return this.pendingMessageCount;
    }

    // special handling for invitation with event status cancelled
    if (item.getAttendees().length > 0 && item.getProperty("STATUS") == "CANCELLED") {
      if (cal.itip.getSequence(item) > 0) {
        // make sure we send a cancellation and not an request
        opType = Ci.calIOperationListener.DELETE;
      } else {
        // don't send an invitation, if the event was newly created and has status cancelled
        this.pendingMessages = [];
        return this.pendingMessageCount;
      }
    }

    if (opType == Ci.calIOperationListener.DELETE) {
      let attendees = this.#filterOwnerFromAttendees(item.getAttendees(), item.calendar);
      this.pendingMessages.push(
        new CalItipOutgoingMessage("CANCEL", attendees, item, null, autoResponse)
      );
      return this.pendingMessageCount;
    } // else ADD, MODIFY:

    let originalAtt = originalItem ? originalItem.getAttendees() : [];
    let itemAtt = item.getAttendees();
    let canceledAttendees = [];
    let addedAttendees = [];

    if (itemAtt.length > 0 || originalAtt.length > 0) {
      let attMap = {};
      for (let att of originalAtt) {
        attMap[att.id.toLowerCase()] = att;
      }

      for (let att of itemAtt) {
        if (att.id.toLowerCase() in attMap) {
          // Attendee was in original item.
          delete attMap[att.id.toLowerCase()];
        } else {
          // Attendee only in new item
          addedAttendees.push(att);
        }
      }

      for (let id in attMap) {
        let cancAtt = attMap[id];
        canceledAttendees.push(cancAtt);
      }
    }

    // Check to see if some part of the item was updated, if so, re-send REQUEST
    if (!originalItem || cal.itip.compare(item, originalItem) > 0) {
      // REQUEST
      // check whether it's a simple UPDATE (no SEQUENCE change) or real (RE)REQUEST,
      // in case of time or location/description change.
      let isMinorUpdate =
        originalItem && cal.itip.getSequence(item) == cal.itip.getSequence(originalItem);

      if (
        !isMinorUpdate ||
        !cal.item.compareContent(stripUserData(item), stripUserData(originalItem))
      ) {
        let requestItem = item.clone();
        if (!requestItem.organizer) {
          requestItem.organizer = cal.itip.createOrganizer(requestItem.calendar);
        }

        // Fix up our attendees for invitations using some good defaults
        let recipients = [];
        let reqItemAtt = requestItem.getAttendees();
        if (!isMinorUpdate) {
          requestItem.removeAllAttendees();
        }
        for (let attendee of reqItemAtt) {
          if (!isMinorUpdate) {
            attendee = attendee.clone();
            if (!attendee.role) {
              attendee.role = "REQ-PARTICIPANT";
            }
            attendee.participationStatus = "NEEDS-ACTION";
            attendee.rsvp = "TRUE";
            requestItem.addAttendee(attendee);
          }

          recipients.push(attendee);
        }

        // if send out should be limited to newly added attendees and no major
        // props (attendee is not such) have changed, only the respective attendee
        // is added to the recipient list while the attendee information in the
        // ical is left to enable the new attendee to see who else is attending
        // the event (if not prevented otherwise)
        if (
          isMinorUpdate &&
          addedAttendees.length > 0 &&
          Services.prefs.getBoolPref("calendar.itip.updateInvitationForNewAttendeesOnly", false)
        ) {
          recipients = addedAttendees;
        }

        // Since this is a REQUEST, it is being sent from the event creator to
        // attendees. We do not need to send a message to the creator, even
        // though they may also be an attendee.
        recipients = this.#filterOwnerFromAttendees(recipients, item.calendar);

        if (recipients.length > 0) {
          this.pendingMessages.push(
            new CalItipOutgoingMessage("REQUEST", recipients, requestItem, null, autoResponse)
          );
        }
      }
    }

    // Cancel the event for all canceled attendees
    if (canceledAttendees.length > 0) {
      let cancelItem = originalItem.clone();
      cancelItem.removeAllAttendees();
      for (let att of canceledAttendees) {
        cancelItem.addAttendee(att);
      }
      canceledAttendees = this.#filterOwnerFromAttendees(canceledAttendees, cancelItem.calendar);
      this.pendingMessages.push(
        new CalItipOutgoingMessage("CANCEL", canceledAttendees, cancelItem, null, autoResponse)
      );
    }
    return this.pendingMessageCount;
  }

  /**
   * Sends the iTIP message using the item's calendar transport. This method
   * should be called after buildOutgoingMessages().
   *
   * @param {calIItipTransport} [transport] - An optional transport to use
   *  instead of the one provided by the item's calendar.
   *
   * @returns {boolean} - True, if the message could be sent.
   */
  send(transport) {
    return this.pendingMessages.every(msg => msg.send(transport));
  }

  /**
   * Filter out calendar owner from a list of event attendees to prevent the
   * owner from receiving messages about changes they have made.
   *
   * @param {calIAttendee[]} attendees - The attendees.
   * @param {calICalendar} calendar - The calendar the event belongs to.
   * @returns {calIAttendee[]} the attendees with calendar owner removed.
   */
  #filterOwnerFromAttendees(attendees, calendar) {
    const calendarEmail = cal.provider.getEmailIdentityOfCalendar(calendar)?.email;
    return attendees.filter(attendee => cal.email.removeMailTo(attendee.id) != calendarEmail);
  }
}

/**
 * Strips user specific data, e.g. categories and alarm settings and returns the stripped item.
 *
 * @param {calIItemBase} item_ - The item to strip data from
 * @returns {calIItemBase} - The stripped item
 */
function stripUserData(item_) {
  let item = item_.clone();
  let stamp = item.stampTime;
  let lastModified = item.lastModifiedTime;
  item.clearAlarms();
  item.alarmLastAck = null;
  item.setCategories([]);
  item.deleteProperty("RECEIVED-SEQUENCE");
  item.deleteProperty("RECEIVED-DTSTAMP");
  for (let [name] of item.properties) {
    let pname = name;
    if (pname.substr(0, "X-MOZ-".length) == "X-MOZ-") {
      item.deleteProperty(name);
    }
  }
  item.getAttendees().forEach(att => {
    att.deleteProperty("RECEIVED-SEQUENCE");
    att.deleteProperty("RECEIVED-DTSTAMP");
  });

  // according to RfC 6638, the following items must not be exposed in client side
  // scheduling messages, so let's remove it if present
  let removeSchedulingParams = aCalUser => {
    aCalUser.deleteProperty("SCHEDULE-AGENT");
    aCalUser.deleteProperty("SCHEDULE-FORCE-SEND");
    aCalUser.deleteProperty("SCHEDULE-STATUS");
  };
  item.getAttendees().forEach(removeSchedulingParams);
  if (item.organizer) {
    removeSchedulingParams(item.organizer);
  }

  item.setProperty("DTSTAMP", stamp);
  item.setProperty("LAST-MODIFIED", lastModified); // need to be last to undirty the item
  return item;
}