Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Commit

Permalink
Add the List-Unsubscribe header for notification emails. (#16274)
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
clokep authored Sep 11, 2023
1 parent 151e4bb commit 9400dc0
Show file tree
Hide file tree
Showing 5 changed files with 110 additions and 6 deletions.
1 change: 1 addition & 0 deletions changelog.d/16274.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Enable users to easily unsubscribe to notifications emails via the `List-Unsubscribe` header.
10 changes: 9 additions & 1 deletion synapse/handlers/send_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
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

Expand Down Expand Up @@ -151,6 +151,7 @@ async def send_email(
app_name: str,
html: str,
text: str,
additional_headers: Optional[Dict[str, str]] = None,
) -> None:
"""Send a multipart email with the given information.
Expand All @@ -160,6 +161,7 @@ async def send_email(
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}
Expand All @@ -181,6 +183,7 @@ async def send_email(
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
Expand All @@ -194,6 +197,11 @@ async def send_email(
# 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)

Expand Down
33 changes: 28 additions & 5 deletions synapse/push/mailer.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,20 +298,26 @@ async def _fetch_room_state(room_id: str) -> None:
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 = {
Expand All @@ -330,6 +336,23 @@ async def send_email(
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(
Expand Down
17 changes: 17 additions & 0 deletions synapse/rest/synapse/client/unsubscribe.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ def __init__(self, hs: "HomeServer"):
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)
Expand All @@ -62,3 +66,16 @@ async def _async_render_GET(self, request: SynapseRequest) -> None:
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)
55 changes: 55 additions & 0 deletions tests/push/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,9 +27,11 @@
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


Expand Down Expand Up @@ -175,6 +179,57 @@ def test_simple_sends_email(self) -> None:

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)
Expand Down

0 comments on commit 9400dc0

Please sign in to comment.