diff --git a/README.md b/README.md index 37cf21b..2dc2139 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,12 @@ modules: period: 6w # How long before an account expires should Synapse send it a renewal email. renew_at: 1w + # Whether to include a link to click in the emails sent to users. If false, only a + # renewal token is sent, in which case a shorter token is used, and the + # user will need to copy it into a compatible client that will send an + # authenticated request to the server. + # Defaults to true. + send_links: true ``` The syntax for durations is the same as in the rest of Synapse's configuration file. diff --git a/email_account_validity/_base.py b/email_account_validity/_base.py index 996af84..9bddcaf 100644 --- a/email_account_validity/_base.py +++ b/email_account_validity/_base.py @@ -14,6 +14,7 @@ # limitations under the License. import logging +import os import time from typing import Optional, Tuple @@ -24,7 +25,13 @@ from email_account_validity._config import EmailAccountValidityConfig from email_account_validity._store import EmailAccountValidityStore -from email_account_validity._utils import random_string +from email_account_validity._utils import ( + LONG_TOKEN_REGEX, + SHORT_TOKEN_REGEX, + random_digit_string, + random_string, + TokenFormat, +) logger = logging.getLogger(__name__) @@ -40,9 +47,11 @@ def __init__( self._store = store self._period = config.period + self._send_links = config.send_links (self._template_html, self._template_text,) = api.read_templates( ["notice_expiry.html", "notice_expiry.txt"], + os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates"), ) if config.renew_email_subject is not None: @@ -53,7 +62,7 @@ def __init__( try: app_name = self._api.email_app_name self._renew_email_subject = renew_email_subject % {"app": app_name} - except Exception: + except (KeyError, TypeError): # If substitution failed, fall back to the bare strings. self._renew_email_subject = renew_email_subject @@ -110,17 +119,27 @@ async def send_renewal_email(self, user_id: str, expiration_ts: int): except SynapseError: display_name = user_id - renewal_token = await self.generate_renewal_token(user_id) + # If the user isn't expected to click on a link, but instead to copy the token + # into their client, we generate a different kind of token, simpler and shorter, + # because a) we don't need it to be unique to the whole table and b) we want the + # user to be able to be easily type it back into their client. + if self._send_links: + renewal_token = await self.generate_unauthenticated_renewal_token(user_id) - url = "%s_synapse/client/email_account_validity/renew?token=%s" % ( - self._api.public_baseurl, - renewal_token, - ) + url = "%s_synapse/client/email_account_validity/renew?token=%s" % ( + self._api.public_baseurl, + renewal_token, + ) + else: + renewal_token = await self.generate_authenticated_renewal_token(user_id) + url = None template_vars = { + "app_name": self._api.email_app_name, "display_name": display_name, "expiration_ts": expiration_ts, "url": url, + "renewal_token": renewal_token, } html_text = self._template_html.render(**template_vars) @@ -128,17 +147,40 @@ async def send_renewal_email(self, user_id: str, expiration_ts: int): for address in addresses: await self._api.send_mail( - address, - self._renew_email_subject, - html_text, - plain_text, + recipient=address, + subject=self._renew_email_subject, + html=html_text, + text=plain_text, ) await self._store.set_renewal_mail_status(user_id=user_id, email_sent=True) - async def generate_renewal_token(self, user_id: str) -> str: - """Generates a 32-byte long random string that will be inserted into the - user's renewal email's unique link, then saves it into the database. + async def generate_authenticated_renewal_token(self, user_id: str) -> str: + """Generates a 8-digit long random string then saves it into the database. + + This token is to be sent to the user over email so that the user can copy it into + their client to renew their account. + + Args: + user_id: ID of the user to generate a string for. + + Returns: + The generated string. + + Raises: + SynapseError(500): Couldn't generate a unique string after 5 attempts. + """ + renewal_token = random_digit_string(8) + await self._store.set_renewal_token_for_user( + user_id, renewal_token, TokenFormat.SHORT, + ) + return renewal_token + + async def generate_unauthenticated_renewal_token(self, user_id: str) -> str: + """Generates a 32-letter long random string then saves it into the database. + + This token is to be sent to the user over email in a link that the user will then + click to renew their account. Args: user_id: ID of the user to generate a string for. @@ -153,13 +195,19 @@ async def generate_renewal_token(self, user_id: str) -> str: while attempts < 5: try: renewal_token = random_string(32) - await self._store.set_renewal_token_for_user(user_id, renewal_token) + await self._store.set_renewal_token_for_user( + user_id, renewal_token, TokenFormat.LONG, + ) return renewal_token except SynapseError: attempts += 1 raise SynapseError(500, "Couldn't generate a unique string as refresh string.") - async def renew_account(self, renewal_token: str) -> Tuple[bool, bool, int]: + async def renew_account( + self, + renewal_token: str, + user_id: Optional[str] = None, + ) -> Tuple[bool, bool, int]: """Renews the account attached to a given renewal token by pushing back the expiration date by the current validity period in the server's configuration. @@ -169,6 +217,9 @@ async def renew_account(self, renewal_token: str) -> Tuple[bool, bool, int]: Args: renewal_token: Token sent with the renewal request. + user_id: The Matrix ID of the user to renew, if the renewal request was + authenticated. + Returns: A tuple containing: * A bool representing whether the token is valid and unused. @@ -176,12 +227,32 @@ async def renew_account(self, renewal_token: str) -> Tuple[bool, bool, int]: * An int representing the user's expiry timestamp as milliseconds since the epoch, or 0 if the token was invalid. """ + # Try to match the token against a known format. + if LONG_TOKEN_REGEX.match(renewal_token): + token_format = TokenFormat.LONG + elif SHORT_TOKEN_REGEX.match(renewal_token): + token_format = TokenFormat.SHORT + else: + # If we can't figure out what format the renewal token is, consider it + # invalid. + return False, False, 0 + + # If we were not able to authenticate the user requesting a renewal, and the + # token needs authentication, consider the token neither valid nor stale. + if user_id is None and token_format == TokenFormat.SHORT: + return False, False, 0 + + # Verify if the token, or the (token, user_id) tuple, exists. try: ( user_id, current_expiration_ts, token_used_ts, - ) = await self._store.get_user_from_renewal_token(renewal_token) + ) = await self._store.validate_renewal_token( + renewal_token, + token_format, + user_id, + ) except SynapseError: return False, False, 0 @@ -238,6 +309,7 @@ async def renew_account_for_user( user_id=user_id, expiration_ts=expiration_ts, email_sent=email_sent, + token_format=TokenFormat.LONG if self._send_links else TokenFormat.SHORT, renewal_token=renewal_token, token_used_ts=now, ) diff --git a/email_account_validity/_config.py b/email_account_validity/_config.py index ea61af2..c7f6ee2 100644 --- a/email_account_validity/_config.py +++ b/email_account_validity/_config.py @@ -22,3 +22,4 @@ class EmailAccountValidityConfig: period: int renew_at: int renew_email_subject: Optional[str] = None + send_links: bool = True diff --git a/email_account_validity/_servlets.py b/email_account_validity/_servlets.py index c17363f..b810502 100644 --- a/email_account_validity/_servlets.py +++ b/email_account_validity/_servlets.py @@ -12,6 +12,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import os from synapse.module_api import ( DirectServeHtmlResource, @@ -19,7 +20,11 @@ ModuleApi, respond_with_html, ) -from synapse.module_api.errors import ConfigError, SynapseError +from synapse.module_api.errors import ( + ConfigError, + InvalidClientCredentialsError, + SynapseError, +) from twisted.web.resource import Resource from email_account_validity._base import EmailAccountValidityBase @@ -64,7 +69,8 @@ def __init__( "account_renewed.html", "account_previously_renewed.html", "invalid_token.html", - ] + ], + os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates"), ) async def _async_render_GET(self, request): @@ -76,11 +82,17 @@ async def _async_render_GET(self, request): renewal_token = request.args[b"token"][0].decode("utf-8") + try: + requester = await self._api.get_user_by_req(request, allow_expired=True) + user_id = requester.user.to_string() + except InvalidClientCredentialsError: + user_id = None + ( token_valid, token_stale, expiration_ts, - ) = await self.renew_account(renewal_token) + ) = await self.renew_account(renewal_token, user_id) if token_valid: status_code = 200 @@ -93,7 +105,7 @@ async def _async_render_GET(self, request): expiration_ts=expiration_ts ) else: - status_code = 404 + status_code = 400 response = self._invalid_token_template.render() respond_with_html(request, status_code, response) @@ -130,15 +142,6 @@ class EmailAccountValidityAdminServlet( EmailAccountValidityBase, DirectServeJsonResource, ): - def __init__( - self, - config: EmailAccountValidityConfig, - api: ModuleApi, - store: EmailAccountValidityStore, - ): - EmailAccountValidityBase.__init__(self, config, api, store) - DirectServeJsonResource.__init__(self) - async def _async_render_POST(self, request): """On POST requests on /admin, update the given user with the given account validity state, if the requester is a server admin. diff --git a/email_account_validity/_store.py b/email_account_validity/_store.py index 5ea91f0..b8b5291 100644 --- a/email_account_validity/_store.py +++ b/email_account_validity/_store.py @@ -19,11 +19,19 @@ from typing import Dict, List, Optional, Tuple, Union from synapse.module_api import DatabasePool, LoggingTransaction, ModuleApi, cached +from synapse.module_api.errors import SynapseError from email_account_validity._config import EmailAccountValidityConfig +from email_account_validity._utils import TokenFormat logger = logging.getLogger(__name__) +# The name of the column to look at for each type of renewal token. +_TOKEN_COLUMN_NAME = { + TokenFormat.LONG: "long_renewal_token", + TokenFormat.SHORT: "short_renewal_token", +} + class EmailAccountValidityStore: def __init__(self, config: EmailAccountValidityConfig, api: ModuleApi): @@ -43,16 +51,44 @@ def create_table_txn(txn: LoggingTransaction): txn.execute( """ CREATE TABLE IF NOT EXISTS email_account_validity( + -- The user's Matrix ID. user_id TEXT PRIMARY KEY, + -- The expiration timestamp for this user in milliseconds. expiration_ts_ms BIGINT NOT NULL, + -- Whether a renewal email has already been sent to this user. email_sent BOOLEAN NOT NULL, - renewal_token TEXT, + -- Long renewal tokens, which are unique to the whole table, so that + -- renewing an account using one doesn't require further + -- authentication. + long_renewal_token TEXT, + -- Short renewal tokens, which aren't unique to the whole table, and + -- with which renewing an account requires authentication using an + -- access token. + short_renewal_token TEXT, + -- Timestamp at which the renewal token for the user has been used, + -- or NULL if it hasn't been used yet. token_used_ts_ms BIGINT ) """, (), ) + txn.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS long_renewal_token_idx + ON email_account_validity(long_renewal_token) + """, + (), + ) + + txn.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS short_renewal_token_idx + ON email_account_validity(short_renewal_token, user_id) + """, + (), + ) + def populate_table_txn(txn: LoggingTransaction, batch_size: int) -> int: # Populate the database with the users that are in the users table but not in # the email_account_validity one. @@ -151,8 +187,9 @@ async def get_users_expiring_soon(self) -> List[Dict[str, Union[str, int]]]: A list of dictionaries, each with a user ID and expiration time (in milliseconds). """ + def select_users_txn(txn, renew_at): + now_ms = int(time.time() * 1000) - def select_users_txn(txn, now_ms, renew_at): txn.execute( """ SELECT user_id, expiration_ts_ms FROM email_account_validity @@ -165,7 +202,6 @@ def select_users_txn(txn, now_ms, renew_at): return await self._api.run_db_interaction( "get_users_expiring_soon", select_users_txn, - int(time.time() * 1000), self._renew_at, ) @@ -174,6 +210,7 @@ async def set_account_validity_for_user( user_id: str, expiration_ts: int, email_sent: bool, + token_format: TokenFormat, renewal_token: Optional[str] = None, token_used_ts: Optional[int] = None, ): @@ -187,6 +224,8 @@ async def set_account_validity_for_user( email_sent: True means a renewal email has been sent for this account and there's no need to send another one for the current validity period. + token_format: The configured token format, used to determine which + column to update. renewal_token: Renewal token the user can use to extend the validity of their account. Defaults to no token. token_used_ts: A timestamp of when the current token was used to renew @@ -200,7 +239,7 @@ def set_account_validity_for_user_txn(txn: LoggingTransaction): user_id, expiration_ts_ms, email_sent, - renewal_token, + %(token_column_name)s, token_used_ts_ms ) VALUES (?, ?, ?, ?, ?) @@ -208,9 +247,9 @@ def set_account_validity_for_user_txn(txn: LoggingTransaction): SET expiration_ts_ms = EXCLUDED.expiration_ts_ms, email_sent = EXCLUDED.email_sent, - renewal_token = EXCLUDED.renewal_token, + %(token_column_name)s = EXCLUDED.%(token_column_name)s, token_used_ts_ms = EXCLUDED.token_used_ts_ms - """, + """ % {"token_column_name": _TOKEN_COLUMN_NAME[token_format]}, (user_id, expiration_ts, email_sent, renewal_token, token_used_ts) ) @@ -247,27 +286,57 @@ def get_expiration_ts_for_user_txn(txn: LoggingTransaction): ) return res - async def set_renewal_token_for_user(self, user_id: str, renewal_token: str): + async def set_renewal_token_for_user( + self, + user_id: str, + renewal_token: str, + token_format: TokenFormat, + ): + """Store the given renewal token for the given user. + + Args: + user_id: The user ID to store the renewal token for. + renewal_token: The renewal token to store for the user. + token_format: The configured token format, used to determine which + column to update. + """ def set_renewal_token_for_user_txn(txn: LoggingTransaction): - DatabasePool.simple_update_one_txn( - txn=txn, - table="email_account_validity", - keyvalues={"user_id": user_id}, - updatevalues={"renewal_token": renewal_token, "token_used_ts_ms": None}, - ) + # We don't need to check if the token is unique since we've got unique + # indexes to check that. + try: + DatabasePool.simple_update_one_txn( + txn=txn, + table="email_account_validity", + keyvalues={"user_id": user_id}, + updatevalues={ + _TOKEN_COLUMN_NAME[token_format]: renewal_token, + "token_used_ts_ms": None, + }, + ) + except Exception: + raise SynapseError(500, "Failed to update renewal token") await self._api.run_db_interaction( "set_renewal_token_for_user", set_renewal_token_for_user_txn, ) - async def get_user_from_renewal_token( - self, renewal_token: str + async def validate_renewal_token( + self, + renewal_token: str, + token_format: TokenFormat, + user_id: Optional[str] = None, ) -> Tuple[str, int, Optional[int]]: - """Get a user ID and renewal status from a renewal token. + """Check if the provided renewal token is associating with a user, optionally + validating the user it belongs to as well, and return the account renewal status + of the user it belongs to. Args: renewal_token: The renewal token to perform the lookup with. + token_format: The configured token format, used to determine which + column to update. + user_id: The Matrix ID of the user to renew, if the renewal request was + authenticated. Returns: A tuple of containing the following values: @@ -277,13 +346,21 @@ async def get_user_from_renewal_token( * An optional int representing the timestamp of when the user renewed their account timestamp as milliseconds since the epoch. None if the account has not been renewed using the current token yet. + + Raises: + StoreError(404): The token could not be found (or does not belong to the + provided user, if any). """ def get_user_from_renewal_token_txn(txn: LoggingTransaction): + keyvalues = {_TOKEN_COLUMN_NAME[token_format]: renewal_token} + if user_id is not None: + keyvalues["user_id"] = user_id + return DatabasePool.simple_select_one_txn( txn=txn, table="email_account_validity", - keyvalues={"renewal_token": renewal_token}, + keyvalues=keyvalues, retcols=["user_id", "expiration_ts_ms", "token_used_ts_ms"], ) @@ -355,11 +432,17 @@ def set_renewal_mail_status_txn(txn: LoggingTransaction): set_renewal_mail_status_txn, ) - async def get_renewal_token_for_user(self, user_id: str) -> str: + async def get_renewal_token_for_user( + self, + user_id: str, + token_format: TokenFormat, + ) -> str: """Retrieve the renewal token for the given user. Args: user_id: Matrix ID of the user to retrieve the renewal token of. + token_format: The configured token format, used to determine which + column to update. Returns: The renewal token for the user. @@ -370,7 +453,7 @@ def get_renewal_token_txn(txn: LoggingTransaction): txn=txn, table="email_account_validity", keyvalues={"user_id": user_id}, - retcol="renewal_token", + retcol=_TOKEN_COLUMN_NAME[token_format], ) return await self._api.run_db_interaction( diff --git a/email_account_validity/_utils.py b/email_account_validity/_utils.py index 6f8e459..b1b7f1d 100644 --- a/email_account_validity/_utils.py +++ b/email_account_validity/_utils.py @@ -1,8 +1,41 @@ +# -*- coding: utf-8 -*- +# Copyright 2021 The Matrix.org Foundation C.I.C. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re import secrets import string from typing import Union +LONG_TOKEN_REGEX = re.compile('^[a-zA-Z]{32}$') +SHORT_TOKEN_REGEX = re.compile('^[0-9]{8}$') + + +class TokenFormat: + """Supported formats for renewal tokens.""" + + # A LONG renewal token is a 32 letter-long string. + LONG = "long" + # A SHORT renewal token is a 8 digit-long string. + SHORT = "short" + + +def random_digit_string(length): + return "".join(secrets.choice(string.digits) for _ in range(length)) + + def parse_duration(value: Union[str, int]) -> int: """Convert a duration as a string or integer to a number of milliseconds. diff --git a/email_account_validity/account_validity.py b/email_account_validity/account_validity.py index a33f06c..1865e6b 100644 --- a/email_account_validity/account_validity.py +++ b/email_account_validity/account_validity.py @@ -80,6 +80,7 @@ def parse_config(config: dict): period=parse_duration(config["period"]), renew_at=parse_duration(config["renew_at"]), renew_email_subject=config.get("renew_email_subject"), + send_links=config.get("send_links", True) ) return parsed_config diff --git a/tests/__init__.py b/tests/__init__.py index 95211cb..4b4acaa 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -15,7 +15,6 @@ from collections import namedtuple import sqlite3 -import pkg_resources import time from unittest import mock @@ -70,13 +69,11 @@ def __next__(self): return self.cur.__next__() -def read_templates(filenames): +def read_templates(filenames, directory): """Reads Jinja templates from the templates directory. This function is mostly copied from Synapse. """ - loader = jinja2.FileSystemLoader( - pkg_resources.resource_filename("email_account_validity", "templates") - ) + loader = jinja2.FileSystemLoader(directory) env = jinja2.Environment( loader=loader, autoescape=jinja2.select_autoescape(), @@ -103,13 +100,15 @@ async def send_mail(recipient, subject, html, text): return None -async def create_account_validity_module() -> EmailAccountValidity: +async def create_account_validity_module(config={}) -> EmailAccountValidity: """Starts an EmailAccountValidity module with a basic config and a mock of the ModuleApi. """ - config = EmailAccountValidityConfig( - period=3628800000, - renew_at=604800000, + config.update( + { + "period": "6w", + "renew_at": "1w", + } ) store = SQLiteStore() @@ -125,7 +124,8 @@ async def create_account_validity_module() -> EmailAccountValidity: # Make sure the table is created. Don't try to populate with users since we don't # have tables to populate from. - module = EmailAccountValidity(config, module_api, populate_users=False) + parsed_config = EmailAccountValidity.parse_config(config) + module = EmailAccountValidity(parsed_config, module_api, populate_users=False) await module._store.create_and_populate_table(populate_users=False) return module diff --git a/tests/test_account_validity.py b/tests/test_account_validity.py index b5ed9cb..ce75d40 100644 --- a/tests/test_account_validity.py +++ b/tests/test_account_validity.py @@ -14,6 +14,7 @@ # limitations under the License. import asyncio +import re import time # From Python 3.8 onwards, aiounittest.AsyncTestCase can be replaced by @@ -23,7 +24,7 @@ from synapse.module_api.errors import SynapseError -from email_account_validity import EmailAccountValidity +from email_account_validity._utils import LONG_TOKEN_REGEX, SHORT_TOKEN_REGEX, TokenFormat from tests import create_account_validity_module @@ -81,7 +82,7 @@ async def test_on_user_registration(self): expiration_ts = await module._store.get_expiration_ts_for_user(user_id) now_ms = int(time.time() * 1000) - self.assertTrue(isinstance(expiration_ts, int)) + self.assertIsInstance(expiration_ts, int) self.assertGreater(expiration_ts, now_ms) @@ -119,6 +120,13 @@ async def get_threepids(user_id): await module.send_renewal_email_to_user(user_id) self.assertEqual(module._api.send_mail.call_count, 1) + # Test that the email content contains a link; we haven't set send_links in the + # module's config so its value should be the default (which is True). + _, kwargs = module._api.send_mail.call_args + path = "_synapse/client/email_account_validity/renew" + self.assertNotEqual(kwargs["html"].find(path), -1) + self.assertNotEqual(kwargs["text"].find(path), -1) + # Test that trying to send an email to a known use that has no email address # attached to their account results in no email being sent. threepids = [] @@ -127,20 +135,24 @@ async def get_threepids(user_id): async def test_renewal_token(self): user_id = "@izzy:test" - module = await create_account_validity_module() # type: EmailAccountValidity + module = await create_account_validity_module() # Insert a row with an expiration timestamp and a renewal token for this user. await module._store.set_expiration_date_for_user(user_id) - await module.generate_renewal_token(user_id) + await module.generate_unauthenticated_renewal_token(user_id) # Retrieve the expiration timestamp and renewal token and check that they're in # the right format. old_expiration_ts = await module._store.get_expiration_ts_for_user(user_id) - self.assertTrue(isinstance(old_expiration_ts, int)) + self.assertIsInstance(old_expiration_ts, int) - renewal_token = await module._store.get_renewal_token_for_user(user_id) - self.assertTrue(isinstance(renewal_token, str)) + renewal_token = await module._store.get_renewal_token_for_user( + user_id, + TokenFormat.LONG, + ) + self.assertIsInstance(renewal_token, str) self.assertGreater(len(renewal_token), 0) + self.assertTrue(LONG_TOKEN_REGEX.match(renewal_token)) # Sleep a bit so the new expiration timestamp isn't likely to be equal to the # previous one. @@ -181,3 +193,58 @@ async def test_renewal_token(self): self.assertFalse(token_stale) self.assertEqual(expiration_ts, 0) + async def test_duplicate_token(self): + user_id_1 = "@izzy1:test" + user_id_2 = "@izzy2:test" + token = "sometoken" + + module = await create_account_validity_module() + + # Insert both users in the table. + await module._store.set_expiration_date_for_user(user_id_1) + await module._store.set_expiration_date_for_user(user_id_2) + + # Set the renewal token. + await module._store.set_renewal_token_for_user(user_id_1, token, TokenFormat.LONG) + + # Try to set the same renewal token for another user. + exception = None + try: + await module._store.set_renewal_token_for_user( + user_id_2, token, TokenFormat.LONG, + ) + except SynapseError as e: + exception = e + + # Check that an exception was raised and that it's the one we're expecting. + self.assertIsInstance(exception, SynapseError) + self.assertEqual(exception.code, 500) + + async def test_send_link_false(self): + user_id = "@izzy:test" + # Create a module with a configuration forbidding it to send links via email. + module = await create_account_validity_module({"send_links": False}) + + async def get_threepids(user_id): + return [{ + "medium": "email", + "address": "izzy@test", + }] + module._api.get_threepids_for_user.side_effect = get_threepids + await module._store.set_expiration_date_for_user(user_id) + + # Test that, when an email is sent, it doesn't include a link. We do this by + # searching the email's content for the path for renewal requests. + await module.send_renewal_email_to_user(user_id) + self.assertEqual(module._api.send_mail.call_count, 1) + + _, kwargs = module._api.send_mail.call_args + path = "_synapse/client/email_account_validity/renew" + self.assertEqual(kwargs["html"].find(path), -1, kwargs["text"]) + self.assertEqual(kwargs["text"].find(path), -1, kwargs["text"]) + + # Check that the renewal token is in the right format. It should be a 8 digit + # long string. + token = await module._store.get_renewal_token_for_user(user_id, TokenFormat.SHORT) + self.assertIsInstance(token, str) + self.assertTrue(SHORT_TOKEN_REGEX.match(token))