summaryrefslogtreecommitdiffstats
path: root/comm/calendar/itip/CalItipMessageSender.jsm
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--comm/calendar/itip/CalItipMessageSender.jsm433
1 files changed, 433 insertions, 0 deletions
diff --git a/comm/calendar/itip/CalItipMessageSender.jsm b/comm/calendar/itip/CalItipMessageSender.jsm
new file mode 100644
index 0000000000..373f9b728c
--- /dev/null
+++ b/comm/calendar/itip/CalItipMessageSender.jsm
@@ -0,0 +1,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;
+}