def _transferAttendees(self, old_comp, new_comp, ignore_attendee_value): """ Transfer Attendee PARTSTAT from old component to new component. @param old_comp: existing server calendar component @type old_comp: L{Component} @param new_comp: new calendar component @type new_comp: L{Component} @param ignore_attendee_value: Attendee to ignore @type ignore_attendee_value: C{str} """ # Create map of ATTENDEEs in old component old_attendees = {} for attendee in old_comp.properties("ATTENDEE"): value = normalizeCUAddr(attendee.value()) if value == ignore_attendee_value: continue old_attendees[value] = attendee for new_attendee in new_comp.properties("ATTENDEE"): # Whenever SCHEDULE-FORCE-SEND is explicitly set by the Organizer we assume the Organizer # is deliberately overwriting PARTSTAT if new_attendee.parameterValue("SCHEDULE-FORCE-SEND", "") == "REQUEST": continue # Transfer parameters from any old Attendees found value = normalizeCUAddr(new_attendee.value()) old_attendee = old_attendees.get(value) if old_attendee: self._transferParameter(old_attendee, new_attendee, "PARTSTAT") self._transferParameter(old_attendee, new_attendee, "RSVP") self._transferParameter(old_attendee, new_attendee, "SCHEDULE-STATUS")
def _transferAttendees(self, old_comp, new_comp, ignore_attendee_value): """ Transfer Attendee PARTSTAT from old component to new component. @param old_comp: existing server calendar component @type old_comp: L{Component} @param new_comp: new calendar component @type new_comp: L{Component} @param ignore_attendee_value: Attendee to ignore @type ignore_attendee_value: C{str} """ # Create map of ATTENDEEs in old component old_attendees = {} for attendee in old_comp.properties("ATTENDEE"): value = normalizeCUAddr(attendee.value()) if value == ignore_attendee_value: continue old_attendees[value] = attendee for new_attendee in new_comp.properties("ATTENDEE"): # Whenever SCHEDULE-FORCE-SEND is explicitly set by the Organizer we assume the Organizer # is deliberately overwriting PARTSTAT if new_attendee.parameterValue("SCHEDULE-FORCE-SEND", "") == "REQUEST": continue # Transfer parameters from any old Attendees found value = normalizeCUAddr(new_attendee.value()) old_attendee = old_attendees.get(value) if old_attendee: self._transferParameter(old_attendee, new_attendee, "PARTSTAT") self._transferParameter(old_attendee, new_attendee, "RSVP") self._transferParameter(old_attendee, new_attendee, "SCHEDULE-STATUS")
def doImplicitAttendeeUpdate(self): """ An iTIP message has been sent by to an attendee by the organizer. We need to update the attendee state based on the nature of the iTIP message. """ # Do security check: ORGANZIER in iTIP MUST match existing resource value if self.recipient_calendar: existing_organizer = self.recipient_calendar.getOrganizer() existing_organizer = normalizeCUAddr(existing_organizer) if existing_organizer else "" new_organizer = normalizeCUAddr(self.message.getOrganizer()) new_organizer = normalizeCUAddr(new_organizer) if new_organizer else "" if existing_organizer != new_organizer: # Additional check - if the existing organizer is missing and the originator # is local to the server - then allow the change if not (existing_organizer == "" and self.originator.hosted()): log.debug("ImplicitProcessing - originator '%s' to recipient '%s' ignoring UID: '%s' - organizer has no copy" % (self.originator.cuaddr, self.recipient.cuaddr, self.uid)) raise ImplicitProcessorException("5.3;Organizer change not allowed") # Handle splitting of data early so we can preserve per-attendee data if self.message.hasProperty("X-CALENDARSERVER-SPLIT-OLDER-UID"): if config.Scheduling.Options.Splitting.Enabled: # Tell the existing resource to split log.debug("ImplicitProcessing - originator '%s' to recipient '%s' splitting UID: '%s'" % (self.originator.cuaddr, self.recipient.cuaddr, self.uid)) split = (yield self.doImplicitAttendeeSplit()) if split: returnValue((True, False, False, None,)) else: self.message.removeProperty("X-CALENDARSERVER-SPLIT-OLDER-UID") self.message.removeProperty("X-CALENDARSERVER-SPLIT-RID") elif self.message.hasProperty("X-CALENDARSERVER-SPLIT-NEWER-UID"): if config.Scheduling.Options.Splitting.Enabled: log.debug("ImplicitProcessing - originator '%s' to recipient '%s' ignoring UID: '%s' - split already done" % (self.originator.cuaddr, self.recipient.cuaddr, self.uid)) returnValue((True, False, False, None,)) else: self.message.removeProperty("X-CALENDARSERVER-SPLIT-OLDER-UID") self.message.removeProperty("X-CALENDARSERVER-SPLIT-RID") # Different based on method if self.method == "REQUEST": result = (yield self.doImplicitAttendeeRequest()) elif self.method == "CANCEL": result = (yield self.doImplicitAttendeeCancel()) elif self.method == "ADD": # TODO: implement ADD result = (False, False, False, None) else: # NB We should never get here as we will have rejected unsupported METHODs earlier. result = (True, True, False, None,) returnValue(result)
def cuAddressConverter(origCUAddr): """ Converts calendar user addresses to OD-compatible form """ cua = normalizeCUAddr(origCUAddr) if cua.startswith("urn:x-uid:"): return "uid", cua[10:] elif cua.startswith("urn:uuid:"): return "guid", uuid.UUID(cua[9:]) elif cua.startswith("mailto:"): return "emailAddresses", cua[7:] elif cua.startswith("/") or cua.startswith("http"): ignored, collection, id = cua.rsplit("/", 2) if collection == "__uids__": return "uid", id else: return "recordName", id else: raise ValueError( "Invalid calendar user address format: %s" % (origCUAddr,) )
def _organizerMerge(self): """ Merge changes to ATTENDEE properties in oldcalendar into newcalendar. """ organizer = normalizeCUAddr( self.newcalendar.masterComponent().propertyValue("ORGANIZER")) self._doSmartMerge(organizer, True)
def recordWithCalendarUserAddress(self, address, timeoutSeconds=None): address = normalizeCUAddr(address) record = None if config.Scheduling.Options.FakeResourceLocationEmail: if address.startswith("mailto:") and address.endswith( "@do_not_reply"): try: address = "urn:x-uid:{}".format( address[7:-13].decode("hex")) except Exception: try: address = "urn:uuid:{}".format(address[7:-13]) except Exception as e: log.error( "Invalid @do_not_reply cu-address: '{address}' {exc}", address=address, exc=e) address = "" if address.startswith("urn:x-uid:"): uid = address[10:] record = yield self.recordWithUID(uid, timeoutSeconds=timeoutSeconds) elif address.startswith("urn:uuid:"): try: guid = uuid.UUID(address[9:]) except ValueError: log.info("Invalid GUID: {guid}", guid=address[9:]) returnValue(None) record = yield self.recordWithGUID(guid, timeoutSeconds=timeoutSeconds) elif address.startswith("mailto:"): records = yield self.recordsWithEmailAddress( address[7:], limitResults=1, timeoutSeconds=timeoutSeconds) record = records[0] if records else None elif address.startswith("/principals/"): parts = address.split("/") if len(parts) == 4: if parts[2] == "__uids__": uid = parts[3] record = yield self.recordWithUID( uid, timeoutSeconds=timeoutSeconds) else: recordType = self.oldNameToRecordType(parts[2]) record = yield self.recordWithShortName( recordType, parts[3], timeoutSeconds=timeoutSeconds) if record: if record.hasCalendars or (config.GroupAttendees.Enabled and record.recordType == BaseRecordType.group): returnValue(record) returnValue(None)
def convertCUAsToMailto(comp): """ Replace non-mailto: CUAs with mailto: CUAs where possible (i.e. there is an EMAIL parameter value attached) """ for attendeeProp in itertools.chain(comp.getAllAttendeeProperties(), [comp.getOrganizerProperty()]): cuaddr = normalizeCUAddr(attendeeProp.value()) if not cuaddr.startswith("mailto:"): emailAddress = attendeeProp.parameterValue("EMAIL", None) if emailAddress: attendeeProp.setValue("mailto:%s" % (emailAddress,)) attendeeProp.removeParameter("EMAIL")
def recordWithCalendarUserAddress( self, address, timeoutSeconds=None ): address = normalizeCUAddr(address) record = None if address.startswith("urn:x-uid:"): uid = address[10:] record = yield self.recordWithUID( uid, timeoutSeconds=timeoutSeconds ) elif address.startswith("urn:uuid:"): try: guid = uuid.UUID(address[9:]) except ValueError: log.info("Invalid GUID: {guid}", guid=address[9:]) returnValue(None) record = yield self.recordWithGUID( guid, timeoutSeconds=timeoutSeconds ) elif address.startswith("mailto:"): records = yield self.recordsWithEmailAddress( address[7:], limitResults=1, timeoutSeconds=timeoutSeconds ) record = records[0] if records else None elif address.startswith("/principals/"): parts = address.split("/") if len(parts) == 4: if parts[2] == "__uids__": uid = parts[3] record = yield self.recordWithUID( uid, timeoutSeconds=timeoutSeconds ) else: recordType = self.oldNameToRecordType(parts[2]) record = yield self.recordWithShortName( recordType, parts[3], timeoutSeconds=timeoutSeconds ) if record: if record.hasCalendars or ( config.GroupAttendees.Enabled and record.recordType == BaseRecordType.group ): returnValue(record) returnValue(None)
def recordWithCalendarUserAddress(self, address, timeoutSeconds=None): address = normalizeCUAddr(address) record = None if address.startswith("urn:x-uid:"): uid = address[10:] record = yield self.recordWithUID(uid, timeoutSeconds=timeoutSeconds) elif address.startswith("urn:uuid:"): try: guid = uuid.UUID(address[9:]) except ValueError: log.info("Invalid GUID: {guid}", guid=address[9:]) returnValue(None) record = yield self.recordWithGUID(guid, timeoutSeconds=timeoutSeconds) elif address.startswith("mailto:"): records = yield self.recordsWithEmailAddress( address[7:], limitResults=1, timeoutSeconds=timeoutSeconds) record = records[0] if records else None elif address.startswith("/principals/"): parts = address.split("/") if len(parts) == 4: if parts[2] == "__uids__": uid = parts[3] record = yield self.recordWithUID( uid, timeoutSeconds=timeoutSeconds) else: recordType = self.oldNameToRecordType(parts[2]) record = yield self.recordWithShortName( recordType, parts[3], timeoutSeconds=timeoutSeconds) if record: if record.hasCalendars or (config.GroupAttendees.Enabled and record.recordType == BaseRecordType.group): returnValue(record) returnValue(None)
def cuAddressConverter(origCUAddr): """ Converts calendar user addresses to OD-compatible form """ cua = normalizeCUAddr(origCUAddr) if cua.startswith("urn:x-uid:"): return "uid", cua[10:] elif cua.startswith("urn:uuid:"): return "guid", uuid.UUID(cua[9:]) elif cua.startswith("mailto:"): return "emailAddresses", cua[7:] elif cua.startswith("/") or cua.startswith("http"): ignored, collection, id = cua.rsplit("/", 2) if collection == "__uids__": return "uid", id else: return "recordName", id else: raise ValueError("Invalid calendar user address format: %s" % (origCUAddr, ))
def outbound(self, txn, originator, recipient, calendar, onlyAfter=None): """ Generates and sends an outbound IMIP message. @param txn: the transaction to use for looking up/creating tokens @type txn: L{CommonStoreTransaction} """ if onlyAfter is None: duration = Duration(days=self.suppressionDays) onlyAfter = DateTime.getNowUTC() - duration icaluid = calendar.resourceUID() method = calendar.propertyValue("METHOD") # Clean up the attendee list which is purely used within the human # readable email message (not modifying the calendar body) attendees = [] for attendeeProp in calendar.getAllAttendeeProperties(): cutype = attendeeProp.parameterValue("CUTYPE", "INDIVIDUAL") if cutype == "INDIVIDUAL": cn = attendeeProp.parameterValue("CN", None) if cn is not None: cn = cn.decode("utf-8") cuaddr = normalizeCUAddr(attendeeProp.value()) if cuaddr.startswith("mailto:"): mailto = cuaddr[7:] if not cn: cn = mailto else: emailAddress = attendeeProp.parameterValue("EMAIL", None) if emailAddress: mailto = emailAddress else: mailto = None if cn or mailto: attendees.append((cn, mailto)) toAddr = recipient if not recipient.lower().startswith("mailto:"): raise ValueError("ATTENDEE address '%s' must be mailto: for iMIP " "operation." % (recipient,)) recipient = recipient[7:] if method != "REPLY": # Invites and cancellations: # Reuse or generate a token based on originator, toAddr, and # event uid record = (yield txn.imipGetToken(originator, toAddr.lower(), icaluid)) if record is None: # Because in the past the originator was sometimes in mailto: # form, lookup an existing token by mailto: as well organizerProperty = calendar.getOrganizerProperty() organizerEmailAddress = organizerProperty.parameterValue("EMAIL", None) if organizerEmailAddress is not None: record = (yield txn.imipGetToken("mailto:%s" % (organizerEmailAddress.lower(),), toAddr.lower(), icaluid)) if record is None: record = (yield txn.imipCreateToken(originator, toAddr.lower(), icaluid)) self.log.debug("Mail gateway created token %s for %s " "(originator), %s (recipient) and %s (icaluid)" % (record.token, originator, toAddr, icaluid)) inviteState = "new" else: self.log.debug("Mail gateway reusing token %s for %s " "(originator), %s (recipient) and %s (icaluid)" % (record.token, originator, toAddr, icaluid)) inviteState = "update" token = record.token fullServerAddress = self.address _ignore_name, serverAddress = email.utils.parseaddr(fullServerAddress) pre, post = serverAddress.split('@') addressWithToken = "%s+%s@%s" % (pre, token, post) organizerProperty = calendar.getOrganizerProperty() organizerEmailAddress = organizerProperty.parameterValue("EMAIL", None) organizerValue = organizerProperty.value() organizerProperty.setValue("mailto:%s" % (addressWithToken,)) # If the organizer is also an attendee, update that attendee value # to match organizerAttendeeProperty = calendar.getAttendeeProperty( [organizerValue]) if organizerAttendeeProperty is not None: organizerAttendeeProperty.setValue("mailto:%s" % (addressWithToken,)) # The email's From will include the originator's real name email # address if available. Otherwise it will be the server's email # address (without # + addressing) if organizerEmailAddress: orgEmail = fromAddr = organizerEmailAddress else: fromAddr = serverAddress orgEmail = None cn = calendar.getOrganizerProperty().parameterValue('CN', None) if cn is None: cn = u'Calendar Server' orgCN = orgEmail else: orgCN = cn = cn.decode("utf-8") # a unicode cn (rather than an encode string value) means the # from address will get properly encoded per rfc2047 within the # MIMEMultipart in generateEmail formattedFrom = "%s <%s>" % (cn, fromAddr) # Reply-to address will be the server+token address else: # REPLY inviteState = "reply" # Look up the attendee property corresponding to the originator # of this reply originatorAttendeeProperty = calendar.getAttendeeProperty( [originator]) formattedFrom = fromAddr = originator = "" if originatorAttendeeProperty: originatorAttendeeEmailAddress = ( originatorAttendeeProperty.parameterValue("EMAIL", None) ) if originatorAttendeeEmailAddress: formattedFrom = fromAddr = originator = ( originatorAttendeeEmailAddress ) organizerMailto = str(calendar.getOrganizer()) if not organizerMailto.lower().startswith("mailto:"): raise ValueError("ORGANIZER address '%s' must be mailto: " "for REPLY." % (organizerMailto,)) orgEmail = organizerMailto[7:] orgCN = calendar.getOrganizerProperty().parameterValue('CN', None) if orgCN: orgCN = orgCN.decode("utf-8") addressWithToken = formattedFrom # At the point we've created the token in the db, which we always # want to do, but if this message is for an event completely in # the past we don't want to actually send an email. if not calendar.hasInstancesAfter(onlyAfter): self.log.debug("Skipping IMIP message for old event") returnValue(True) # Now prevent any "internal" CUAs from being exposed by converting # to mailto: if we have one for attendeeProp in calendar.getAllAttendeeProperties(): cutype = attendeeProp.parameterValue('CUTYPE', None) if cutype == "INDIVIDUAL": cuaddr = normalizeCUAddr(attendeeProp.value()) if not cuaddr.startswith("mailto:"): emailAddress = attendeeProp.parameterValue("EMAIL", None) if emailAddress: attendeeProp.setValue("mailto:%s" % (emailAddress,)) msgId, message = self.generateEmail( inviteState, calendar, orgEmail, orgCN, attendees, formattedFrom, addressWithToken, recipient, language=self.language) try: success = (yield self.smtpSender.sendMessage( fromAddr, toAddr, msgId, message)) returnValue(success) except Exception, e: self.log.error("Failed to send IMIP message (%s)" % (str(e),)) returnValue(False)
def doImplicitAttendeeCancel(self): """ An iTIP CANCEL message has been sent to an attendee. If there is no existing resource, we will simply ignore the message. If there is an existing resource we need to reconcile the changes between it and the iTIP message. @return: C{tuple} of (processed, auto-processed, store inbox item, changes) """ # If there is no existing copy, then ignore if self.recipient_calendar is None: log.debug("ImplicitProcessing - originator '%s' to recipient '%s' ignoring METHOD:CANCEL, UID: '%s' - attendee has no copy" % (self.originator.cuaddr, self.recipient.cuaddr, self.uid)) result = (True, True, True, None) else: # Need to check for auto-respond attendees. These need to suppress the inbox message # if the cancel is processed. However, if the principal is a user we always force the # inbox item on them even if auto-schedule is true so that they get a notification # of the cancel. organizer = normalizeCUAddr(self.message.getOrganizer()) autoprocessed = yield self.recipient.record.canAutoSchedule(organizer=organizer) store_inbox = not autoprocessed or self.recipient.record.getCUType() == "INDIVIDUAL" # Check to see if this is a cancel of the entire event processed_message, delete_original, rids = iTipProcessing.processCancel(self.message, self.recipient_calendar, autoprocessing=autoprocessed) if processed_message: if autoprocessed and accountingEnabled("AutoScheduling", self.recipient.record): accounting = { "action": "cancel", "when": DateTime.getNowUTC().getText(), "deleting": delete_original, } emitAccounting( "AutoScheduling", self.recipient.record, json.dumps(accounting) + "\r\n", filename=self.uid.encode("base64")[:-1] + ".txt" ) if delete_original: # Delete the attendee's copy of the event log.debug("ImplicitProcessing - originator '%s' to recipient '%s' processing METHOD:CANCEL, UID: '%s' - deleting entire event" % (self.originator.cuaddr, self.recipient.cuaddr, self.uid)) yield self.deleteCalendarResource(self.recipient_calendar_resource) # Build the schedule-changes XML element changes = customxml.ScheduleChanges( customxml.DTStamp(), customxml.Action( customxml.Cancel(), ), ) result = (True, autoprocessed, store_inbox, changes,) else: # Update the attendee's copy of the event log.debug("ImplicitProcessing - originator '%s' to recipient '%s' processing METHOD:CANCEL, UID: '%s' - updating event" % (self.originator.cuaddr, self.recipient.cuaddr, self.uid)) yield self.writeCalendarResource(None, self.recipient_calendar_resource, self.recipient_calendar) # Build the schedule-changes XML element if rids: action = customxml.Cancel( *[customxml.Recurrence(customxml.RecurrenceID.fromString(rid.getText())) for rid in sorted(rids)] ) else: action = customxml.Cancel() changes = customxml.ScheduleChanges( customxml.DTStamp(), customxml.Action(action), ) result = (True, autoprocessed, store_inbox, changes) else: log.debug("ImplicitProcessing - originator '%s' to recipient '%s' processing METHOD:CANCEL, UID: '%s' - ignoring" % (self.originator.cuaddr, self.recipient.cuaddr, self.uid)) result = (True, True, False, None) returnValue(result)
def attendeeMerge(self, attendee): """ Merge the ATTENDEE specific changes with the organizer's view of the attendee's event. This will remove any attempt by the attendee to change things like the time or location. @param attendee: the value of the ATTENDEE property corresponding to the attendee making the change @type attendee: C{str} @return: C{tuple} of: C{bool} - change is allowed C{bool} - iTIP reply needs to be sent C{list} - list of RECURRENCE-IDs changed L{Component} - new calendar object to store """ self.attendee = normalizeCUAddr(attendee) returnCalendar = self.oldcalendar.duplicate() returnMaster = returnCalendar.masterComponent() changeCausesReply = False changedRids = [] # First get uid/rid map of components def mapComponents(calendar): map = {} cancelledRids = set() master = None for component in calendar.subcomponents(): if component.name() == "VTIMEZONE": continue name = component.name() uid = component.propertyValue("UID") rid = component.getRecurrenceIDUTC() map[(name, uid, rid,)] = component if component.propertyValue("STATUS") == "CANCELLED" and rid is not None: cancelledRids.add(rid) if rid is None: master = component # Normalize each master by adding any STATUS:CANCELLED components as EXDATEs exdates = None if master: # Get all EXDATEs in UTC exdates = set() for exdate in master.properties("EXDATE"): exdates.update([value.getValue().duplicate().adjustToUTC() for value in exdate.value()]) return exdates, map, master exdatesold, mapold, masterold = mapComponents(self.oldcalendar) setold = set(mapold.keys()) exdatesnew, mapnew, masternew = mapComponents(self.newcalendar) setnew = set(mapnew.keys()) # Handle case where iCal breaks events without a master component if masternew is not None and masterold is None: masternewStart = masternew.getStartDateUTC() keynew = (masternew.name(), masternew.propertyValue("UID"), masternewStart) if keynew not in setold: # The DTSTART in the fake master does not match a RECURRENCE-ID in the real data. # We have to do a brute force search for the component that matches based on DTSTART for componentold in self.oldcalendar.subcomponents(): if componentold.name() == "VTIMEZONE": continue if masternewStart == componentold.getStartDateUTC(): break else: # Nothing matches - this has to be treated as an error self._logDiffError("attendeeMerge: Unable to match fake master component: %s" % (keynew,)) return False, False, (), None else: componentold = self.oldcalendar.overriddenComponent(masternewStart) # Take the recurrence ID from component1 and fix map2/set2 keynew = (masternew.name(), masternew.propertyValue("UID"), None) componentnew = mapnew[keynew] del mapnew[keynew] ridold = componentold.getRecurrenceIDUTC() newkeynew = (masternew.name(), masternew.propertyValue("UID"), ridold) mapnew[newkeynew] = componentnew setnew.remove(keynew) setnew.add(newkeynew) # All the components in oldcalendar must be in newcalendar unless they are CANCELLED for key in setold - setnew: _ignore_name, _ignore_uid, rid = key component = mapold[key] if component.propertyValue("STATUS") != "CANCELLED": # Attendee may decline by EXDATE'ing an instance - we need to handle that if exdatesnew is None or rid in exdatesnew: # Mark Attendee as DECLINED in the server instance overridden = returnCalendar.overriddenComponent(rid) if self._attendeeDecline(overridden): changeCausesReply = True changedRids.append(rid) # When a master component is present we keep the missing override in place but mark it as hidden. # When no master is present we now do the same so we can track updates to the override correctly. overridden.replaceProperty(Property(Component.HIDDEN_INSTANCE_PROPERTY, "T")) else: # We used to generate a 403 here - but instead we now ignore this error and let the server data # override the client. # If smart_merge is happening, then derive an instance in the new data as the change in the old # data is valid and likely due to some other attendee changing their status. if self.smart_merge: newOverride = self.newcalendar.deriveInstance(rid, allowCancelled=True) if newOverride is None: self._logDiffError("attendeeMerge: Could not derive instance for uncancelled component: %s" % (key,)) else: self.newcalendar.addComponent(newOverride) setnew.add(key) mapnew[key] = newOverride else: self._logDiffError("attendeeMerge: Missing uncancelled component from first calendar: %s" % (key,)) else: if exdatesnew is not None and rid not in exdatesnew: # We used to generate a 403 here - but instead we now ignore this error and let the server data # override the client self._logDiffError("attendeeMerge: Missing EXDATE for cancelled components from first calendar: %s" % (key,)) else: # Remove the CANCELLED component from the new calendar and add an EXDATE overridden = returnCalendar.overriddenComponent(rid) returnCalendar.removeComponent(overridden) if returnMaster: # Use the original R-ID value so we preserve the timezone original_rid = component.propertyValue("RECURRENCE-ID") returnMaster.addProperty(Property("EXDATE", [original_rid, ])) # Derive a new component in the new calendar for each new one in setnew for key in setnew - setold: # First check if the attendee's copy is cancelled and properly EXDATE'd # and skip it if so. _ignore_name, _ignore_uid, rid = key componentnew = mapnew[key] if componentnew.propertyValue("STATUS") == "CANCELLED": if exdatesold is None or rid not in exdatesold: # We used to generate a 403 here - but instead we now ignore this error and let the server data # override the client self._logDiffError("attendeeMerge: Cancelled component not found in first calendar (or no EXDATE): %s" % (key,)) setnew.remove(key) else: # Derive new component with STATUS:CANCELLED and remove EXDATE newOverride = returnCalendar.deriveInstance(rid, allowCancelled=True) if newOverride is None: # We used to generate a 403 here - but instead we now ignore this error and let the server data # override the client self._logDiffError("attendeeMerge: Could not derive instance for cancelled component: %s" % (key,)) setnew.remove(key) else: returnCalendar.addComponent(newOverride) else: # Derive new component newOverride = returnCalendar.deriveInstance(rid) if newOverride is None: # We used to generate a 403 here - but instead we now ignore this error and let the server data # override the client self._logDiffError("attendeeMerge: Could not derive instance for uncancelled component: %s" % (key,)) setnew.remove(key) else: returnCalendar.addComponent(newOverride) # So now returnCalendar has all the same components as set2. Check changes and do transfers. # Make sure the same VCALENDAR properties match if not self._checkVCALENDARProperties(returnCalendar, self.newcalendar): # We used to generate a 403 here - but instead we now ignore this error and let the server data # override the client self._logDiffError("attendeeMerge: VCALENDAR properties do not match") # Now we transfer per-Attendee # data from newcalendar into returnCalendar to sync up changes, whilst verifying that other # key properties are unchanged declines = [] for key in setnew: _ignore_name, _ignore_uid, rid = key serverData = returnCalendar.overriddenComponent(rid) clientData = mapnew[key] allowed, reply = self._transferAttendeeData(serverData, clientData, declines) if not allowed: # We used to generate a 403 here - but instead we now ignore this error and let the server data # override the client self._logDiffError("attendeeMerge: Mismatched calendar objects") #return False, False, (), None changeCausesReply |= reply if reply: changedRids.append(rid) # We need to derive instances for any declined using an EXDATE for decline in sorted(declines): overridden = returnCalendar.overriddenComponent(decline) if not overridden: overridden = returnCalendar.deriveInstance(decline) if overridden is not None: if self._attendeeDecline(overridden): changeCausesReply = True changedRids.append(decline) # When a master component is present we keep the missing override in place but mark it as hidden. # When no master is present we remove the override, if exdatesnew is not None: overridden.replaceProperty(Property(Component.HIDDEN_INSTANCE_PROPERTY, "T")) returnCalendar.addComponent(overridden) else: self._logDiffError("attendeeMerge: Unable to override an instance to mark as DECLINED: %s" % (decline,)) return False, False, (), None return True, changeCausesReply, changedRids, returnCalendar
def attendeeMerge(self, attendee): """ Merge the ATTENDEE specific changes with the organizer's view of the attendee's event. This will remove any attempt by the attendee to change things like the time or location. @param attendee: the value of the ATTENDEE property corresponding to the attendee making the change @type attendee: C{str} @return: C{tuple} of: C{bool} - change is allowed C{bool} - iTIP reply needs to be sent C{list} - list of RECURRENCE-IDs changed L{Component} - new calendar object to store """ self.attendee = normalizeCUAddr(attendee) returnCalendar = self.oldcalendar.duplicate() returnMaster = returnCalendar.masterComponent() changeCausesReply = False changedRids = [] # First get uid/rid map of components def mapComponents(calendar): map = {} cancelledRids = set() master = None for component in calendar.subcomponents(): if component.name() == "VTIMEZONE": continue name = component.name() uid = component.propertyValue("UID") rid = component.getRecurrenceIDUTC() map[( name, uid, rid, )] = component if component.propertyValue( "STATUS") == "CANCELLED" and rid is not None: cancelledRids.add(rid) if rid is None: master = component # Normalize each master by adding any STATUS:CANCELLED components as EXDATEs exdates = None if master: # Get all EXDATEs in UTC exdates = set() for exdate in master.properties("EXDATE"): exdates.update([ value.getValue().duplicate().adjustToUTC() for value in exdate.value() ]) return exdates, map, master exdatesold, mapold, masterold = mapComponents(self.oldcalendar) setold = set(mapold.keys()) exdatesnew, mapnew, masternew = mapComponents(self.newcalendar) setnew = set(mapnew.keys()) # Handle case where iCal breaks events without a master component if masternew is not None and masterold is None: masternewStart = masternew.getStartDateUTC() keynew = (masternew.name(), masternew.propertyValue("UID"), masternewStart) if keynew not in setold: # The DTSTART in the fake master does not match a RECURRENCE-ID in the real data. # We have to do a brute force search for the component that matches based on DTSTART for componentold in self.oldcalendar.subcomponents(): if componentold.name() == "VTIMEZONE": continue if masternewStart == componentold.getStartDateUTC(): break else: # Nothing matches - this has to be treated as an error self._logDiffError( "attendeeMerge: Unable to match fake master component: %s" % (keynew, )) return False, False, (), None else: componentold = self.oldcalendar.overriddenComponent( masternewStart) # Take the recurrence ID from component1 and fix map2/set2 keynew = (masternew.name(), masternew.propertyValue("UID"), None) componentnew = mapnew[keynew] del mapnew[keynew] ridold = componentold.getRecurrenceIDUTC() newkeynew = (masternew.name(), masternew.propertyValue("UID"), ridold) mapnew[newkeynew] = componentnew setnew.remove(keynew) setnew.add(newkeynew) # All the components in oldcalendar must be in newcalendar unless they are CANCELLED for key in setold - setnew: _ignore_name, _ignore_uid, rid = key component = mapold[key] if component.propertyValue("STATUS") != "CANCELLED": # Attendee may decline by EXDATE'ing an instance - we need to handle that if exdatesnew is None or rid in exdatesnew: # Mark Attendee as DECLINED in the server instance overridden = returnCalendar.overriddenComponent(rid) if self._attendeeDecline(overridden): changeCausesReply = True changedRids.append(rid) # When a master component is present we keep the missing override in place but mark it as hidden. # When no master is present we now do the same so we can track updates to the override correctly. overridden.replaceProperty( Property(Component.HIDDEN_INSTANCE_PROPERTY, "T")) else: # We used to generate a 403 here - but instead we now ignore this error and let the server data # override the client. # If smart_merge is happening, then derive an instance in the new data as the change in the old # data is valid and likely due to some other attendee changing their status. if self.smart_merge: newOverride = self.newcalendar.deriveInstance( rid, allowCancelled=True) if newOverride is None: self._logDiffError( "attendeeMerge: Could not derive instance for uncancelled component: %s" % (key, )) else: self.newcalendar.addComponent(newOverride) setnew.add(key) mapnew[key] = newOverride else: self._logDiffError( "attendeeMerge: Missing uncancelled component from first calendar: %s" % (key, )) else: if exdatesnew is not None and rid not in exdatesnew: # We used to generate a 403 here - but instead we now ignore this error and let the server data # override the client self._logDiffError( "attendeeMerge: Missing EXDATE for cancelled components from first calendar: %s" % (key, )) else: # Remove the CANCELLED component from the new calendar and add an EXDATE overridden = returnCalendar.overriddenComponent(rid) returnCalendar.removeComponent(overridden) if returnMaster: # Use the original R-ID value so we preserve the timezone original_rid = component.propertyValue("RECURRENCE-ID") returnMaster.addProperty( Property("EXDATE", [ original_rid, ])) # Derive a new component in the new calendar for each new one in setnew for key in setnew - setold: # First check if the attendee's copy is cancelled and properly EXDATE'd # and skip it if so. _ignore_name, _ignore_uid, rid = key componentnew = mapnew[key] if componentnew.propertyValue("STATUS") == "CANCELLED": if exdatesold is None or rid not in exdatesold: # We used to generate a 403 here - but instead we now ignore this error and let the server data # override the client self._logDiffError( "attendeeMerge: Cancelled component not found in first calendar (or no EXDATE): %s" % (key, )) setnew.remove(key) else: # Derive new component with STATUS:CANCELLED and remove EXDATE newOverride = returnCalendar.deriveInstance( rid, allowCancelled=True) if newOverride is None: # We used to generate a 403 here - but instead we now ignore this error and let the server data # override the client self._logDiffError( "attendeeMerge: Could not derive instance for cancelled component: %s" % (key, )) setnew.remove(key) else: returnCalendar.addComponent(newOverride) else: # Derive new component newOverride = returnCalendar.deriveInstance(rid) if newOverride is None: # We used to generate a 403 here - but instead we now ignore this error and let the server data # override the client self._logDiffError( "attendeeMerge: Could not derive instance for uncancelled component: %s" % (key, )) setnew.remove(key) else: returnCalendar.addComponent(newOverride) # So now returnCalendar has all the same components as set2. Check changes and do transfers. # Make sure the same VCALENDAR properties match if not self._checkVCALENDARProperties(returnCalendar, self.newcalendar): # We used to generate a 403 here - but instead we now ignore this error and let the server data # override the client self._logDiffError( "attendeeMerge: VCALENDAR properties do not match") # Now we transfer per-Attendee # data from newcalendar into returnCalendar to sync up changes, whilst verifying that other # key properties are unchanged declines = [] for key in setnew: _ignore_name, _ignore_uid, rid = key serverData = returnCalendar.overriddenComponent(rid) clientData = mapnew[key] allowed, reply = self._transferAttendeeData( serverData, clientData, declines) if not allowed: # We used to generate a 403 here - but instead we now ignore this error and let the server data # override the client self._logDiffError( "attendeeMerge: Mismatched calendar objects") # return False, False, (), None changeCausesReply |= reply if reply: changedRids.append(rid) # We need to derive instances for any declined using an EXDATE for decline in sorted(declines): overridden = returnCalendar.overriddenComponent(decline) if not overridden: overridden = returnCalendar.deriveInstance(decline) if overridden is not None: if self._attendeeDecline(overridden): changeCausesReply = True changedRids.append(decline) # When a master component is present we keep the missing override in place but mark it as hidden. # When no master is present we remove the override, if exdatesnew is not None: overridden.replaceProperty( Property(Component.HIDDEN_INSTANCE_PROPERTY, "T")) returnCalendar.addComponent(overridden) else: self._logDiffError( "attendeeMerge: Unable to override an instance to mark as DECLINED: %s" % (decline, )) return False, False, (), None return True, changeCausesReply, changedRids, returnCalendar
def _organizerMerge(self): """ Merge changes to ATTENDEE properties in oldcalendar into newcalendar. """ organizer = normalizeCUAddr(self.newcalendar.masterComponent().propertyValue("ORGANIZER")) self._doSmartMerge(organizer, True)
def doImplicitAttendeeRequest(self): """ An iTIP REQUEST message has been sent to an attendee. If there is no existing resource, we will simply create a new one. If there is an existing resource we need to reconcile the changes between it and the iTIP message. @return: C{tuple} of (processed, auto-processed, store inbox item, changes) """ # If there is no existing copy, then look for default calendar and copy it here if self.new_resource: # Check if the incoming data has the recipient declined in all instances. In that case we will not create # a new resource as chances are the recipient previously deleted the resource and we want to keep it deleted. attendees = self.message.getAttendeeProperties((self.recipient.cuaddr,)) if all([attendee.parameterValue("PARTSTAT", "NEEDS-ACTION") == "DECLINED" for attendee in attendees]): log.debug("ImplicitProcessing - originator '%s' to recipient '%s' processing METHOD:REQUEST, UID: '%s' - ignoring all declined" % (self.originator.cuaddr, self.recipient.cuaddr, self.uid)) returnValue((True, False, False, None,)) # Check for default calendar default = (yield self.recipient.inbox.viewerHome().defaultCalendar(self.message.mainType())) if default is None: log.error("No default calendar for recipient: '%s'." % (self.recipient.cuaddr,)) raise ImplicitProcessorException(iTIPRequestStatus.NO_USER_SUPPORT) log.debug("ImplicitProcessing - originator '%s' to recipient '%s' processing METHOD:REQUEST, UID: '%s' - new processed" % (self.originator.cuaddr, self.recipient.cuaddr, self.uid)) new_calendar = iTipProcessing.processNewRequest(self.message, self.recipient.cuaddr, creating=True) # Handle auto-reply behavior organizer = normalizeCUAddr(self.message.getOrganizer()) if (yield self.recipient.record.canAutoSchedule(organizer=organizer)): # auto schedule mode can depend on who the organizer is mode = yield self.recipient.record.getAutoScheduleMode(organizer=organizer) send_reply, store_inbox, partstat, accounting = (yield self.checkAttendeeAutoReply(new_calendar, mode)) if accounting is not None: accounting["action"] = "create" emitAccounting( "AutoScheduling", self.recipient.record, json.dumps(accounting) + "\r\n", filename=self.uid.encode("base64")[:-1] + ".txt" ) # Only store inbox item when reply is not sent or always for users store_inbox = store_inbox or self.recipient.record.getCUType() == "INDIVIDUAL" else: send_reply = False store_inbox = True new_resource = (yield self.writeCalendarResource(default, None, new_calendar)) if send_reply: # Track outstanding auto-reply processing log.debug("ImplicitProcessing - recipient '%s' processing UID: '%s' - auto-reply queued" % (self.recipient.cuaddr, self.uid,)) ScheduleAutoReplyWork.autoReply(self.txn, new_resource, partstat) # Build the schedule-changes XML element changes = customxml.ScheduleChanges( customxml.DTStamp(), customxml.Action( customxml.Create(), ), ) result = (True, send_reply, store_inbox, changes,) else: # Processing update to existing event new_calendar, rids = iTipProcessing.processRequest(self.message, self.recipient_calendar, self.recipient.cuaddr) if new_calendar: # Handle auto-reply behavior organizer = normalizeCUAddr(self.message.getOrganizer()) if (yield self.recipient.record.canAutoSchedule(organizer=organizer)) and not hasattr(self.txn, "doing_attendee_refresh"): # auto schedule mode can depend on who the organizer is mode = yield self.recipient.record.getAutoScheduleMode(organizer=organizer) send_reply, store_inbox, partstat, accounting = (yield self.checkAttendeeAutoReply(new_calendar, mode)) if accounting is not None: accounting["action"] = "modify" emitAccounting( "AutoScheduling", self.recipient.record, json.dumps(accounting) + "\r\n", filename=self.uid.encode("base64")[:-1] + ".txt" ) # Only store inbox item when reply is not sent or always for users store_inbox = store_inbox or self.recipient.record.getCUType() == "INDIVIDUAL" else: send_reply = False store_inbox = True # Let the store know that no time-range info has changed for a refresh (assuming that # no auto-accept changes were made) if hasattr(self.txn, "doing_attendee_refresh"): new_calendar.noInstanceIndexing = not send_reply # Update the attendee's copy of the event log.debug("ImplicitProcessing - originator '%s' to recipient '%s' processing METHOD:REQUEST, UID: '%s' - updating event" % (self.originator.cuaddr, self.recipient.cuaddr, self.uid)) new_resource = (yield self.writeCalendarResource(None, self.recipient_calendar_resource, new_calendar)) if send_reply: # Track outstanding auto-reply processing log.debug("ImplicitProcessing - recipient '%s' processing UID: '%s' - auto-reply queued" % (self.recipient.cuaddr, self.uid,)) ScheduleAutoReplyWork.autoReply(self.txn, new_resource, partstat) # Build the schedule-changes XML element update_details = [] for rid, props_changed in sorted(rids.iteritems(), key=lambda x: x[0]): recurrence = [] if rid is None: recurrence.append(customxml.Master()) else: recurrence.append(customxml.RecurrenceID.fromString(rid.getText())) changes = [] for propName, paramNames in sorted(props_changed.iteritems(), key=lambda x: x[0]): params = tuple([customxml.ChangedParameter(name=param) for param in paramNames]) changes.append(customxml.ChangedProperty(*params, **{"name": propName})) recurrence.append(customxml.Changes(*changes)) update_details += (customxml.Recurrence(*recurrence),) changes = customxml.ScheduleChanges( customxml.DTStamp(), customxml.Action( customxml.Update(*update_details), ), ) # Refresh from another Attendee should not have Inbox item if hasattr(self.txn, "doing_attendee_refresh"): store_inbox = False result = (True, send_reply, store_inbox, changes,) else: # Request needs to be ignored log.debug("ImplicitProcessing - originator '%s' to recipient '%s' processing METHOD:REQUEST, UID: '%s' - ignoring" % (self.originator.cuaddr, self.recipient.cuaddr, self.uid)) result = (True, True, False, None,) returnValue(result)
def outbound(self, txn, originator, recipient, calendar, onlyAfter=None): """ Generates and sends an outbound IMIP message. @param txn: the transaction to use for looking up/creating tokens @type txn: L{CommonStoreTransaction} """ if onlyAfter is None: duration = Duration(days=self.suppressionDays) onlyAfter = DateTime.getNowUTC() - duration icaluid = calendar.resourceUID() method = calendar.propertyValue("METHOD") # Clean up the attendee list which is purely used within the human # readable email message (not modifying the calendar body) attendees = [] for attendeeProp in calendar.getAllAttendeeProperties(): cutype = attendeeProp.parameterValue("CUTYPE", "INDIVIDUAL") if cutype == "INDIVIDUAL": cn = attendeeProp.parameterValue("CN", None) if cn is not None: cn = cn.decode("utf-8") cuaddr = normalizeCUAddr(attendeeProp.value()) if cuaddr.startswith("mailto:"): mailto = cuaddr[7:] if not cn: cn = mailto else: emailAddress = attendeeProp.parameterValue("EMAIL", None) if emailAddress: mailto = emailAddress else: mailto = None if cn or mailto: attendees.append((cn, mailto)) toAddr = recipient if not recipient.lower().startswith("mailto:"): raise ValueError("ATTENDEE address '%s' must be mailto: for iMIP " "operation." % (recipient, )) recipient = recipient[7:] if method != "REPLY": # Invites and cancellations: # Reuse or generate a token based on originator, toAddr, and # event uid token = (yield txn.imipGetToken(originator, toAddr.lower(), icaluid)) if token is None: # Because in the past the originator was sometimes in mailto: # form, lookup an existing token by mailto: as well organizerProperty = calendar.getOrganizerProperty() organizerEmailAddress = organizerProperty.parameterValue( "EMAIL", None) if organizerEmailAddress is not None: token = (yield txn.imipGetToken( "mailto:%s" % (organizerEmailAddress.lower(), ), toAddr.lower(), icaluid)) if token is None: token = (yield txn.imipCreateToken(originator, toAddr.lower(), icaluid)) self.log.debug( "Mail gateway created token %s for %s " "(originator), %s (recipient) and %s (icaluid)" % (token, originator, toAddr, icaluid)) inviteState = "new" else: self.log.debug( "Mail gateway reusing token %s for %s " "(originator), %s (recipient) and %s (icaluid)" % (token, originator, toAddr, icaluid)) inviteState = "update" fullServerAddress = self.address _ignore_name, serverAddress = email.utils.parseaddr( fullServerAddress) pre, post = serverAddress.split('@') addressWithToken = "%s+%s@%s" % (pre, token, post) organizerProperty = calendar.getOrganizerProperty() organizerEmailAddress = organizerProperty.parameterValue( "EMAIL", None) organizerValue = organizerProperty.value() organizerProperty.setValue("mailto:%s" % (addressWithToken, )) # If the organizer is also an attendee, update that attendee value # to match organizerAttendeeProperty = calendar.getAttendeeProperty( [organizerValue]) if organizerAttendeeProperty is not None: organizerAttendeeProperty.setValue("mailto:%s" % (addressWithToken, )) # The email's From will include the originator's real name email # address if available. Otherwise it will be the server's email # address (without # + addressing) if organizerEmailAddress: orgEmail = fromAddr = organizerEmailAddress else: fromAddr = serverAddress orgEmail = None cn = calendar.getOrganizerProperty().parameterValue('CN', None) if cn is None: cn = u'Calendar Server' orgCN = orgEmail else: orgCN = cn = cn.decode("utf-8") # a unicode cn (rather than an encode string value) means the # from address will get properly encoded per rfc2047 within the # MIMEMultipart in generateEmail formattedFrom = "%s <%s>" % (cn, fromAddr) # Reply-to address will be the server+token address else: # REPLY inviteState = "reply" # Look up the attendee property corresponding to the originator # of this reply originatorAttendeeProperty = calendar.getAttendeeProperty( [originator]) formattedFrom = fromAddr = originator = "" if originatorAttendeeProperty: originatorAttendeeEmailAddress = ( originatorAttendeeProperty.parameterValue("EMAIL", None)) if originatorAttendeeEmailAddress: formattedFrom = fromAddr = originator = ( originatorAttendeeEmailAddress) organizerMailto = str(calendar.getOrganizer()) if not organizerMailto.lower().startswith("mailto:"): raise ValueError("ORGANIZER address '%s' must be mailto: " "for REPLY." % (organizerMailto, )) orgEmail = organizerMailto[7:] orgCN = calendar.getOrganizerProperty().parameterValue('CN', None) addressWithToken = formattedFrom # At the point we've created the token in the db, which we always # want to do, but if this message is for an event completely in # the past we don't want to actually send an email. if not calendar.hasInstancesAfter(onlyAfter): self.log.debug("Skipping IMIP message for old event") returnValue(True) # Now prevent any "internal" CUAs from being exposed by converting # to mailto: if we have one for attendeeProp in calendar.getAllAttendeeProperties(): cutype = attendeeProp.parameterValue('CUTYPE', None) if cutype == "INDIVIDUAL": cuaddr = normalizeCUAddr(attendeeProp.value()) if not cuaddr.startswith("mailto:"): emailAddress = attendeeProp.parameterValue("EMAIL", None) if emailAddress: attendeeProp.setValue("mailto:%s" % (emailAddress, )) msgId, message = self.generateEmail(inviteState, calendar, orgEmail, orgCN, attendees, formattedFrom, addressWithToken, recipient, language=self.language) try: success = (yield self.smtpSender.sendMessage(fromAddr, toAddr, msgId, message)) returnValue(success) except Exception, e: self.log.error("Failed to send IMIP message (%s)" % (str(e), )) returnValue(False)