Beispiel #1
0
 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
Beispiel #2
0
    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()
Beispiel #3
0
    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)
Beispiel #4
0
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)
Beispiel #6
0
    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()