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