Adds both the List-Unsubscribe (RFC2369) and List-Unsubscribe-Post (RFC8058) headers to push notification emails, which together should: * Show an "Unsubscribe" link in the MUA UI when viewing Synapse notification emails. * Enable "one-click" unsubscribe (the user never leaves their MUA, which automatically makes a POST request to the specified endpoint).tags/v1.93.0rc1
@@ -0,0 +1 @@ | |||
Enable users to easily unsubscribe to notifications emails via the `List-Unsubscribe` header. |
@@ -17,7 +17,7 @@ import logging | |||
from email.mime.multipart import MIMEMultipart | |||
from email.mime.text import MIMEText | |||
from io import BytesIO | |||
from typing import TYPE_CHECKING, Any, Optional | |||
from typing import TYPE_CHECKING, Any, Dict, Optional | |||
from pkg_resources import parse_version | |||
@@ -151,6 +151,7 @@ class SendEmailHandler: | |||
app_name: str, | |||
html: str, | |||
text: str, | |||
additional_headers: Optional[Dict[str, str]] = None, | |||
) -> None: | |||
"""Send a multipart email with the given information. | |||
@@ -160,6 +161,7 @@ class SendEmailHandler: | |||
app_name: The app name to include in the From header. | |||
html: The HTML content to include in the email. | |||
text: The plain text content to include in the email. | |||
additional_headers: A map of additional headers to include. | |||
""" | |||
try: | |||
from_string = self._from % {"app": app_name} | |||
@@ -181,6 +183,7 @@ class SendEmailHandler: | |||
multipart_msg["To"] = email_address | |||
multipart_msg["Date"] = email.utils.formatdate() | |||
multipart_msg["Message-ID"] = email.utils.make_msgid() | |||
# Discourage automatic responses to Synapse's emails. | |||
# Per RFC 3834, automatic responses should not be sent if the "Auto-Submitted" | |||
# header is present with any value other than "no". See | |||
@@ -194,6 +197,11 @@ class SendEmailHandler: | |||
# https://stackoverflow.com/a/25324691/5252017 | |||
# https://stackoverflow.com/a/61646381/5252017 | |||
multipart_msg["X-Auto-Response-Suppress"] = "All" | |||
if additional_headers: | |||
for header, value in additional_headers.items(): | |||
multipart_msg[header] = value | |||
multipart_msg.attach(text_part) | |||
multipart_msg.attach(html_part) | |||
@@ -298,20 +298,26 @@ class Mailer: | |||
notifs_by_room, state_by_room, notif_events, reason | |||
) | |||
unsubscribe_link = self._make_unsubscribe_link(user_id, app_id, email_address) | |||
template_vars: TemplateVars = { | |||
"user_display_name": user_display_name, | |||
"unsubscribe_link": self._make_unsubscribe_link( | |||
user_id, app_id, email_address | |||
), | |||
"unsubscribe_link": unsubscribe_link, | |||
"summary_text": summary_text, | |||
"rooms": rooms, | |||
"reason": reason, | |||
} | |||
await self.send_email(email_address, summary_text, template_vars) | |||
await self.send_email( | |||
email_address, summary_text, template_vars, unsubscribe_link | |||
) | |||
async def send_email( | |||
self, email_address: str, subject: str, extra_template_vars: TemplateVars | |||
self, | |||
email_address: str, | |||
subject: str, | |||
extra_template_vars: TemplateVars, | |||
unsubscribe_link: Optional[str] = None, | |||
) -> None: | |||
"""Send an email with the given information and template text""" | |||
template_vars: TemplateVars = { | |||
@@ -330,6 +336,23 @@ class Mailer: | |||
app_name=self.app_name, | |||
html=html_text, | |||
text=plain_text, | |||
# Include the List-Unsubscribe header which some clients render in the UI. | |||
# Per RFC 2369, this can be a URL or mailto URL. See | |||
# https://www.rfc-editor.org/rfc/rfc2369.html#section-3.2 | |||
# | |||
# It is preferred to use email, but Synapse doesn't support incoming email. | |||
# | |||
# Also include the List-Unsubscribe-Post header from RFC 8058. See | |||
# https://www.rfc-editor.org/rfc/rfc8058.html#section-3.1 | |||
# | |||
# Note that many email clients will not render the unsubscribe link | |||
# unless DKIM, etc. is properly setup. | |||
additional_headers={ | |||
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click", | |||
"List-Unsubscribe": f"<{unsubscribe_link}>", | |||
} | |||
if unsubscribe_link | |||
else None, | |||
) | |||
async def _get_room_vars( | |||
@@ -38,6 +38,10 @@ class UnsubscribeResource(DirectServeHtmlResource): | |||
self.macaroon_generator = hs.get_macaroon_generator() | |||
async def _async_render_GET(self, request: SynapseRequest) -> None: | |||
""" | |||
Handle a user opening an unsubscribe link in the browser, either via an | |||
HTML/Text email or via the List-Unsubscribe header. | |||
""" | |||
token = parse_string(request, "access_token", required=True) | |||
app_id = parse_string(request, "app_id", required=True) | |||
pushkey = parse_string(request, "pushkey", required=True) | |||
@@ -62,3 +66,16 @@ class UnsubscribeResource(DirectServeHtmlResource): | |||
200, | |||
UnsubscribeResource.SUCCESS_HTML, | |||
) | |||
async def _async_render_POST(self, request: SynapseRequest) -> None: | |||
""" | |||
Handle a mail user agent POSTing to the unsubscribe URL via the | |||
List-Unsubscribe & List-Unsubscribe-Post headers. | |||
""" | |||
# TODO Assert that the body has a single field | |||
# Assert the body has form encoded key/value pair of | |||
# List-Unsubscribe=One-Click. | |||
await self._async_render_GET(request) |
@@ -13,10 +13,12 @@ | |||
# limitations under the License. | |||
import email.message | |||
import os | |||
from http import HTTPStatus | |||
from typing import Any, Dict, List, Sequence, Tuple | |||
import attr | |||
import pkg_resources | |||
from parameterized import parameterized | |||
from twisted.internet.defer import Deferred | |||
from twisted.test.proto_helpers import MemoryReactor | |||
@@ -25,9 +27,11 @@ import synapse.rest.admin | |||
from synapse.api.errors import Codes, SynapseError | |||
from synapse.push.emailpusher import EmailPusher | |||
from synapse.rest.client import login, room | |||
from synapse.rest.synapse.client.unsubscribe import UnsubscribeResource | |||
from synapse.server import HomeServer | |||
from synapse.util import Clock | |||
from tests.server import FakeSite, make_request | |||
from tests.unittest import HomeserverTestCase | |||
@@ -175,6 +179,57 @@ class EmailPusherTests(HomeserverTestCase): | |||
self._check_for_mail() | |||
@parameterized.expand([(False,), (True,)]) | |||
def test_unsubscribe(self, use_post: bool) -> None: | |||
# Create a simple room with two users | |||
room = self.helper.create_room_as(self.user_id, tok=self.access_token) | |||
self.helper.invite( | |||
room=room, src=self.user_id, tok=self.access_token, targ=self.others[0].id | |||
) | |||
self.helper.join(room=room, user=self.others[0].id, tok=self.others[0].token) | |||
# The other user sends a single message. | |||
self.helper.send(room, body="Hi!", tok=self.others[0].token) | |||
# We should get emailed about that message | |||
args, kwargs = self._check_for_mail() | |||
# That email should contain an unsubscribe link in the body and header. | |||
msg: bytes = args[5] | |||
# Multipart: plain text, base 64 encoded; html, base 64 encoded | |||
multipart_msg = email.message_from_bytes(msg) | |||
txt = multipart_msg.get_payload()[0].get_payload(decode=True).decode() | |||
html = multipart_msg.get_payload()[1].get_payload(decode=True).decode() | |||
self.assertIn("/_synapse/client/unsubscribe", txt) | |||
self.assertIn("/_synapse/client/unsubscribe", html) | |||
# The unsubscribe headers should exist. | |||
assert multipart_msg.get("List-Unsubscribe") is not None | |||
self.assertIsNotNone(multipart_msg.get("List-Unsubscribe-Post")) | |||
# Open the unsubscribe link. | |||
unsubscribe_link = multipart_msg["List-Unsubscribe"].strip("<>") | |||
unsubscribe_resource = UnsubscribeResource(self.hs) | |||
channel = make_request( | |||
self.reactor, | |||
FakeSite(unsubscribe_resource, self.reactor), | |||
"POST" if use_post else "GET", | |||
unsubscribe_link, | |||
shorthand=False, | |||
) | |||
self.assertEqual(HTTPStatus.OK, channel.code, channel.result) | |||
# Ensure the pusher was removed. | |||
pushers = list( | |||
self.get_success( | |||
self.hs.get_datastores().main.get_pushers_by( | |||
{"user_name": self.user_id} | |||
) | |||
) | |||
) | |||
self.assertEqual(pushers, []) | |||
def test_invite_sends_email(self) -> None: | |||
# Create a room and invite the user to it | |||
room = self.helper.create_room_as(self.others[0].id, tok=self.others[0].token) | |||