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.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)
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue