Add the List-Unsubscribe header for notification emails. (#16274)

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).
This commit is contained in:
Patrick Cloke 2023-09-11 09:49:48 -04:00 committed by GitHub
parent 151e4bbc45
commit 9400dc0535
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 110 additions and 6 deletions

View File

@ -0,0 +1 @@
Enable users to easily unsubscribe to notifications emails via the `List-Unsubscribe` header.

View File

@ -17,7 +17,7 @@ import logging
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from io import BytesIO 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 from pkg_resources import parse_version
@ -151,6 +151,7 @@ class SendEmailHandler:
app_name: str, app_name: str,
html: str, html: str,
text: str, text: str,
additional_headers: Optional[Dict[str, str]] = None,
) -> None: ) -> None:
"""Send a multipart email with the given information. """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. app_name: The app name to include in the From header.
html: The HTML content to include in the email. html: The HTML content to include in the email.
text: The plain text 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: try:
from_string = self._from % {"app": app_name} from_string = self._from % {"app": app_name}
@ -181,6 +183,7 @@ class SendEmailHandler:
multipart_msg["To"] = email_address multipart_msg["To"] = email_address
multipart_msg["Date"] = email.utils.formatdate() multipart_msg["Date"] = email.utils.formatdate()
multipart_msg["Message-ID"] = email.utils.make_msgid() multipart_msg["Message-ID"] = email.utils.make_msgid()
# Discourage automatic responses to Synapse's emails. # Discourage automatic responses to Synapse's emails.
# Per RFC 3834, automatic responses should not be sent if the "Auto-Submitted" # Per RFC 3834, automatic responses should not be sent if the "Auto-Submitted"
# header is present with any value other than "no". See # 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/25324691/5252017
# https://stackoverflow.com/a/61646381/5252017 # https://stackoverflow.com/a/61646381/5252017
multipart_msg["X-Auto-Response-Suppress"] = "All" 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(text_part)
multipart_msg.attach(html_part) multipart_msg.attach(html_part)

View File

@ -298,20 +298,26 @@ class Mailer:
notifs_by_room, state_by_room, notif_events, reason notifs_by_room, state_by_room, notif_events, reason
) )
unsubscribe_link = self._make_unsubscribe_link(user_id, app_id, email_address)
template_vars: TemplateVars = { template_vars: TemplateVars = {
"user_display_name": user_display_name, "user_display_name": user_display_name,
"unsubscribe_link": self._make_unsubscribe_link( "unsubscribe_link": unsubscribe_link,
user_id, app_id, email_address
),
"summary_text": summary_text, "summary_text": summary_text,
"rooms": rooms, "rooms": rooms,
"reason": reason, "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( 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: ) -> None:
"""Send an email with the given information and template text""" """Send an email with the given information and template text"""
template_vars: TemplateVars = { template_vars: TemplateVars = {
@ -330,6 +336,23 @@ class Mailer:
app_name=self.app_name, app_name=self.app_name,
html=html_text, html=html_text,
text=plain_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( async def _get_room_vars(

View File

@ -38,6 +38,10 @@ class UnsubscribeResource(DirectServeHtmlResource):
self.macaroon_generator = hs.get_macaroon_generator() self.macaroon_generator = hs.get_macaroon_generator()
async def _async_render_GET(self, request: SynapseRequest) -> None: 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) token = parse_string(request, "access_token", required=True)
app_id = parse_string(request, "app_id", required=True) app_id = parse_string(request, "app_id", required=True)
pushkey = parse_string(request, "pushkey", required=True) pushkey = parse_string(request, "pushkey", required=True)
@ -62,3 +66,16 @@ class UnsubscribeResource(DirectServeHtmlResource):
200, 200,
UnsubscribeResource.SUCCESS_HTML, 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)

View File

@ -13,10 +13,12 @@
# limitations under the License. # limitations under the License.
import email.message import email.message
import os import os
from http import HTTPStatus
from typing import Any, Dict, List, Sequence, Tuple from typing import Any, Dict, List, Sequence, Tuple
import attr import attr
import pkg_resources import pkg_resources
from parameterized import parameterized
from twisted.internet.defer import Deferred from twisted.internet.defer import Deferred
from twisted.test.proto_helpers import MemoryReactor from twisted.test.proto_helpers import MemoryReactor
@ -25,9 +27,11 @@ import synapse.rest.admin
from synapse.api.errors import Codes, SynapseError from synapse.api.errors import Codes, SynapseError
from synapse.push.emailpusher import EmailPusher from synapse.push.emailpusher import EmailPusher
from synapse.rest.client import login, room from synapse.rest.client import login, room
from synapse.rest.synapse.client.unsubscribe import UnsubscribeResource
from synapse.server import HomeServer from synapse.server import HomeServer
from synapse.util import Clock from synapse.util import Clock
from tests.server import FakeSite, make_request
from tests.unittest import HomeserverTestCase from tests.unittest import HomeserverTestCase
@ -175,6 +179,57 @@ class EmailPusherTests(HomeserverTestCase):
self._check_for_mail() 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: def test_invite_sends_email(self) -> None:
# Create a room and invite the user to it # Create a room and invite the user to it
room = self.helper.create_room_as(self.others[0].id, tok=self.others[0].token) room = self.helper.create_room_as(self.others[0].id, tok=self.others[0].token)