Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support branded templates #328

Merged
merged 16 commits into from
Jan 15, 2021
1 change: 1 addition & 0 deletions changelog.d/328.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Move templates to a per-brand subdirectory of /res. Add templates.path and brand.default config options
5 changes: 2 additions & 3 deletions matrix_is_test/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
clientapi.http.bind_address = localhost
clientapi.http.port = {port}
client_http_base = http://localhost:{port}
verify_response_template = {testsubject_path}/res/verify_response_template
federation.verifycerts = False

[db]
Expand All @@ -34,16 +33,16 @@
[general]
server.name = test.local
terms.path = {terms_path}
templates.path = {testsubject_path}/res
brand.default = is-test

[email]
email.tlsmode = 0
email.template = {testsubject_path}/res/verification_template.eml
email.invite.subject = %(sender_display_name)s has invited you to chat
email.smtphost = localhost
email.from = Sydent Validation <noreply@localhost>
email.smtpport = 9925
email.subject = Your Validation Token
email.invite_template = {testsubject_path}/res/invite_template.eml
"""

class MatrixIsTestLauncher(object):
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
10 changes: 8 additions & 2 deletions sydent/http/servlets/emailservlet.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,15 @@ def render_POST(self, request):
}

ipaddress = self.sydent.ip_from_request(request)
brand = self.sydent.brand_from_request(request)

nextLink = None
if 'next_link' in args and not args['next_link'].startswith("file:///"):
nextLink = args['next_link']

try:
sid = self.sydent.validators.email.requestToken(
email, clientSecret, sendAttempt, nextLink, ipaddress=ipaddress
email, clientSecret, sendAttempt, nextLink, ipaddress=ipaddress, brand=brand,
)
resp = {'sid': str(sid)}
except EmailAddressException:
Expand Down Expand Up @@ -105,7 +106,12 @@ def render_GET(self, request):
else:
msg = "Verification failed: you may need to request another verification email"

templateFile = self.sydent.cfg.get('http', 'verify_response_template')
brand = self.sydent.brand_from_request(request)
templateFile = self.sydent.get_branded_template(
brand,
"verify_response_template.html",
('http', 'verify_response_template'),
)

request.setHeader("Content-Type", "text/html")
res = open(templateFile).read() % {'message': msg}
Expand Down
10 changes: 8 additions & 2 deletions sydent/http/servlets/msisdnservlet.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,10 @@ def render_POST(self, request):
phone_number_object, phonenumbers.PhoneNumberFormat.INTERNATIONAL
)

brand = self.sydent.brand_from_request(request)
try:
sid = self.sydent.validators.msisdn.requestToken(
phone_number_object, clientSecret, sendAttempt
phone_number_object, clientSecret, sendAttempt, brand
)
resp = {
'success': True, 'sid': str(sid),
Expand Down Expand Up @@ -127,7 +128,12 @@ def render_GET(self, request):
request.setResponseCode(400)
msg = "Verification failed: you may need to request another verification text"

templateFile = self.sydent.cfg.get('http', 'verify_response_template')
brand = self.sydent.brand_from_request(request)
templateFile = self.sydent.get_branded_template(
brand,
"verify_response_template.html",
('http', 'verify_response_template'),
)

request.setHeader("Content-Type", "text/html")
return open(templateFile).read() % {'message': msg}
Expand Down
9 changes: 8 additions & 1 deletion sydent/http/servlets/store_invite_servlet.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,14 @@ def render_POST(self, request):
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)
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())
Expand Down
87 changes: 84 additions & 3 deletions sydent/sydent.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@
'terms.path': '',
'address_lookup_limit': '10000', # Maximum amount of addresses in a single /lookup request

# The root path to use for load templates. This should contain branded
# directories. Each directory should contain the following templates:
#
# * invite_template.eml
# * verification_template.eml
# * verify_response_template.html
'templates.path': 'res',
# The brand directory to use if no brand hint (or an invalid brand hint)
# is provided by the request.
'brand.default': 'matrix-org',

# The following can be added to your local config file to enable prometheus
# support.
# 'prometheus_port': '8080', # The port to serve metrics on
Expand All @@ -111,12 +122,18 @@
'replication.https.port': '4434',
'obey_x_forwarded_for': 'False',
'federation.verifycerts': 'True',
'verify_response_template': '',
# verify_response_template is deprecated, but still used if defined Define
# templates.path and brand.default under general instead.
#
# 'verify_response_template': 'res/verify_response_page_template',
'client_http_base': '',
},
'email': {
'email.template': 'res/email.template',
'email.invite_template': 'res/invite.template',
# email.template and email.invite_template are deprecated, but still used
# if defined. Define templates.path and brand.default under general instead.
#
# 'email.template': 'res/verification_template.eml',
# 'email.invite_template': 'res/invite_template.eml',
'email.from': 'Sydent Validation <noreply@{hostname}>',
'email.subject': 'Your Validation Token',
'email.invite.subject': '%(sender_display_name)s has invited you to chat',
Expand Down Expand Up @@ -203,6 +220,19 @@ def __init__(self, cfg, reactor=twisted.internet.reactor):
addr=self.cfg.get("general", "prometheus_addr"),
)

if self.cfg.has_option("general", "templates.path"):
# Get the possible brands by looking at directories under the
# templates.path directory.
root_template_path = self.cfg.get("general", "templates.path")
if os.path.exists(root_template_path):
self.valid_brands = {
p for p in os.listdir(root_template_path) if os.path.isdir(os.path.join(root_template_path, p))
}
else:
# This is a legacy code-path and assumes that verify_response_template,
# email.template, and email.invite_template are defined.
self.valid_brands = set()

self.enable_v1_associations = parse_cfg_bool(
self.cfg.get("general", "enable_v1_associations")
)
Expand Down Expand Up @@ -323,6 +353,57 @@ def ip_from_request(self, request):
return request.requestHeaders.getRawHeaders("X-Forwarded-For")[0]
return request.getClientIP()

def brand_from_request(self, request):
"""
If the brand GET parameter is passed, returns that as a string, otherwise returns None.

:param request: The incoming request.
:type request: twisted.web.http.Request

:return: The brand to use or None if no hint is found.
:rtype: str or None
"""
if b"brand" in request.args:
return request.args[b"brand"][0].decode("utf-8")
return None

def get_branded_template(self, brand, template_name, deprecated_template_name):
"""
Calculate a (maybe) branded template filename to use.

If the deprecated email.template setting is defined, always use it.
Otherwise, attempt to use the hinted brand from the request if the brand
is valid. Otherwise, fallback to the default brand.

:param brand: The hint of which brand to use.
:type brand: str or None
:param template_name: The name of the template file to load.
:type template_name: str
:param deprecated_template_name: The deprecated setting to use, if provided.
:type deprecated_template_name: Tuple[str]

:return: The template filename to use.
:rtype: str
"""

# If the deprecated setting is defined, return it.
try:
return self.cfg.get(*deprecated_template_name)
except configparser.NoOptionError:
pass

# If a brand hint is provided, attempt to use it if it is valid.
if brand:
if brand not in self.valid_brands:
brand = None

# If the brand hint is not valid, or not provided, fallback to the default brand.
if not brand:
brand = self.cfg.get("general", "brand.default")

root_template_path = self.cfg.get("general", "templates.path")
return os.path.join(root_template_path, brand, template_name)


class Validators:
pass
Expand Down
9 changes: 4 additions & 5 deletions sydent/util/emailutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,23 +38,22 @@
logger = logging.getLogger(__name__)


def sendEmail(sydent, templateName, mailTo, substitutions):
def sendEmail(sydent, templateFile, mailTo, substitutions):
"""
Sends an email with the given parameters.

:param sydent: The Sydent instance to use when building the configuration to send the
email with.
:type sydent: sydent.sydent.Sydent
:param templateName: The name of the template to use when building the body of the
:param templateFile: The filename of the template to use when building the body of the
email.
:type templateName: str
:type templateFile: str
:param mailTo: The email address to send the email to.
:type mailTo: unicode
:param substitutions: The substitutions to use with the template.
:type substitutions: dict[str, str]
"""
mailFrom = sydent.cfg.get('email', 'email.from')
mailTemplateFile = sydent.cfg.get('email', templateName)

myHostname = sydent.cfg.get('email', 'email.hostname')
if myHostname == '':
Expand All @@ -75,7 +74,7 @@ def sendEmail(sydent, templateName, mailTo, substitutions):
allSubstitutions[k+"_forhtml"] = escape(v)
allSubstitutions[k+"_forurl"] = urllib.parse.quote(v)

mailString = open(mailTemplateFile).read() % allSubstitutions
mailString = open(templateFile).read() % allSubstitutions
parsedFrom = email.utils.parseaddr(mailFrom)[1]
parsedTo = email.utils.parseaddr(mailTo)[1]
if parsedFrom == '' or parsedTo == '':
Expand Down
12 changes: 10 additions & 2 deletions sydent/validators/emailvalidator.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class EmailValidator:
def __init__(self, sydent):
self.sydent = sydent

def requestToken(self, emailAddress, clientSecret, sendAttempt, nextLink, ipaddress=None):
def requestToken(self, emailAddress, clientSecret, sendAttempt, nextLink, ipaddress=None, brand=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.
Expand All @@ -47,6 +47,8 @@ def requestToken(self, emailAddress, clientSecret, sendAttempt, nextLink, ipaddr
:type nextLink: unicode
:param ipaddress: The requester's IP address.
:type ipaddress: str or None
:param brand: A hint at a brand from the request.
:type brand: str or None

:return: The ID of the session created (or of the existing one if any)
:rtype: int
Expand All @@ -58,6 +60,12 @@ def requestToken(self, emailAddress, clientSecret, sendAttempt, nextLink, ipaddr

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

templateFile = self.sydent.get_branded_template(
brand,
"verification_template.eml",
('email', 'email.template'),
)

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
Expand All @@ -73,7 +81,7 @@ def requestToken(self, emailAddress, clientSecret, sendAttempt, nextLink, ipaddr
"Attempting to mail code %s (nextLink: %s) to %s",
valSession.token, nextLink, emailAddress,
)
sendEmail(self.sydent, 'email.template', emailAddress, substitutions)
sendEmail(self.sydent, templateFile, emailAddress, substitutions)

valSessionStore.setSendAttemptNumber(valSession.id, sendAttempt)

Expand Down
4 changes: 3 additions & 1 deletion sydent/validators/msisdnvalidator.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def __init__(self, sydent):

self.smsRules[country] = action

def requestToken(self, phoneNumber, clientSecret, sendAttempt):
def requestToken(self, phoneNumber, clientSecret, sendAttempt, brand=None):
"""
Creates or retrieves a validation session and sends an text message to the
corresponding phone number address with a token to use to verify the association.
Expand All @@ -75,6 +75,8 @@ def requestToken(self, phoneNumber, clientSecret, sendAttempt):
:type clientSecret: unicode
:param sendAttempt: The current send attempt.
:type sendAttempt: int
:param brand: A hint at a brand from the request.
:type brand: str or None

:return: The ID of the session created (or of the existing one if any)
:rtype: int
Expand Down
82 changes: 82 additions & 0 deletions tests/test_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# 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 os.path
from mock import Mock, patch

from twisted.web.client import Response
from twisted.trial import unittest

from sydent.db.invite_tokens import JoinTokenStore
from sydent.http.httpclient import FederationHttpClient
from sydent.http.servlets.store_invite_servlet import StoreInviteServlet
from tests.utils import make_request, make_sydent


class TestRequestCode(unittest.TestCase):
def setUp(self):
# Create a new sydent
config = {
"general": {
"templates.path": os.path.join(os.path.dirname(os.path.dirname(__file__)), "res"),
},
}
self.sydent = make_sydent(test_config=config)

def _render_request(self, request):
# Patch out the email sending so we can investigate the resulting email.
with patch("sydent.util.emailutils.smtplib") as smtplib:
request.render(self.sydent.servlets.emailRequestCode)

# Fish out the SMTP object and return it.
smtp = smtplib.SMTP.return_value
smtp.sendmail.assert_called_once()

return smtp

def test_request_code(self):
self.sydent.run()

request, channel = make_request(
self.sydent.reactor, "POST", "/_matrix/identity/v1/validate/email/requestToken",
{
"email": "test@test",
"client_secret": "oursecret",
"send_attempt": 0,
}
)
smtp = self._render_request(request)
self.assertEqual(channel.code, 200)

# Ensure the email is as expected.
email_contents = smtp.sendmail.call_args[0][2].decode("utf-8")
self.assertIn("Confirm your email address for Matrix", email_contents)

def test_branded_request_code(self):
self.sydent.run()

request, channel = make_request(
self.sydent.reactor, "POST", "/_matrix/identity/v1/validate/email/requestToken?brand=vector-im",
{
"email": "test@test",
"client_secret": "oursecret",
"send_attempt": 0,
}
)
smtp = self._render_request(request)
self.assertEqual(channel.code, 200)

# Ensure the email is as expected.
email_contents = smtp.sendmail.call_args[0][2].decode("utf-8")
self.assertIn("Confirm your email address for Element", email_contents)
Loading