def getMailSender(cls): """ Instantiate and return a singleton MailSender object @return: a MailSender """ if cls.mailSender is None: if config.Scheduling.iMIP.Enabled: settings = config.Scheduling.iMIP.Sending smtpSender = SMTPSender(settings.Username, settings.Password, settings.UseSSL, settings.Server, settings.Port) cls.mailSender = MailSender(settings.Address, settings.SuppressionDays, smtpSender, getLanguage(config)) return cls.mailSender
def generateEmail(self, inviteState, calendar, orgEmail, orgCN, attendees, fromAddress, replyToAddress, toAddress, language='en'): """ Generate MIME text containing an iMIP invitation, cancellation, update or reply. @param inviteState: 'new', 'update', or 'reply'. @type inviteState: C{str} @param calendar: the iCalendar component to attach to the email. @type calendar: L{twistedcaldav.ical.Component} @param orgEmail: The email for the organizer, in C{localhost@domain} format, or C{None} if the organizer has no email address. @type orgEmail: C{str} or C{NoneType} @param orgCN: Common name / display name for the organizer. @type orgCN: C{unicode} @param attendees: A C{list} of 2-C{tuple}s of (common name, email address) similar to (orgEmail, orgCN). @param fromAddress: the address to use in the C{From:} header of the email. @type fromAddress: C{str} @param replyToAddress: the address to use in the C{Reply-To} header. @type replyToAddress: C{str} @param toAddress: the address to use in the C{To} header. @type toAddress: C{str} @param language: a 2-letter language code describing the target language that the email should be generated in. @type language: C{str} @return: a 2-tuple of C{str}s: (message ID, message text). The message ID is the value of the C{Message-ID} header, and the message text is the full MIME message, ready for transport over SMTP. """ details = self.getEventDetails(calendar, language=language) canceled = (calendar.propertyValue("METHOD") == "CANCEL") subjectFormat, labels = localizedLabels(language, canceled, inviteState) details.update(labels) details['subject'] = subjectFormat % {'summary' : details['summary']} plainText = self.renderPlainText(details, (orgCN, orgEmail), attendees, canceled) htmlText = self.renderHTML( details, (orgCN, orgEmail), attendees, canceled ) msg = MIMEMultipart() msg["From"] = fromAddress msg["Subject"] = details['subject'] msg["Reply-To"] = replyToAddress msg["To"] = toAddress msg["Date"] = rfc822date() msgId = SMTPSender.betterMessageID() msg["Message-ID"] = msgId msgAlt = MIMEMultipart("alternative") msg.attach(msgAlt) # plain version msgPlain = MIMEText(plainText, "plain", "UTF-8") msgAlt.attach(msgPlain) # html version msgHtmlRelated = MIMEMultipart("related", type="text/html") msgAlt.attach(msgHtmlRelated) msgHtml = MIMEText(htmlText, "html", "UTF-8") msgHtmlRelated.attach(msgHtml) # the icalendar attachment # Make sure we always have the timezones used in the calendar data as iMIP requires VTIMEZONE # always be present (i.e., timezones-by-reference is not allowed in iMIP). calendarText = calendar.getTextWithTimezones(includeTimezones=True) self.log.debug("Mail gateway sending calendar body: %s" % (calendarText,)) msgIcal = MIMEText(calendarText, "calendar", "UTF-8") method = calendar.propertyValue("METHOD").lower() msgIcal.set_param("method", method) msgIcal.add_header("Content-ID", "<invitation.ics>") msgIcal.add_header( "Content-Disposition", "inline;filename=invitation.ics") msg.attach(msgIcal) return msgId, msg.as_string()
def processReply(self, msg): # extract the token from the To header _ignore_name, addr = email.utils.parseaddr(msg['To']) if addr: # addr looks like: [email protected] token = self._extractToken(addr) if not token: log.error("Mail gateway didn't find a token in message " "%s (%s)" % (msg['Message-ID'], msg['To'])) returnValue(self.NO_TOKEN) else: log.error("Mail gateway couldn't parse To: address (%s) in " "message %s" % (msg['To'], msg['Message-ID'])) returnValue(self.MALFORMED_TO_ADDRESS) txn = self.store.newTransaction(label="MailReceiver.processReply") result = (yield txn.imipLookupByToken(token)) yield txn.commit() try: # Note the results are returned as utf-8 encoded strings organizer, attendee, _ignore_icaluid = result[0] except: # This isn't a token we recognize log.error("Mail gateway found a token (%s) but didn't " "recognize it in message %s" % (token, msg['Message-ID'])) returnValue(self.UNKNOWN_TOKEN) for part in msg.walk(): if part.get_content_type() == "text/calendar": calBody = part.get_payload(decode=True) break else: # No icalendar attachment log.warn("Mail gateway didn't find an icalendar attachment " "in message %s" % (msg['Message-ID'], )) toAddr = None fromAddr = attendee[7:] if organizer.startswith("mailto:"): toAddr = organizer[7:] elif organizer.startswith("urn:x-uid:"): uid = organizer[10:] record = yield self.directory.recordWithUID(uid) try: if record and record.emailAddresses: toAddr = list(record.emailAddresses)[0] except AttributeError: pass if toAddr is None: log.error("Don't have an email address for the organizer; " "ignoring reply.") returnValue(self.NO_ORGANIZER_ADDRESS) settings = config.Scheduling["iMIP"]["Sending"] smtpSender = SMTPSender(settings.Username, settings.Password, settings.UseSSL, settings.Server, settings.Port) del msg["From"] msg["From"] = fromAddr del msg["Reply-To"] msg["Reply-To"] = fromAddr del msg["To"] msg["To"] = toAddr log.warn("Mail gateway forwarding reply back to organizer") yield smtpSender.sendMessage(fromAddr, toAddr, messageid(), msg.as_string()) returnValue(self.REPLY_FORWARDED_TO_ORGANIZER) # Process the imip attachment; inject to calendar server log.debug(calBody) calendar = Component.fromString(calBody) event = calendar.mainComponent() # Don't let a missing PRODID prevent the reply from being processed if not calendar.hasProperty("PRODID"): calendar.addProperty(Property("PRODID", "Unknown")) calendar.removeAllButOneAttendee(attendee) organizerProperty = calendar.getOrganizerProperty() if organizerProperty is None: # ORGANIZER is required per rfc2446 section 3.2.3 log.warn("Mail gateway didn't find an ORGANIZER in REPLY %s" % (msg['Message-ID'], )) event.addProperty(Property("ORGANIZER", organizer)) else: organizerProperty.setValue(organizer) if not calendar.getAttendees(): # The attendee we're expecting isn't there, so add it back # with a SCHEDULE-STATUS of SERVICE_UNAVAILABLE. # The organizer will then see that the reply was not successful. attendeeProp = Property("ATTENDEE", attendee, params={ "SCHEDULE-STATUS": iTIPRequestStatus.SERVICE_UNAVAILABLE, }) event.addProperty(attendeeProp) # TODO: We have talked about sending an email to the reply-to # at this point, to let them know that their reply was missing # the appropriate ATTENDEE. This will require a new localizable # email template for the message. txn = self.store.newTransaction(label="MailReceiver.processReply") yield txn.enqueue(IMIPReplyWork, organizer=organizer, attendee=attendee, icalendarText=str(calendar)) yield txn.commit() returnValue(self.INJECTION_SUBMITTED)
class MailReceiver(object): NO_TOKEN = 0 UNKNOWN_TOKEN = 1 UNKNOWN_TOKEN_OLD = 2 MALFORMED_TO_ADDRESS = 3 NO_ORGANIZER_ADDRESS = 4 REPLY_FORWARDED_TO_ORGANIZER = 5 INJECTION_SUBMITTED = 6 INCOMPLETE_DSN = 7 UNKNOWN_FAILURE = 8 def __init__(self, store, directory): self.store = store self.directory = directory def checkDSN(self, message): # returns (isdsn, action, icalendar attachment) report = deliveryStatus = calBody = None for part in message.walk(): contentType = part.get_content_type() if contentType == "multipart/report": report = part continue elif contentType == "message/delivery-status": deliveryStatus = part continue elif contentType == "message/rfc822": # original = part continue elif contentType == "text/calendar": calBody = part.get_payload(decode=True) continue if report is not None and deliveryStatus is not None: # we have what appears to be a dsn lines = str(deliveryStatus).split("\n") for line in lines: lower = line.lower() if lower.startswith("action:"): # found action: action = lower.split(' ')[1] break else: action = None return True, action, calBody else: # not a dsn return False, None, None def _extractToken(self, text): try: pre, _ignore_post = text.split('@') pre, token = pre.split('+') return token except ValueError: return None @inlineCallbacks def processDSN(self, calBody, msgId): calendar = Component.fromString(calBody) # Extract the token (from organizer property) organizer = calendar.getOrganizer() token = self._extractToken(organizer) if not token: log.error("Mail gateway can't find token in DSN {msgid}", msgid=msgId) return txn = self.store.newTransaction(label="MailReceiver.processDSN") records = (yield txn.imipLookupByToken(token)) yield txn.commit() try: # Note the results are returned as utf-8 encoded strings record = records[0] except: # This isn't a token we recognize log.error( "Mail gateway found a token ({token}) but didn't recognize it in message {msgid}", token=token, msgid=msgId, ) returnValue(self.UNKNOWN_TOKEN) calendar.removeAllButOneAttendee(record.attendee) calendar.getOrganizerProperty().setValue(organizer) for comp in calendar.subcomponents(): if comp.name() == "VEVENT": comp.addProperty( Property("REQUEST-STATUS", ["5.1", "Service unavailable"])) break else: # no VEVENT in the calendar body. # TODO: what to do in this case? pass log.warn("Mail gateway processing DSN {msgid}", msgid=msgId) txn = self.store.newTransaction(label="MailReceiver.processDSN") yield txn.enqueue(IMIPReplyWork, organizer=record.organizer, attendee=record.attendee, icalendarText=str(calendar)) yield txn.commit() returnValue(self.INJECTION_SUBMITTED) @inlineCallbacks def processReply(self, msg): # extract the token from the To header _ignore_name, addr = email.utils.parseaddr(msg['To']) if addr: # addr looks like: [email protected] token = self._extractToken(addr) if not token: log.error( "Mail gateway didn't find a token in message " "{msgid} ({to})", msgid=msg['Message-ID'], to=msg['To']) returnValue(self.NO_TOKEN) else: log.error( "Mail gateway couldn't parse To: address ({to}) in " "message {msgid}", to=msg['To'], msgid=msg['Message-ID']) returnValue(self.MALFORMED_TO_ADDRESS) txn = self.store.newTransaction(label="MailReceiver.processReply") records = (yield txn.imipLookupByToken(token)) yield txn.commit() try: # Note the results are returned as utf-8 encoded strings record = records[0] except: # This isn't a token we recognize log.info( "Mail gateway found a token ({token}) but didn't " "recognize it in message {msgid}", token=token, msgid=msg['Message-ID']) # Any email with an unknown token which was sent over 72 hours ago # is deleted. If we can't parse the date we leave it in the inbox. dateString = msg.get("Date") if dateString is not None: try: dateSent = dateutil.parser.parse(dateString) except Exception, e: log.info( "Could not parse date in IMIP email '{date}' ({ex})", date=dateString, ex=e, ) returnValue(self.UNKNOWN_TOKEN) now = datetime.datetime.now(dateutil.tz.tzutc()) if dateSent < now - datetime.timedelta(hours=72): returnValue(self.UNKNOWN_TOKEN_OLD) returnValue(self.UNKNOWN_TOKEN) for part in msg.walk(): if part.get_content_type() == "text/calendar": calBody = part.get_payload(decode=True) break else: # No icalendar attachment log.warn( "Mail gateway didn't find an icalendar attachment " "in message {msgid}", msgid=msg['Message-ID']) toAddr = None fromAddr = record.attendee[7:] if record.organizer.startswith("mailto:"): toAddr = record.organizer[7:] elif record.organizer.startswith("urn:x-uid:"): uid = record.organizer[10:] record = yield self.directory.recordWithUID(uid) try: if record and record.emailAddresses: toAddr = list(record.emailAddresses)[0] except AttributeError: pass if toAddr is None: log.error( "Don't have an email address for the organizer; ignoring reply." ) returnValue(self.NO_ORGANIZER_ADDRESS) settings = config.Scheduling["iMIP"]["Sending"] smtpSender = SMTPSender(settings.Username, settings.Password, settings.UseSSL, settings.Server, settings.Port) del msg["From"] msg["From"] = fromAddr del msg["Reply-To"] msg["Reply-To"] = fromAddr del msg["To"] msg["To"] = toAddr log.warn("Mail gateway forwarding reply back to organizer") yield smtpSender.sendMessage(fromAddr, toAddr, SMTPSender.betterMessageID(), msg.as_string()) returnValue(self.REPLY_FORWARDED_TO_ORGANIZER) # Process the imip attachment; inject to calendar server log.debug(calBody) calendar = Component.fromString(calBody) event = calendar.mainComponent() sanitizeCalendar(calendar) calendar.removeAllButOneAttendee(record.attendee) organizerProperty = calendar.getOrganizerProperty() if organizerProperty is None: # ORGANIZER is required per rfc2446 section 3.2.3 log.warn("Mail gateway didn't find an ORGANIZER in REPLY {msgid}", msgid=msg['Message-ID']) event.addProperty(Property("ORGANIZER", record.organizer)) else: organizerProperty.setValue(record.organizer) if not calendar.getAttendees(): # The attendee we're expecting isn't there, so add it back # with a SCHEDULE-STATUS of SERVICE_UNAVAILABLE. # The organizer will then see that the reply was not successful. attendeeProp = Property("ATTENDEE", record.attendee, params={ "SCHEDULE-STATUS": iTIPRequestStatus.SERVICE_UNAVAILABLE, }) event.addProperty(attendeeProp) # TODO: We have talked about sending an email to the reply-to # at this point, to let them know that their reply was missing # the appropriate ATTENDEE. This will require a new localizable # email template for the message. txn = self.store.newTransaction(label="MailReceiver.processReply") yield txn.enqueue(IMIPReplyWork, organizer=record.organizer, attendee=record.attendee, icalendarText=str(calendar)) yield txn.commit() returnValue(self.INJECTION_SUBMITTED)
def processReply(self, msg): # extract the token from the To header _ignore_name, addr = email.utils.parseaddr(msg['To']) if addr: # addr looks like: [email protected] token = self._extractToken(addr) if not token: log.error("Mail gateway didn't find a token in message " "%s (%s)" % (msg['Message-ID'], msg['To'])) returnValue(self.NO_TOKEN) else: log.error("Mail gateway couldn't parse To: address (%s) in " "message %s" % (msg['To'], msg['Message-ID'])) returnValue(self.MALFORMED_TO_ADDRESS) txn = self.store.newTransaction() result = (yield txn.imipLookupByToken(token)) yield txn.commit() try: # Note the results are returned as utf-8 encoded strings organizer, attendee, _ignore_icaluid = result[0] except: # This isn't a token we recognize log.error("Mail gateway found a token (%s) but didn't " "recognize it in message %s" % (token, msg['Message-ID'])) returnValue(self.UNKNOWN_TOKEN) for part in msg.walk(): if part.get_content_type() == "text/calendar": calBody = part.get_payload(decode=True) break else: # No icalendar attachment log.warn("Mail gateway didn't find an icalendar attachment " "in message %s" % (msg['Message-ID'],)) toAddr = None fromAddr = attendee[7:] if organizer.startswith("mailto:"): toAddr = organizer[7:] elif organizer.startswith("urn:uuid:"): guid = organizer[9:] record = self.directory.recordWithGUID(guid) if record and record.emailAddresses: toAddr = list(record.emailAddresses)[0] if toAddr is None: log.error("Don't have an email address for the organizer; " "ignoring reply.") returnValue(self.NO_ORGANIZER_ADDRESS) settings = config.Scheduling["iMIP"]["Sending"] smtpSender = SMTPSender(settings.Username, settings.Password, settings.UseSSL, settings.Server, settings.Port) del msg["From"] msg["From"] = fromAddr del msg["Reply-To"] msg["Reply-To"] = fromAddr del msg["To"] msg["To"] = toAddr log.warn("Mail gateway forwarding reply back to organizer") yield smtpSender.sendMessage(fromAddr, toAddr, messageid(), msg) returnValue(self.REPLY_FORWARDED_TO_ORGANIZER) # Process the imip attachment; inject to calendar server log.debug(calBody) calendar = Component.fromString(calBody) event = calendar.mainComponent() calendar.removeAllButOneAttendee(attendee) organizerProperty = calendar.getOrganizerProperty() if organizerProperty is None: # ORGANIZER is required per rfc2446 section 3.2.3 log.warn("Mail gateway didn't find an ORGANIZER in REPLY %s" % (msg['Message-ID'],)) event.addProperty(Property("ORGANIZER", organizer)) else: organizerProperty.setValue(organizer) if not calendar.getAttendees(): # The attendee we're expecting isn't there, so add it back # with a SCHEDULE-STATUS of SERVICE_UNAVAILABLE. # The organizer will then see that the reply was not successful. attendeeProp = Property("ATTENDEE", attendee, params={ "SCHEDULE-STATUS": iTIPRequestStatus.SERVICE_UNAVAILABLE, } ) event.addProperty(attendeeProp) # TODO: We have talked about sending an email to the reply-to # at this point, to let them know that their reply was missing # the appropriate ATTENDEE. This will require a new localizable # email template for the message. txn = self.store.newTransaction() yield txn.enqueue(IMIPReplyWork, organizer=organizer, attendee=attendee, icalendarText=str(calendar)) yield txn.commit() returnValue(self.INJECTION_SUBMITTED)
def generateEmail(self, inviteState, calendar, orgEmail, orgCN, attendees, fromAddress, replyToAddress, toAddress, language='en'): """ Generate MIME text containing an iMIP invitation, cancellation, update or reply. @param inviteState: 'new', 'update', or 'reply'. @type inviteState: C{str} @param calendar: the iCalendar component to attach to the email. @type calendar: L{twistedcaldav.ical.Component} @param orgEmail: The email for the organizer, in C{localhost@domain} format, or C{None} if the organizer has no email address. @type orgEmail: C{str} or C{NoneType} @param orgCN: Common name / display name for the organizer. @type orgCN: C{unicode} @param attendees: A C{list} of 2-C{tuple}s of (common name, email address) similar to (orgEmail, orgCN). @param fromAddress: the address to use in the C{From:} header of the email. @type fromAddress: C{str} @param replyToAddress: the address to use in the C{Reply-To} header. @type replyToAddress: C{str} @param toAddress: the address to use in the C{To} header. @type toAddress: C{str} @param language: a 2-letter language code describing the target language that the email should be generated in. @type language: C{str} @return: a 2-tuple of C{str}s: (message ID, message text). The message ID is the value of the C{Message-ID} header, and the message text is the full MIME message, ready for transport over SMTP. """ details = self.getEventDetails(calendar, language=language) canceled = (calendar.propertyValue("METHOD") == "CANCEL") subjectFormat, labels = localizedLabels(language, canceled, inviteState) details.update(labels) details['subject'] = subjectFormat % {'summary': details['summary']} plainText = self.renderPlainText(details, (orgCN, orgEmail), attendees, canceled) htmlText = self.renderHTML(details, (orgCN, orgEmail), attendees, canceled) msg = MIMEMultipart() msg["From"] = fromAddress msg["Subject"] = self._scrubHeader(details['subject']) msg["Reply-To"] = self._scrubHeader(replyToAddress) msg["To"] = self._scrubHeader(toAddress) msg["Date"] = rfc822date() msgId = SMTPSender.betterMessageID() msg["Message-ID"] = msgId msgAlt = MIMEMultipart("alternative") msg.attach(msgAlt) # plain version msgPlain = MIMEText(plainText, "plain", "UTF-8") msgAlt.attach(msgPlain) # html version msgHtmlRelated = MIMEMultipart("related", type="text/html") msgAlt.attach(msgHtmlRelated) msgHtml = MIMEText(htmlText, "html", "UTF-8") msgHtmlRelated.attach(msgHtml) # the icalendar attachment # Make sure we always have the timezones used in the calendar data as iMIP requires VTIMEZONE # always be present (i.e., timezones-by-reference is not allowed in iMIP). calendarText = calendar.getTextWithTimezones(includeTimezones=True) self.log.debug("Mail gateway sending calendar body: {body}", body=calendarText) msgIcal = MIMEText(calendarText, "calendar", "UTF-8") method = calendar.propertyValue("METHOD").lower() msgIcal.set_param("method", method) msgIcal.add_header("Content-ID", "<invitation.ics>") msgIcal.add_header("Content-Disposition", "inline;filename=invitation.ics") msg.attach(msgIcal) return msgId, msg.as_string()