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).pull/16300/head
parent
151e4bbc45
commit
9400dc0535
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue