Пример #1
0
    def setUp(self):
        self.store = yield buildStore(self, None)
        self.directory = self.store.directoryService()
        self.sender = MailSender(
            "*****@*****.**", 7, DummySMTPSender(),
            language="en")

        def _getSender(ignored):
            return self.sender
        self.patch(IMIPInvitationWork, "getMailSender", _getSender)
Пример #2
0
    def setUp(self):
        self.store = yield buildStore(self, None)
        self.directory = self.store.directoryService()
        self.sender = MailSender(
            "*****@*****.**", 7, DummySMTPSender(),
            language="en")

        def _getSender(ignored):
            return self.sender
        self.patch(IMIPInvitationWork, "getMailSender", _getSender)

        self.wp = None
        self.store.queuer.callWithNewProposals(self._proposalCallback)
Пример #3
0
    def setUp(self):
        self.store = yield buildStore(self, None)
        self.directory = self.store.directoryService()
        self.sender = MailSender(
            "*****@*****.**", 7, DummySMTPSender(),
            language="en")

        def _getSender(ignored):
            return self.sender
        self.patch(IMIPInvitationWork, "getMailSender", _getSender)
    def setUp(self):
        self.store = yield buildStore(self, None)
        self.directory = self.store.directoryService()
        self.sender = MailSender("*****@*****.**", 7, DummySMTPSender(),
            language="en")

        def _getSender(ignored):
            return self.sender
        self.patch(IMIPInvitationWork, "getMailSender", _getSender)

        self.wp = None
        self.store.queuer.callWithNewProposals(self._proposalCallback)
Пример #5
0
class OutboundTests(unittest.TestCase):

    @inlineCallbacks
    def setUp(self):
        self.store = yield buildStore(self, None)
        self.directory = self.store.directoryService()
        self.sender = MailSender(
            "*****@*****.**", 7, DummySMTPSender(),
            language="en")

        def _getSender(ignored):
            return self.sender
        self.patch(IMIPInvitationWork, "getMailSender", _getSender)

    @inlineCallbacks
    def test_work(self):
        txn = self.store.newTransaction()
        yield txn.enqueue(
            IMIPInvitationWork,
            fromAddr=ORGANIZER,
            toAddr=ATTENDEE,
            icalendarText=initialInviteText.replace("\n", "\r\n"),
        )
        yield txn.commit()
        yield JobItem.waitEmpty(self.store.newTransaction, reactor, 60)

        txn = self.store.newTransaction()
        record = (yield txn.imipGetToken(
            ORGANIZER,
            ATTENDEE,
            ICALUID
        ))
        self.assertTrue(record is not None)
        record = (yield txn.imipLookupByToken(record.token))[0]
        yield txn.commit()
        self.assertEquals(record.organizer, ORGANIZER)
        self.assertEquals(record.attendee, ATTENDEE)
        self.assertEquals(record.icaluid, ICALUID)

    @inlineCallbacks
    def test_workFailure(self):
        self.sender.smtpSender.shouldSucceed = False

        txn = self.store.newTransaction()
        yield txn.enqueue(
            IMIPInvitationWork,
            fromAddr=ORGANIZER,
            toAddr=ATTENDEE,
            icalendarText=initialInviteText.replace("\n", "\r\n"),
        )
        yield txn.commit()
        yield JobItem.waitEmpty(self.store.newTransaction, reactor, 60)

    def _interceptEmail(
        self, inviteState, calendar, orgEmail, orgCn,
        attendees, fromAddress, replyToAddress, toAddress, language="en"
    ):
        self.inviteState = inviteState
        self.calendar = calendar
        self.orgEmail = orgEmail
        self.orgCn = orgCn
        self.attendees = attendees
        self.fromAddress = fromAddress
        self.replyToAddress = replyToAddress
        self.toAddress = toAddress
        self.language = language
        self.results = self._actualGenerateEmail(
            inviteState, calendar,
            orgEmail, orgCn, attendees, fromAddress, replyToAddress, toAddress,
            language=language)
        return self.results

    @inlineCallbacks
    def test_outbound(self):
        """
        Make sure outbound( ) stores tokens properly so they can be looked up
        """

        config.Scheduling.iMIP.Sending.Address = "*****@*****.**"
        self.patch(config.Localization, "LocalesDirectory", os.path.join(os.path.dirname(__file__), "locales"))
        self._actualGenerateEmail = self.sender.generateEmail
        self.patch(self.sender, "generateEmail", self._interceptEmail)

        data = (
            # Initial invite
            (
                initialInviteText,
                "CFDD5E46-4F74-478A-9311-B3FF905449C3",
                "urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A",
                "mailto:[email protected]",
                "new",
                "*****@*****.**",
                u"Th\xe9 Organizer",
                [
                    (u'Th\xe9 Attendee', u'*****@*****.**'),
                    (u'Th\xe9 Organizer', u'*****@*****.**'),
                    (u'An Attendee without CUTYPE', u'*****@*****.**'),
                    (None, u'*****@*****.**'),
                ],
                u"Th\xe9 Organizer <*****@*****.**>",
                "=?utf-8?q?Th=C3=A9_Organizer_=3Corganizer=40example=2Ecom=3E?=",
                "*****@*****.**",
                "Event invitation: testing outbound( ) Embedded: Header",
            ),

            # Update
            (
                u"""BEGIN:VCALENDAR
VERSION:2.0
METHOD:REQUEST
BEGIN:VEVENT
UID:CFDD5E46-4F74-478A-9311-B3FF905449C3
DTSTART:20100325T154500Z
DTEND:20100325T164500Z
ATTENDEE;CN=Th\xe9 Attendee;CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:
 mailto:[email protected]
ATTENDEE;CN=Th\xe9 Organizer;CUTYPE=INDIVIDUAL;[email protected];PAR
 TSTAT=ACCEPTED:urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A
ORGANIZER;CN=Th\xe9 Organizer;[email protected]:urn:uuid:C3B38B00-41
 66-11DD-B22C-A07C87E02F6A
SUMMARY:t\xe9sting outbound( ) *update*
END:VEVENT
END:VCALENDAR
""".encode("utf-8"),
                "CFDD5E46-4F74-478A-9311-B3FF905449C3",
                "urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A",
                "mailto:[email protected]",
                "update",
                "*****@*****.**",
                u"Th\xe9 Organizer",
                [
                    (u'Th\xe9 Attendee', u'*****@*****.**'),
                    (u'Th\xe9 Organizer', u'*****@*****.**')
                ],
                u"Th\xe9 Organizer <*****@*****.**>",
                "=?utf-8?q?Th=C3=A9_Organizer_=3Corganizer=40example=2Ecom=3E?=",
                "*****@*****.**",
                "=?utf-8?q?Event_update=3A_t=C3=A9sting_outbound=28_=29_*update*?=",
            ),

            # Reply
            (
                u"""BEGIN:VCALENDAR
VERSION:2.0
METHOD:REPLY
BEGIN:VEVENT
UID:DFDD5E46-4F74-478A-9311-B3FF905449C4
DTSTART:20100325T154500Z
DTEND:20100325T164500Z
ATTENDEE;CN=Th\xe9 Attendee;CUTYPE=INDIVIDUAL;[email protected];PARTST
 AT=ACCEPTED:urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A
ORGANIZER;CN=Th\xe9 Organizer;[email protected]:mailto:organizer@exam
 ple.com
SUMMARY:t\xe9sting outbound( ) *reply*
END:VEVENT
END:VCALENDAR
""".encode("utf-8"),
                None,
                "urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A",
                "mailto:[email protected]",
                "reply",
                "*****@*****.**",
                u"Th\xe9 Organizer",
                [
                    (u'Th\xe9 Attendee', u'*****@*****.**'),
                ],
                "*****@*****.**",
                "*****@*****.**",
                "*****@*****.**",
                "=?utf-8?q?Event_reply=3A_t=C3=A9sting_outbound=28_=29_*reply*?=",
            ),

        )
        for (
            inputCalendar, UID, inputOriginator, inputRecipient, inviteState,
            outputOrganizerEmail, outputOrganizerName, outputAttendeeList,
            outputFrom, encodedFrom, outputRecipient, outputSubject
        ) in data:

            txn = self.store.newTransaction()
            yield self.sender.outbound(
                txn,
                inputOriginator,
                inputRecipient,
                Component.fromString(inputCalendar.replace("\n", "\r\n")),
                onlyAfter=DateTime(2010, 1, 1, 0, 0, 0)
            )
            yield txn.commit()

            msg = email.message_from_string(self.sender.smtpSender.message)
            self.assertEquals(msg["From"], encodedFrom)
            self.assertEquals(self.inviteState, inviteState)
            self.assertEquals(self.orgEmail, outputOrganizerEmail)
            self.assertEquals(self.orgCn, outputOrganizerName)
            self.assertEquals(self.attendees, outputAttendeeList)
            self.assertEquals(self.fromAddress, outputFrom)
            self.assertEquals(self.toAddress, outputRecipient)
            self.assertEquals(msg["Subject"], outputSubject)

            if UID:  # The organizer is local, and server is sending to remote
                    # attendee
                txn = self.store.newTransaction()
                record = (yield txn.imipGetToken(inputOriginator, inputRecipient, UID))
                yield txn.commit()
                self.assertNotEquals(record, None)
                self.assertEquals(
                    msg["Reply-To"],
                    "*****@*****.**" % (record.token,))

                # Make sure attendee property for organizer exists and matches
                # the CUA of the organizer property
                orgValue = self.calendar.getOrganizerProperty().value()
                self.assertEquals(
                    orgValue,
                    self.calendar.getAttendeeProperty([orgValue]).value()
                )

            else:  # Reply only -- the attendee is local, and server is sending reply to remote organizer

                self.assertEquals(msg["Reply-To"], self.fromAddress)

            # Check that we don't send any messages for events completely in
            # the past.
            self.sender.smtpSender.reset()
            txn = self.store.newTransaction()
            yield self.sender.outbound(
                txn,
                inputOriginator,
                inputRecipient,
                Component.fromString(inputCalendar.replace("\n", "\r\n")),
                onlyAfter=DateTime(2021, 1, 1, 0, 0, 0)
            )
            yield txn.commit()
            self.assertFalse(self.sender.smtpSender.sendMessageCalled)

    @inlineCallbacks
    def test_tokens(self):
        txn = self.store.newTransaction()
        self.assertEquals((yield txn.imipLookupByToken("xyzzy")), [])
        yield txn.commit()

        txn = self.store.newTransaction()
        record1 = (yield txn.imipCreateToken("organizer", "attendee", "icaluid"))
        yield txn.commit()

        txn = self.store.newTransaction()
        record2 = (yield txn.imipGetToken("organizer", "attendee", "icaluid"))
        yield txn.commit()
        self.assertEquals(record1.token, record2.token)

        txn = self.store.newTransaction()
        record = (yield txn.imipLookupByToken(record1.token))[0]
        self.assertEquals(
            [record.organizer, record.attendee, record.icaluid],
            ["organizer", "attendee", "icaluid"])
        yield txn.commit()

        txn = self.store.newTransaction()
        yield txn.imipRemoveToken(record1.token)
        yield txn.commit()

        txn = self.store.newTransaction()
        self.assertEquals((yield txn.imipLookupByToken(record1.token)), [])
        yield txn.commit()

    @inlineCallbacks
    def test_mailtoTokens(self):
        """
        Make sure old mailto tokens are still honored
        """

        organizerEmail = "mailto:[email protected]"

        # Explictly store a token with mailto: CUA for organizer
        # (something that doesn't happen any more, but did in the past)
        txn = self.store.newTransaction()
        origRecord = (yield txn.imipCreateToken(
            organizerEmail,
            "mailto:[email protected]",
            "CFDD5E46-4F74-478A-9311-B3FF905449C3"
        ))
        yield txn.commit()

        inputCalendar = initialInviteText
        UID = "CFDD5E46-4F74-478A-9311-B3FF905449C3"
        inputOriginator = "urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A"
        inputRecipient = "mailto:[email protected]"

        txn = self.store.newTransaction()
        yield self.sender.outbound(
            txn, inputOriginator, inputRecipient,
            Component.fromString(inputCalendar.replace("\n", "\r\n")),
            onlyAfter=DateTime(2010, 1, 1, 0, 0, 0))
        yield txn.commit()

        # Verify we didn't create a new token...
        txn = self.store.newTransaction()
        record = (yield txn.imipGetToken(inputOriginator, inputRecipient, UID))
        yield txn.commit()
        self.assertEquals(record, None)

        # But instead kept the old one...
        txn = self.store.newTransaction()
        record = (yield txn.imipGetToken(organizerEmail, inputRecipient, UID))
        yield txn.commit()
        self.assertEquals(record.token, origRecord.token)

    def generateSampleEmail(self, caltext=initialInviteText):
        """
        Invoke L{MailHandler.generateEmail} and parse the result.
        """
        calendar = Component.fromString(caltext)
        msgID, msgTxt = self.sender.generateEmail(
            inviteState='new',
            calendar=calendar,
            orgEmail=u"user01@localhost",
            orgCN=u"User Z\xe9ro One",
            attendees=[(u"Us\xe9r One", "user01@localhost"),
                       (u"User 2", "user02@localhost")],
            fromAddress="user01@localhost",
            replyToAddress="imip-system@localhost",
            toAddress="user03@localhost",
        )
        message = email.message_from_string(msgTxt)
        return msgID, message

    def test_generateEmail(self):
        """
        L{MailHandler.generateEmail} generates a MIME-formatted email with a
        text/plain part, a text/html part, and a text/calendar part.
        """
        msgID, message = self.generateSampleEmail()
        self.assertEquals(message['Message-ID'], msgID)
        expectedTypes = set(["text/plain", "text/html", "text/calendar"])
        actualTypes = set([
            part.get_content_type() for part in message.walk()
            if part.get_content_type().startswith("text/")
        ])
        self.assertEquals(actualTypes, expectedTypes)

    def test_generateEmail_noOrganizerCN(self):
        """
        L{MailHandler.generateEmail} generates a MIME-formatted email when
        the organizer property has no CN parameter.
        """
        calendar = Component.fromString(initialInviteText)
        _ignore_msgID, msgTxt = self.sender.generateEmail(
            inviteState='new',
            calendar=calendar,
            orgEmail=u"user01@localhost",
            orgCN=None,
            attendees=[(u"Us\xe9r One", "user01@localhost"),
                       (u"User 2", "user02@localhost")],
            fromAddress="user01@localhost",
            replyToAddress="imip-system@localhost",
            toAddress="user03@localhost",
        )
        message = email.message_from_string(msgTxt)
        self.assertTrue(message is not None)

    def test_generateEmail_noAttendeeCN(self):
        """
        L{MailHandler.generateEmail} generates a MIME-formatted email when
        the attendee property has no CN parameter.
        """
        calendar = Component.fromString(initialInviteText)
        _ignore_msgID, msgTxt = self.sender.generateEmail(
            inviteState='new',
            calendar=calendar,
            orgEmail=u"user01@localhost",
            orgCN=u"User Z\xe9ro One",
            attendees=[(None, "user01@localhost"),
                       (None, "user02@localhost")],
            fromAddress="user01@localhost",
            replyToAddress="imip-system@localhost",
            toAddress="user03@localhost",
        )
        message = email.message_from_string(msgTxt)
        self.assertTrue(message is not None)

    def test_messageID(self):
        """
        L{SMTPSender.betterMessageID} generates a Message-ID domain matching
        the L{config.ServerHostName} value.
        """
        self.patch(config, "ServerHostName", "calendar.example.com")
        msgID, message = self.generateSampleEmail()
        self.assertEquals(message['Message-ID'], msgID)
        self.assertEqual(msgID[:-1].split("@")[1], config.ServerHostName)

    def test_alwaysIncludeTimezones(self):
        """
        L{MailHandler.generateEmail} generates a MIME-formatted email with a
        text/plain part, a text/html part, and a text/calendar part.
        """
        _ignore, message = self.generateSampleEmail(inviteTextWithTimezone)
        calparts = tuple(typed_subpart_iterator(message, "text", "calendar"))
        self.assertEqual(len(calparts), 1)
        caldata = calparts[0].get_payload(decode=True)
        self.assertTrue("BEGIN:VTIMEZONE" in caldata)
        self.assertTrue("TZID:America/New_York" in caldata)

        _ignore, message = self.generateSampleEmail(inviteTextNoTimezone)
        calparts = tuple(typed_subpart_iterator(message, "text", "calendar"))
        self.assertEqual(len(calparts), 1)
        caldata = calparts[0].get_payload(decode=True)
        self.assertTrue("BEGIN:VTIMEZONE" in caldata)
        self.assertTrue("TZID:America/New_York" in caldata)

    def test_emailEncoding(self):
        """
        L{MailHandler.generateEmail} will preserve any non-ASCII characters
        present in the fields that it formats in the message body.
        """
        _ignore_msgID, message = self.generateSampleEmail()
        textPart = partByType(message, "text/plain")
        htmlPart = partByType(message, "text/html")

        plainText = textPart.get_payload(decode=True).decode(
            textPart.get_content_charset()
        )
        htmlText = htmlPart.get_payload(decode=True).decode(
            htmlPart.get_content_charset()
        )

        self.assertIn(u"Us\u00e9r One", plainText)
        self.assertIn(u'<a href="mailto:user01@localhost">Us\u00e9r One</a>',
                      htmlText)

        # The same assertion, but with the organizer's form.
        self.assertIn(
            u'<a href="mailto:user01@localhost">User Z\u00e9ro One</a>',
            htmlText)

    def test_emailQuoting(self):
        """
        L{MailHandler.generateEmail} will HTML-quote all relevant fields in the
        HTML part, but not the text/plain part.
        """
        _ignore_msgID, message = self.generateSampleEmail()
        htmlPart = partByType(message, "text/html").get_payload(decode=True)
        plainPart = partByType(message, "text/plain").get_payload(decode=True)
        expectedPlain = 'awesome description with "<" and "&"'
        expectedHTML = expectedPlain.replace("&", "&amp;").replace("<", "&lt;")

        self.assertIn(expectedPlain, plainPart)
        self.assertIn(expectedHTML, htmlPart)

    def test_stringFormatTemplateLoader(self):
        """
        L{StringFormatTemplateLoader.load} will convert a template with
        C{%(x)s}-format slots by converting it to a template with C{<t:slot
        name="x" />} slots, and a renderer on the document element named
        according to the constructor argument.
        """
        class StubElement(Element):
            loader = StringFormatTemplateLoader(
                lambda: StringIO(
                    "<test><alpha>%(slot1)s</alpha>%(other)s</test>"
                ),
                "testRenderHere"
            )

            @renderer
            def testRenderHere(self, request, tag):
                return tag.fillSlots(slot1="hello",
                                     other="world")
        result = []
        flattenString(None, StubElement()).addCallback(result.append)
        self.assertEquals(
            list(result),
            ["<test><alpha>hello</alpha>world</test>"]
        )

    def test_templateLoaderWithAttributes(self):
        """
        L{StringFormatTemplateLoader.load} will convert a template with
        C{%(x)s}-format slots inside attributes into t:attr elements containing
        t:slot slots.
        """
        class StubElement(Element):
            loader = StringFormatTemplateLoader(
                lambda: StringIO(
                    '<test><alpha beta="before %(slot1)s after">inner</alpha>'
                    '%(other)s</test>'
                ),
                "testRenderHere"
            )

            @renderer
            def testRenderHere(self, request, tag):
                return tag.fillSlots(slot1="hello",
                                     other="world")
        result = []
        flattenString(None, StubElement()).addCallback(result.append)
        self.assertEquals(
            result,
            [
                '<test><alpha beta="before hello after">'
                'inner</alpha>world</test>'
            ]
        )

    def test_templateLoaderTagSoup(self):
        """
        L{StringFormatTemplateLoader.load} will convert a template with
        C{%(x)s}-format slots into t:slot slots, and render a well-formed output
        document, even if the input is malformed (i.e. missing necessary closing
        tags).
        """
        class StubElement(Element):
            loader = StringFormatTemplateLoader(
                lambda: StringIO(
                    '<test><alpha beta="before %(slot1)s after">inner</alpha>'
                    '%(other)s'
                ),
                "testRenderHere"
            )

            @renderer
            def testRenderHere(self, request, tag):
                return tag.fillSlots(slot1="hello",
                                     other="world")
        result = []
        flattenString(None, StubElement()).addCallback(result.append)
        self.assertEquals(result,
                          ['<test><alpha beta="before hello after">'
                           'inner</alpha>world</test>'])

    def test_scrubHeader(self):

        self.assertEquals(self.sender._scrubHeader("ABC"), "ABC")
        self.assertEquals(self.sender._scrubHeader("ABC: 123\nXYZ: 456"), "ABC: 123 XYZ: 456")
class OutboundTests(unittest.TestCase):

    @inlineCallbacks
    def setUp(self):
        self.store = yield buildStore(self, None)
        self.directory = self.store.directoryService()
        self.sender = MailSender("*****@*****.**", 7, DummySMTPSender(),
            language="en")

        def _getSender(ignored):
            return self.sender
        self.patch(IMIPInvitationWork, "getMailSender", _getSender)

        self.wp = None
        self.store.queuer.callWithNewProposals(self._proposalCallback)


    def _proposalCallback(self, wp):
        self.wp = wp


    @inlineCallbacks
    def test_work(self):
        txn = self.store.newTransaction()
        wp = (yield txn.enqueue(IMIPInvitationWork,
            fromAddr=ORGANIZER,
            toAddr=ATTENDEE,
            icalendarText=initialInviteText.replace("\n", "\r\n"),
        ))
        self.assertEquals(wp, self.wp)
        yield txn.commit()
        yield wp.whenExecuted()

        txn = self.store.newTransaction()
        token = (yield txn.imipGetToken(
            ORGANIZER,
            ATTENDEE,
            ICALUID
        ))
        self.assertTrue(token)
        organizer, attendee, icaluid = (yield txn.imipLookupByToken(token))[0]
        yield txn.commit()
        self.assertEquals(organizer, ORGANIZER)
        self.assertEquals(attendee, ATTENDEE)
        self.assertEquals(icaluid, ICALUID)


    @inlineCallbacks
    def test_workFailure(self):
        self.sender.smtpSender.shouldSucceed = False

        txn = self.store.newTransaction()
        wp = (yield txn.enqueue(IMIPInvitationWork,
            fromAddr=ORGANIZER,
            toAddr=ATTENDEE,
            icalendarText=initialInviteText.replace("\n", "\r\n"),
        ))
        yield txn.commit()
        yield wp.whenExecuted()
        # Verify a new work proposal was not created
        self.assertEquals(wp, self.wp)


    def _interceptEmail(self, inviteState, calendar, orgEmail, orgCn,
        attendees, fromAddress, replyToAddress, toAddress, language="en"):
        self.inviteState = inviteState
        self.calendar = calendar
        self.orgEmail = orgEmail
        self.orgCn = orgCn
        self.attendees = attendees
        self.fromAddress = fromAddress
        self.replyToAddress = replyToAddress
        self.toAddress = toAddress
        self.language = language
        self.results = self._actualGenerateEmail(inviteState, calendar,
            orgEmail, orgCn, attendees, fromAddress, replyToAddress, toAddress,
            language=language)
        return self.results


    @inlineCallbacks
    def test_outbound(self):
        """
        Make sure outbound( ) stores tokens properly so they can be looked up
        """

        config.Scheduling.iMIP.Sending.Address = "*****@*****.**"
        self.patch(config.Localization, "LocalesDirectory", os.path.join(os.path.dirname(__file__), "locales"))
        self._actualGenerateEmail = self.sender.generateEmail
        self.patch(self.sender, "generateEmail", self._interceptEmail)

        data = (
            # Initial invite
            (
                initialInviteText,
                "CFDD5E46-4F74-478A-9311-B3FF905449C3",
                "urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A",
                "mailto:[email protected]",
                "new",
                "*****@*****.**",
                u"Th\xe9 Organizer",
                [
                    (u'Th\xe9 Attendee', u'*****@*****.**'),
                    (u'Th\xe9 Organizer', u'*****@*****.**'),
                    (u'An Attendee without CUTYPE', u'*****@*****.**'),
                    (None, u'*****@*****.**'),
                ],
                u"Th\xe9 Organizer <*****@*****.**>",
                "=?utf-8?q?Th=C3=A9_Organizer_=3Corganizer=40example=2Ecom=3E?=",
                "*****@*****.**",
            ),

            # Update
            (
                """BEGIN:VCALENDAR
VERSION:2.0
METHOD:REQUEST
BEGIN:VEVENT
UID:CFDD5E46-4F74-478A-9311-B3FF905449C3
DTSTART:20100325T154500Z
DTEND:20100325T164500Z
ATTENDEE;CN=The Attendee;CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:
 mailto:[email protected]
ATTENDEE;CN=The Organizer;CUTYPE=INDIVIDUAL;[email protected];PAR
 TSTAT=ACCEPTED:urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A
ORGANIZER;CN=The Organizer;[email protected]:urn:uuid:C3B38B00-41
 66-11DD-B22C-A07C87E02F6A
SUMMARY:testing outbound( ) *update*
END:VEVENT
END:VCALENDAR
""",
                "CFDD5E46-4F74-478A-9311-B3FF905449C3",
                "urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A",
                "mailto:[email protected]",
                "update",
                "*****@*****.**",
                "The Organizer",
                [
                    (u'The Attendee', u'*****@*****.**'),
                    (u'The Organizer', u'*****@*****.**')
                ],
                "The Organizer <*****@*****.**>",
                "The Organizer <*****@*****.**>",
                "*****@*****.**",
            ),

            # Reply
            (
                """BEGIN:VCALENDAR
VERSION:2.0
METHOD:REPLY
BEGIN:VEVENT
UID:DFDD5E46-4F74-478A-9311-B3FF905449C4
DTSTART:20100325T154500Z
DTEND:20100325T164500Z
ATTENDEE;CN=The Attendee;CUTYPE=INDIVIDUAL;[email protected];PARTST
 AT=ACCEPTED:urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A
ORGANIZER;CN=The Organizer;[email protected]:mailto:organizer@exam
 ple.com
SUMMARY:testing outbound( ) *reply*
END:VEVENT
END:VCALENDAR
""",
                None,
                "urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A",
                "mailto:[email protected]",
                "reply",
                "*****@*****.**",
                "The Organizer",
                [
                    (u'The Attendee', u'*****@*****.**'),
                ],
                "*****@*****.**",
                "*****@*****.**",
                "*****@*****.**",
            ),

        )
        for (inputCalendar, UID, inputOriginator, inputRecipient, inviteState,
            outputOrganizerEmail, outputOrganizerName, outputAttendeeList,
            outputFrom, encodedFrom, outputRecipient) in data:

            txn = self.store.newTransaction()
            yield self.sender.outbound(
                txn,
                inputOriginator,
                inputRecipient,
                Component.fromString(inputCalendar.replace("\n", "\r\n")),
                onlyAfter=DateTime(2010, 1, 1, 0, 0, 0)
            )
            yield txn.commit()

            msg = email.message_from_string(self.sender.smtpSender.message)
            self.assertEquals(msg["From"], encodedFrom)
            self.assertEquals(self.inviteState, inviteState)
            self.assertEquals(self.orgEmail, outputOrganizerEmail)
            self.assertEquals(self.orgCn, outputOrganizerName)
            self.assertEquals(self.attendees, outputAttendeeList)
            self.assertEquals(self.fromAddress, outputFrom)
            self.assertEquals(self.toAddress, outputRecipient)

            if UID: # The organizer is local, and server is sending to remote
                    # attendee
                txn = self.store.newTransaction()
                token = (yield txn.imipGetToken(inputOriginator, inputRecipient,
                    UID))
                yield txn.commit()
                self.assertNotEquals(token, None)
                self.assertEquals(msg["Reply-To"],
                    "*****@*****.**" % (token,))

                # Make sure attendee property for organizer exists and matches
                # the CUA of the organizer property
                orgValue = self.calendar.getOrganizerProperty().value()
                self.assertEquals(
                    orgValue,
                    self.calendar.getAttendeeProperty([orgValue]).value()
                )

            else: # Reply only -- the attendee is local, and server is sending reply to remote organizer

                self.assertEquals(msg["Reply-To"], self.fromAddress)

            # Check that we don't send any messages for events completely in
            # the past.
            self.sender.smtpSender.reset()
            txn = self.store.newTransaction()
            yield self.sender.outbound(
                txn,
                inputOriginator,
                inputRecipient,
                Component.fromString(inputCalendar.replace("\n", "\r\n")),
                onlyAfter=DateTime(2021, 1, 1, 0, 0, 0)
            )
            yield txn.commit()
            self.assertFalse(self.sender.smtpSender.sendMessageCalled)


    @inlineCallbacks
    def test_tokens(self):
        txn = self.store.newTransaction()
        token = (yield txn.imipLookupByToken("xyzzy"))
        yield txn.commit()
        self.assertEquals(token, [])

        txn = self.store.newTransaction()
        token1 = (yield txn.imipCreateToken("organizer", "attendee", "icaluid"))
        yield txn.commit()

        txn = self.store.newTransaction()
        token2 = (yield txn.imipGetToken("organizer", "attendee", "icaluid"))
        yield txn.commit()
        self.assertEquals(token1, token2)

        txn = self.store.newTransaction()
        self.assertEquals((yield txn.imipLookupByToken(token1)),
            [["organizer", "attendee", "icaluid"]])
        yield txn.commit()

        txn = self.store.newTransaction()
        yield txn.imipRemoveToken(token1)
        yield txn.commit()

        txn = self.store.newTransaction()
        self.assertEquals((yield txn.imipLookupByToken(token1)), [])
        yield txn.commit()


    @inlineCallbacks
    def test_mailtoTokens(self):
        """
        Make sure old mailto tokens are still honored
        """

        organizerEmail = "mailto:[email protected]"

        # Explictly store a token with mailto: CUA for organizer
        # (something that doesn't happen any more, but did in the past)
        txn = self.store.newTransaction()
        origToken = (yield txn.imipCreateToken(organizerEmail,
            "mailto:[email protected]",
            "CFDD5E46-4F74-478A-9311-B3FF905449C3"
            )
        )
        yield txn.commit()

        inputCalendar = initialInviteText
        UID = "CFDD5E46-4F74-478A-9311-B3FF905449C3"
        inputOriginator = "urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A"
        inputRecipient = "mailto:[email protected]"

        txn = self.store.newTransaction()
        yield self.sender.outbound(txn, inputOriginator, inputRecipient,
            Component.fromString(inputCalendar.replace("\n", "\r\n")),
            onlyAfter=DateTime(2010, 1, 1, 0, 0, 0))

        # Verify we didn't create a new token...
        txn = self.store.newTransaction()
        token = (yield txn.imipGetToken(inputOriginator, inputRecipient, UID))
        yield txn.commit()
        self.assertEquals(token, None)

        # But instead kept the old one...
        txn = self.store.newTransaction()
        token = (yield txn.imipGetToken(organizerEmail, inputRecipient, UID))
        yield txn.commit()
        self.assertEquals(token, origToken)


    def generateSampleEmail(self):
        """
        Invoke L{MailHandler.generateEmail} and parse the result.
        """
        calendar = Component.fromString(initialInviteText)
        msgID, msgTxt = self.sender.generateEmail(
            inviteState='new',
            calendar=calendar,
            orgEmail=u"user01@localhost",
            orgCN=u"User Z\xe9ro One",
            attendees=[(u"Us\xe9r One", "user01@localhost"),
                       (u"User 2", "user02@localhost")],
            fromAddress="user01@localhost",
            replyToAddress="imip-system@localhost",
            toAddress="user03@localhost",
        )
        message = email.message_from_string(msgTxt)
        return msgID, message


    def test_generateEmail(self):
        """
        L{MailHandler.generateEmail} generates a MIME-formatted email with a
        text/plain part, a text/html part, and a text/calendar part.
        """
        msgID, message = self.generateSampleEmail()
        self.assertEquals(message['Message-ID'], msgID)
        expectedTypes = set(["text/plain", "text/html", "text/calendar"])
        actualTypes = set([
            part.get_content_type() for part in message.walk()
            if part.get_content_type().startswith("text/")
        ])
        self.assertEquals(actualTypes, expectedTypes)


    def test_emailEncoding(self):
        """
        L{MailHandler.generateEmail} will preserve any non-ASCII characters
        present in the fields that it formats in the message body.
        """
        _ignore_msgID, message = self.generateSampleEmail()
        textPart = partByType(message, "text/plain")
        htmlPart = partByType(message, "text/html")

        plainText = textPart.get_payload(decode=True).decode(
            textPart.get_content_charset()
        )
        htmlText = htmlPart.get_payload(decode=True).decode(
            htmlPart.get_content_charset()
        )

        self.assertIn(u"Us\u00e9r One", plainText)
        self.assertIn(u'<a href="mailto:user01@localhost">Us\u00e9r One</a>',
                      htmlText)

        # The same assertion, but with the organizer's form.
        self.assertIn(
            u'<a href="mailto:user01@localhost">User Z\u00e9ro One</a>',
            htmlText)


    def test_emailQuoting(self):
        """
        L{MailHandler.generateEmail} will HTML-quote all relevant fields in the
        HTML part, but not the text/plain part.
        """
        _ignore_msgID, message = self.generateSampleEmail()
        htmlPart = partByType(message, "text/html").get_payload(decode=True)
        plainPart = partByType(message, "text/plain").get_payload(decode=True)
        expectedPlain = 'awesome description with "<" and "&"'
        expectedHTML = expectedPlain.replace("&", "&amp;").replace("<", "&lt;")

        self.assertIn(expectedPlain, plainPart)
        self.assertIn(expectedHTML, htmlPart)


    def test_stringFormatTemplateLoader(self):
        """
        L{StringFormatTemplateLoader.load} will convert a template with
        C{%(x)s}-format slots by converting it to a template with C{<t:slot
        name="x" />} slots, and a renderer on the document element named
        according to the constructor argument.
        """
        class StubElement(Element):
            loader = StringFormatTemplateLoader(
                lambda : StringIO(
                    "<test><alpha>%(slot1)s</alpha>%(other)s</test>"
                ),
                "testRenderHere"
            )

            @renderer
            def testRenderHere(self, request, tag):
                return tag.fillSlots(slot1="hello",
                                     other="world")
        result = []
        flattenString(None, StubElement()).addCallback(result.append)
        self.assertEquals(result,
                          ["<test><alpha>hello</alpha>world</test>"])


    def test_templateLoaderWithAttributes(self):
        """
        L{StringFormatTemplateLoader.load} will convert a template with
        C{%(x)s}-format slots inside attributes into t:attr elements containing
        t:slot slots.
        """
        class StubElement(Element):
            loader = StringFormatTemplateLoader(
                lambda : StringIO(
                    '<test><alpha beta="before %(slot1)s after">inner</alpha>'
                    '%(other)s</test>'
                ),
                "testRenderHere"
            )

            @renderer
            def testRenderHere(self, request, tag):
                return tag.fillSlots(slot1="hello",
                                     other="world")
        result = []
        flattenString(None, StubElement()).addCallback(result.append)
        self.assertEquals(result,
                          ['<test><alpha beta="before hello after">'
                           'inner</alpha>world</test>'])


    def test_templateLoaderTagSoup(self):
        """
        L{StringFormatTemplateLoader.load} will convert a template with
        C{%(x)s}-format slots into t:slot slots, and render a well-formed output
        document, even if the input is malformed (i.e. missing necessary closing
        tags).
        """
        class StubElement(Element):
            loader = StringFormatTemplateLoader(
                lambda : StringIO(
                    '<test><alpha beta="before %(slot1)s after">inner</alpha>'
                    '%(other)s'
                ),
                "testRenderHere"
            )

            @renderer
            def testRenderHere(self, request, tag):
                return tag.fillSlots(slot1="hello",
                                     other="world")
        result = []
        flattenString(None, StubElement()).addCallback(result.append)
        self.assertEquals(result,
                          ['<test><alpha beta="before hello after">'
                           'inner</alpha>world</test>'])