Esempio n. 1
0
    def requestToken(self, emailAddress, clientSecret, sendAttempt, nextLink, ipaddress=None):
        valSessionStore = ThreePidValSessionStore(self.sydent)

        valSession = valSessionStore.getOrCreateTokenSession(medium='email', address=emailAddress,
                                                             clientSecret=clientSecret)

        valSessionStore.setMtime(valSession.id, time_msec())

        if int(valSession.sendAttemptNumber) >= int(sendAttempt):
            logger.info("Not mailing code because current send attempt (%d) is not less than given send attempt (%s)", int(sendAttempt), int(valSession.sendAttemptNumber))
            return valSession.id

        ipstring = ipaddress if ipaddress else u"an unknown location"

        substitutions = {
            'ipaddress': ipstring,
            'link': self.makeValidateLink(valSession, clientSecret, nextLink),
            'token': valSession.token,
        }
        logger.info(
            "Attempting to mail code %s (nextLink: %s) to %s",
            valSession.token, nextLink, emailAddress,
        )
        sendEmail(self.sydent, 'email.template', emailAddress, substitutions)

        valSessionStore.setSendAttemptNumber(valSession.id, sendAttempt)

        return valSession.id
Esempio n. 2
0
    def test_migration_email(self):
        with patch("sydent.util.emailutils.smtplib") as smtplib:
            # self.sydent.config.email.template is deprecated
            if self.sydent.config.email.template is None:
                templateFile = self.sydent.get_branded_template(
                    None,
                    "migration_template.eml",
                )
            else:
                templateFile = self.sydent.config.email.template

            sendEmail(
                self.sydent,
                templateFile,
                "*****@*****.**",
                {
                    "mxid": "@bob:example.com",
                    "subject_header_value": "MatrixID Deletion",
                },
            )
            smtp = smtplib.SMTP.return_value
            email_contents = smtp.sendmail.call_args[0][2].decode("utf-8")
            self.assertIn("In the past", email_contents)

            # test email was sent
            smtp.sendmail.assert_called()
Esempio n. 3
0
    def test_jinja_vector_verification(self):
        substitutions = {
            "address": "*****@*****.**",
            "medium": "email",
            "to": "*****@*****.**",
            "link": "https://link_test.com",
        }

        templateFile = self.sydent.get_branded_template(
            "vector-im",
            "verification_template.eml",
        )

        with patch("sydent.util.emailutils.smtplib") as smtplib:
            sendEmail(self.sydent, templateFile, "*****@*****.**",
                      substitutions)

        smtp = smtplib.SMTP.return_value
        email_contents = smtp.sendmail.call_args[0][2].decode("utf-8")

        path = os.path.join(
            self.sydent.config.general.templates_path,
            "vector_verification_sample.txt",
        )

        with open(path, "r") as file:
            expected_text = file.read()

        # remove the email headers as they are variable
        email_contents = email_contents[email_contents.index("Hello"):]

        # test all ouput is as expected
        self.assertEqual(email_contents, expected_text)
Esempio n. 4
0
    def test_jinja_matrix_verification(self):
        substitutions = {
            "address": "*****@*****.**",
            "medium": "email",
            "to": "*****@*****.**",
            "token": "<<token>>",
            "link": "https://link_test.com",
        }

        templateFile = self.sydent.get_branded_template(
            "matrix-org",
            "verification_template.eml",
        )

        with patch("sydent.util.emailutils.smtplib") as smtplib:
            sendEmail(self.sydent, templateFile, "*****@*****.**",
                      substitutions)

        smtp = smtplib.SMTP.return_value
        email_contents = smtp.sendmail.call_args[0][2].decode("utf-8")

        # test html input is escaped
        self.assertIn("&lt;&lt;token&gt;&gt;", email_contents)

        # test safe values are not escaped
        self.assertIn("<<token>>", email_contents)
Esempio n. 5
0
    def requestToken(self,
                     emailAddress,
                     clientSecret,
                     sendAttempt,
                     nextLink,
                     ipaddress=None):
        """
        Creates or retrieves a validation session and sends an email to the corresponding
        email address with a token to use to verify the association.

        :param emailAddress: The email address to send the email to.
        :type emailAddress: unicode
        :param clientSecret: The client secret to use.
        :type clientSecret: unicode
        :param sendAttempt: The current send attempt.
        :type sendAttempt: int
        :param nextLink: The link to redirect the user to once they have completed the
            validation.
        :type nextLink: unicode
        :param ipaddress: The requester's IP address.
        :type ipaddress: str or None

        :return: The ID of the session created (or of the existing one if any)
        :rtype: int
        """
        valSessionStore = ThreePidValSessionStore(self.sydent)

        valSession = valSessionStore.getOrCreateTokenSession(
            medium=u'email', address=emailAddress, clientSecret=clientSecret)

        valSessionStore.setMtime(valSession.id, time_msec())

        if int(valSession.sendAttemptNumber) >= int(sendAttempt):
            logger.info(
                "Not mailing code because current send attempt (%d) is not less than given send attempt (%s)",
                int(sendAttempt), int(valSession.sendAttemptNumber))
            return valSession.id

        ipstring = ipaddress if ipaddress else u"an unknown location"

        substitutions = {
            'ipaddress': ipstring,
            'link': self.makeValidateLink(valSession, clientSecret, nextLink),
            'token': valSession.token,
        }
        logger.info(
            "Attempting to mail code %s (nextLink: %s) to %s",
            valSession.token,
            nextLink,
            emailAddress,
        )
        sendEmail(self.sydent, 'email.template', emailAddress, substitutions)

        valSessionStore.setSendAttemptNumber(valSession.id, sendAttempt)

        return valSession.id
Esempio n. 6
0
def sendEmailWithBackoff(
    sydent: Sydent,
    address: str,
    mxid: str,
    test: bool = False,
) -> None:
    """Send an email with exponential backoff - that way we don't stop sending halfway
    through if the SMTP server rejects our email (e.g. because of rate limiting).

    Setting test to True disables the backoff.

    Raises a CantSendEmailException if no email could be sent after MAX_ATTEMPTS_FOR_EMAIL
    attempts.
    """

    # Disable backoff if we're running tests.
    backoff = 1 if not test else 0

    for i in range(MAX_ATTEMPTS_FOR_EMAIL):
        try:
            template_file = sydent.get_branded_template(
                None,
                "migration_template.eml",
            )

            sendEmail(
                sydent,
                template_file,
                address,
                {"mxid": mxid},
                log_send_errors=False,
            )
            logger.info("Sent email to %s" % address)

            return
        except EmailSendException:
            logger.info(
                "Failed to send email to %s (attempt %d/%d)"
                % (address, i + 1, MAX_ATTEMPTS_FOR_EMAIL)
            )

            time.sleep(backoff)
            backoff *= 2

    raise CantSendEmailException()
Esempio n. 7
0
    def requestToken(self,
                     emailAddress,
                     clientSecret,
                     sendAttempt,
                     nextLink,
                     ipaddress=None):
        valSessionStore = ThreePidValSessionStore(self.sydent)

        valSession = valSessionStore.getOrCreateTokenSession(
            medium='email', address=emailAddress, clientSecret=clientSecret)

        valSessionStore.setMtime(valSession.id, time_msec())

        if int(valSession.sendAttemptNumber) >= int(sendAttempt):
            logger.info(
                "Not mailing code because current send attempt (%d) is not less than given send attempt (%s)",
                int(sendAttempt), int(valSession.sendAttemptNumber))
            return valSession.id

        ipstring = ipaddress if ipaddress else u"an unknown location"

        substitutions = {
            'ipaddress': ipstring,
            'link': self.makeValidateLink(valSession, clientSecret, nextLink),
            'token': valSession.token,
        }
        logger.info(
            "Attempting to mail code %s (nextLink: %s) to %s",
            valSession.token,
            nextLink,
            emailAddress,
        )
        sendEmail(self.sydent, 'email.template', emailAddress, substitutions)

        valSessionStore.setSendAttemptNumber(valSession.id, sendAttempt)

        return valSession.id
Esempio n. 8
0
    def render_POST(self, request):
        send_cors(request)
        err, args = get_args(request, ("medium", "address", "room_id", "sender",))
        if err:
            return json.dumps(err)
        medium = args["medium"]
        address = args["address"]
        roomId = args["room_id"]
        sender = args["sender"]

        globalAssocStore = GlobalAssociationStore(self.sydent)
        mxid = globalAssocStore.getMxid(medium, address)
        if mxid:
            request.setResponseCode(400)
            return json.dumps({
                "errcode": "THREEPID_IN_USE",
                "error": "Binding already known",
                "mxid": mxid,
            })

        if medium != "email":
            request.setResponseCode(400)
            return json.dumps({
                "errcode": "M_UNRECOGNIZED",
                "error": "Didn't understand medium '%s'" % (medium,),
            })

        token = self._randomString(128)

        tokenStore = JoinTokenStore(self.sydent)

        ephemeralPrivateKey = nacl.signing.SigningKey.generate()
        ephemeralPublicKey = ephemeralPrivateKey.verify_key

        ephemeralPrivateKeyBase64 = encode_base64(ephemeralPrivateKey.encode(), True)
        ephemeralPublicKeyBase64 = encode_base64(ephemeralPublicKey.encode(), True)

        tokenStore.storeEphemeralPublicKey(ephemeralPublicKeyBase64)
        tokenStore.storeToken(medium, address, roomId, sender, token)

        substitutions = {}
        for key, values in request.args.items():
            if len(values) == 1 and type(values[0]) == str:
                substitutions[key] = values[0]
        substitutions["token"] = token

        required = [
            'sender_display_name',
            'token',
            'room_name',
            'bracketed_room_name',
            'room_avatar_url',
            'sender_display_name',
            'guest_user_id',
            'guest_access_token',
        ]
        for k in required:
            substitutions.setdefault(k, '')

        substitutions["ephemeral_private_key"] = ephemeralPrivateKeyBase64
        if substitutions["room_name"] != '':
            substitutions["bracketed_room_name"] = "(%s)" % substitutions["room_name"]

        subject_header = Header(self.sydent.cfg.get('email', 'email.invite.subject', raw=True) % substitutions, 'utf8')
        substitutions["subject_header_value"] = subject_header.encode()

        sendEmail(self.sydent, "email.invite_template", address, substitutions)

        pubKey = self.sydent.keyring.ed25519.verify_key
        pubKeyBase64 = encode_base64(pubKey.encode())

        baseUrl = "%s/_matrix/identity/api/v1" % (self.sydent.cfg.get('http', 'client_http_base'),)

        keysToReturn = []
        keysToReturn.append({
            "public_key": pubKeyBase64,
            "key_validity_url": baseUrl + "/pubkey/isvalid",
        })
        keysToReturn.append({
            "public_key": ephemeralPublicKeyBase64,
            "key_validity_url": baseUrl + "/pubkey/ephemeral/isvalid",
        })

        resp = {
            "token": token,
            "public_key": pubKeyBase64,
            "public_keys": keysToReturn,
            "display_name": self.redact(address),
        }

        return json.dumps(resp)
Esempio n. 9
0
    def render_POST(self, request):
        send_cors(request)
        err = require_args(request, ("medium", "address", "room_id", "sender",))
        if err:
            return json.dumps(err)
        medium = request.args["medium"][0]
        address = request.args["address"][0]
        roomId = request.args["room_id"][0]
        sender = request.args["sender"][0]

        globalAssocStore = GlobalAssociationStore(self.sydent)
        mxid = globalAssocStore.getMxid(medium, address)
        if mxid:
            request.setResponseCode(400)
            return json.dumps({
                "errcode": "THREEPID_IN_USE",
                "error": "Binding already known",
                "mxid": mxid,
            })

        if medium != "email":
            request.setResponseCode(400)
            return json.dumps({
                "errcode": "M_UNRECOGNIZED",
                "error": "Didn't understand medium '%s'" % (medium,),
            })

        token = self._randomString(128)

        tokenStore = JoinTokenStore(self.sydent)

        ephemeralPrivateKey = nacl.signing.SigningKey.generate()
        ephemeralPublicKey = ephemeralPrivateKey.verify_key

        ephemeralPrivateKeyBase64 = encode_base64(ephemeralPrivateKey.encode(), True)
        ephemeralPublicKeyBase64 = encode_base64(ephemeralPublicKey.encode(), True)

        tokenStore.storeEphemeralPublicKey(ephemeralPublicKeyBase64)
        tokenStore.storeToken(medium, address, roomId, sender, token)

        substitutions = {}
        for key, values in request.args.items():
            if len(values) == 1 and type(values[0]) == str:
                substitutions[key] = values[0]
        substitutions["token"] = token

        required = [
            'sender_display_name',
            'token',
            'room_name',
            'bracketed_room_name',
            'room_avatar_url',
            'sender_display_name',
            'guest_user_id',
            'guest_access_token',
        ]
        for k in required:
            substitutions.setdefault(k, '')

        substitutions["ephemeral_private_key"] = ephemeralPrivateKeyBase64
        if substitutions["room_name"] != '':
            substitutions["bracketed_room_name"] = "(%s)" % substitutions["room_name"]

        subject_header = Header(self.sydent.cfg.get('email', 'email.invite.subject', raw=True) % substitutions, 'utf8')
        substitutions["subject_header_value"] = subject_header.encode()

        sendEmail(self.sydent, "email.invite_template", address, substitutions)

        pubKey = self.sydent.keyring.ed25519.verify_key
        pubKeyBase64 = encode_base64(pubKey.encode())

        baseUrl = "%s/_matrix/identity/api/v1" % (self.sydent.cfg.get('http', 'client_http_base'),)

        keysToReturn = []
        keysToReturn.append({
            "public_key": pubKeyBase64,
            "key_validity_url": baseUrl + "/pubkey/isvalid",
        })
        keysToReturn.append({
            "public_key": ephemeralPublicKeyBase64,
            "key_validity_url": baseUrl + "/pubkey/ephemeral/isvalid",
        })

        resp = {
            "token": token,
            "public_key": pubKeyBase64,
            "public_keys": keysToReturn,
            "display_name": self.redact(address),
        }

        return json.dumps(resp)
Esempio n. 10
0
    def render_POST(self, request):
        send_cors(request)

        authIfV2(self.sydent, request)

        args = get_args(request, ("medium", "address", "room_id", "sender",))
        medium = args["medium"]
        address = args["address"]
        roomId = args["room_id"]
        sender = args["sender"]

        globalAssocStore = GlobalAssociationStore(self.sydent)
        mxid = globalAssocStore.getMxid(medium, address)
        if mxid:
            request.setResponseCode(400)
            return {
                "errcode": "M_THREEPID_IN_USE",
                "error": "Binding already known",
                "mxid": mxid,
            }

        if medium != "email":
            request.setResponseCode(400)
            return {
                "errcode": "M_UNRECOGNIZED",
                "error": "Didn't understand medium '%s'" % (medium,),
            }

        token = self._randomString(128)

        tokenStore = JoinTokenStore(self.sydent)

        ephemeralPrivateKey = nacl.signing.SigningKey.generate()
        ephemeralPublicKey = ephemeralPrivateKey.verify_key

        ephemeralPrivateKeyBase64 = encode_base64(ephemeralPrivateKey.encode(), True)
        ephemeralPublicKeyBase64 = encode_base64(ephemeralPublicKey.encode(), True)

        tokenStore.storeEphemeralPublicKey(ephemeralPublicKeyBase64)
        tokenStore.storeToken(medium, address, roomId, sender, token)

        # Variables to substitute in the template.
        substitutions = {}
        # Include all arguments sent via the request.
        for k, v in args.items():
            if isinstance(v, string_types):
                substitutions[k] = v
        substitutions["token"] = token

        # Substitutions that the template requires, but are optional to provide
        # to the API.
        extra_substitutions = [
            'sender_display_name',
            'token',
            'room_name',
            'bracketed_room_name',
            'room_avatar_url',
            'sender_avatar_url',
            'guest_user_id',
            'guest_access_token',
        ]
        for k in extra_substitutions:
            substitutions.setdefault(k, '')

        substitutions["ephemeral_private_key"] = ephemeralPrivateKeyBase64
        if substitutions["room_name"] != '':
            substitutions["bracketed_room_name"] = "(%s)" % substitutions["room_name"]

        substitutions["web_client_location"] = self.sydent.default_web_client_location
        if 'org.matrix.web_client_location' in substitutions:
            substitutions["web_client_location"] = substitutions.pop("org.matrix.web_client_location")

        subject_header = Header(self.sydent.cfg.get('email', 'email.invite.subject', raw=True) % substitutions, 'utf8')
        substitutions["subject_header_value"] = subject_header.encode()

        brand = self.sydent.brand_from_request(request)
        templateFile = self.sydent.get_branded_template(
            brand,
            "invite_template.eml",
            ('email', 'email.invite_template'),
        )

        sendEmail(self.sydent, templateFile, address, substitutions)

        pubKey = self.sydent.keyring.ed25519.verify_key
        pubKeyBase64 = encode_base64(pubKey.encode())

        baseUrl = "%s/_matrix/identity/api/v1" % (self.sydent.cfg.get('http', 'client_http_base'),)

        keysToReturn = []
        keysToReturn.append({
            "public_key": pubKeyBase64,
            "key_validity_url": baseUrl + "/pubkey/isvalid",
        })
        keysToReturn.append({
            "public_key": ephemeralPublicKeyBase64,
            "key_validity_url": baseUrl + "/pubkey/ephemeral/isvalid",
        })

        resp = {
            "token": token,
            "public_key": pubKeyBase64,
            "public_keys": keysToReturn,
            "display_name": self.redact_email_address(address),
        }

        return resp
Esempio n. 11
0
    def render_POST(self, request: Request) -> JsonDict:
        send_cors(request)

        args = get_args(
            request,
            (
                "medium",
                "address",
                "room_id",
                "sender",
            ),
        )
        medium = args["medium"]
        address = args["address"]
        roomId = args["room_id"]
        sender = args["sender"]

        # ensure we are casefolding email address before storing
        normalised_address = normalise_address(address, medium)

        verified_sender = None
        if self.require_auth:
            account = authV2(self.sydent, request)
            verified_sender = sender
            if account.userId != sender:
                raise MatrixRestError(403, "M_UNAUTHORIZED",
                                      "'sender' doesn't match")

        globalAssocStore = GlobalAssociationStore(self.sydent)
        mxid = globalAssocStore.getMxid(medium, normalised_address)
        if mxid:
            request.setResponseCode(400)
            return {
                "errcode": "M_THREEPID_IN_USE",
                "error": "Binding already known",
                "mxid": mxid,
            }

        if medium != "email":
            request.setResponseCode(400)
            return {
                "errcode": "M_UNRECOGNIZED",
                "error": "Didn't understand medium '%s'" % (medium, ),
            }

        if not (0 < len(address) <= MAX_EMAIL_ADDRESS_LENGTH):
            request.setResponseCode(400)
            return {
                "errcode": "M_INVALID_PARAM",
                "error": "Invalid email provided"
            }

        token = self._randomString(128)

        tokenStore = JoinTokenStore(self.sydent)

        ephemeralPrivateKey = nacl.signing.SigningKey.generate()
        ephemeralPublicKey = ephemeralPrivateKey.verify_key

        ephemeralPrivateKeyBase64 = encode_base64(ephemeralPrivateKey.encode(),
                                                  True)
        ephemeralPublicKeyBase64 = encode_base64(ephemeralPublicKey.encode(),
                                                 True)

        tokenStore.storeEphemeralPublicKey(ephemeralPublicKeyBase64)
        tokenStore.storeToken(medium, normalised_address, roomId, sender,
                              token)

        # Variables to substitute in the template.
        substitutions = {}
        # Include all arguments sent via the request.
        for k, v in args.items():
            if isinstance(v, str):
                substitutions[k] = v
        substitutions["token"] = token

        # Substitutions that the template requires, but are optional to provide
        # to the API.
        extra_substitutions = [
            "sender_display_name",
            "token",
            "room_name",
            "bracketed_room_name",
            "room_avatar_url",
            "sender_avatar_url",
            "guest_user_id",
            "guest_access_token",
        ]
        for k in extra_substitutions:
            substitutions.setdefault(k, "")

        # For MSC3288 room type, prefer the stable field, but fallback to the
        # unstable field.
        if "room_type" not in substitutions:
            substitutions["room_type"] = substitutions.get(
                "org.matrix.msc3288.room_type", "")

        substitutions["bracketed_verified_sender"] = ""
        if verified_sender:
            substitutions["bracketed_verified_sender"] = "(%s) " % (
                verified_sender, )

        substitutions["ephemeral_private_key"] = ephemeralPrivateKeyBase64
        if substitutions["room_name"] != "":
            substitutions[
                "bracketed_room_name"] = "(%s) " % substitutions["room_name"]

        substitutions[
            "web_client_location"] = self.sydent.config.email.default_web_client_location
        if "org.matrix.web_client_location" in substitutions:
            substitutions["web_client_location"] = substitutions[
                "org.matrix.web_client_location"]

        if substitutions["room_type"] == "m.space":
            subject = self.sydent.config.email.invite_subject_space % substitutions
        else:
            subject = self.sydent.config.email.invite_subject % substitutions

        substitutions["subject_header_value"] = Header(subject,
                                                       "utf8").encode()

        brand = self.sydent.brand_from_request(request)

        # self.sydent.config.email.invite_template is deprecated
        if self.sydent.config.email.invite_template is None:
            templateFile = self.sydent.get_branded_template(
                brand,
                "invite_template.eml",
            )
        else:
            templateFile = self.sydent.config.email.invite_template

        try:
            sendEmail(self.sydent, templateFile, normalised_address,
                      substitutions)
        except EmailAddressException:
            request.setResponseCode(HTTPStatus.BAD_REQUEST)
            return {
                "errcode": "M_INVALID_EMAIL",
                "error": "Invalid email address"
            }

        pubKey = self.sydent.keyring.ed25519.verify_key
        pubKeyBase64 = encode_base64(pubKey.encode())

        baseUrl = "%s/_matrix/identity/api/v1" % (
            self.sydent.config.http.server_http_url_base, )

        keysToReturn = []
        keysToReturn.append({
            "public_key": pubKeyBase64,
            "key_validity_url": baseUrl + "/pubkey/isvalid",
        })
        keysToReturn.append({
            "public_key":
            ephemeralPublicKeyBase64,
            "key_validity_url":
            baseUrl + "/pubkey/ephemeral/isvalid",
        })

        resp = {
            "token": token,
            "public_key": pubKeyBase64,
            "public_keys": keysToReturn,
            "display_name": self.redact_email_address(address),
        }

        return resp
Esempio n. 12
0
    def test_jinja_matrix_invite(self):
        substitutions = {
            "address": "*****@*****.**",
            "medium": "email",
            "room_alias": "#somewhere:exmaple.org",
            "room_avatar_url": "mxc://example.org/s0meM3dia",
            "room_id": "!something:example.org",
            "room_name": "Bob's Emporium of Messages",
            "sender": "@bob:example.com",
            "sender_avatar_url": "mxc://example.org/an0th3rM3dia",
            "sender_display_name": "<Bob Smith>",
            "bracketed_verified_sender": "Bob Smith",
            "bracketed_room_name": "Bob's Emporium of Messages",
            "to": "*****@*****.**",
            "token": "a_token",
            "ephemeral_private_key": "mystery_key",
            "web_client_location": "https://matrix.org",
            "room_type": "",
        }

        # self.sydent.config.email.invite_template is deprecated
        if self.sydent.config.email.invite_template is None:
            templateFile = self.sydent.get_branded_template(
                "matrix-org",
                "invite_template.eml",
            )
        else:
            templateFile = self.sydent.config.email.invite_template

        with patch("sydent.util.emailutils.smtplib") as smtplib:
            sendEmail(self.sydent, templateFile, "*****@*****.**",
                      substitutions)

        smtp = smtplib.SMTP.return_value
        email_contents = smtp.sendmail.call_args[0][2].decode("utf-8")

        # test url input is encoded
        self.assertIn(urllib.parse.quote("mxc://example.org/s0meM3dia"),
                      email_contents)

        # test html input is escaped
        self.assertIn("Bob&#39;s Emporium of Messages", email_contents)

        # test safe values are not escaped
        self.assertIn("<Bob Smith>", email_contents)

        # test our link is as expected
        expected_url = (
            "https://matrix.org/#/room/" +
            urllib.parse.quote("!something:example.org") + "?email=" +
            urllib.parse.quote("*****@*****.**") +
            "&signurl=https%3A%2F%2Fmatrix.org%2F_matrix%2Fidentity%2Fapi%2Fv1%2Fsign-ed25519%3Ftoken%3D"
            + urllib.parse.quote("a_token") + "%26private_key%3D" +
            urllib.parse.quote("mystery_key") + "&room_name=" +
            urllib.parse.quote("Bob's Emporium of Messages") +
            "&room_avatar_url=" +
            urllib.parse.quote("mxc://example.org/s0meM3dia") +
            "&inviter_name=" + urllib.parse.quote("<Bob Smith>") +
            "&guest_access_token=&guest_user_id=&room_type=")
        text = email_contents.splitlines()
        link = text[22]
        self.assertEqual(link, expected_url)