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

Commit 9400dc0

Browse files
authored
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).
1 parent 151e4bb commit 9400dc0

File tree

5 files changed

+110
-6
lines changed

5 files changed

+110
-6
lines changed

changelog.d/16274.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Enable users to easily unsubscribe to notifications emails via the `List-Unsubscribe` header.

synapse/handlers/send_email.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from email.mime.multipart import MIMEMultipart
1818
from email.mime.text import MIMEText
1919
from io import BytesIO
20-
from typing import TYPE_CHECKING, Any, Optional
20+
from typing import TYPE_CHECKING, Any, Dict, Optional
2121

2222
from pkg_resources import parse_version
2323

@@ -151,6 +151,7 @@ async def send_email(
151151
app_name: str,
152152
html: str,
153153
text: str,
154+
additional_headers: Optional[Dict[str, str]] = None,
154155
) -> None:
155156
"""Send a multipart email with the given information.
156157
@@ -160,6 +161,7 @@ async def send_email(
160161
app_name: The app name to include in the From header.
161162
html: The HTML content to include in the email.
162163
text: The plain text content to include in the email.
164+
additional_headers: A map of additional headers to include.
163165
"""
164166
try:
165167
from_string = self._from % {"app": app_name}
@@ -181,6 +183,7 @@ async def send_email(
181183
multipart_msg["To"] = email_address
182184
multipart_msg["Date"] = email.utils.formatdate()
183185
multipart_msg["Message-ID"] = email.utils.make_msgid()
186+
184187
# Discourage automatic responses to Synapse's emails.
185188
# Per RFC 3834, automatic responses should not be sent if the "Auto-Submitted"
186189
# header is present with any value other than "no". See
@@ -194,6 +197,11 @@ async def send_email(
194197
# https://stackoverflow.com/a/25324691/5252017
195198
# https://stackoverflow.com/a/61646381/5252017
196199
multipart_msg["X-Auto-Response-Suppress"] = "All"
200+
201+
if additional_headers:
202+
for header, value in additional_headers.items():
203+
multipart_msg[header] = value
204+
197205
multipart_msg.attach(text_part)
198206
multipart_msg.attach(html_part)
199207

synapse/push/mailer.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -298,20 +298,26 @@ async def _fetch_room_state(room_id: str) -> None:
298298
notifs_by_room, state_by_room, notif_events, reason
299299
)
300300

301+
unsubscribe_link = self._make_unsubscribe_link(user_id, app_id, email_address)
302+
301303
template_vars: TemplateVars = {
302304
"user_display_name": user_display_name,
303-
"unsubscribe_link": self._make_unsubscribe_link(
304-
user_id, app_id, email_address
305-
),
305+
"unsubscribe_link": unsubscribe_link,
306306
"summary_text": summary_text,
307307
"rooms": rooms,
308308
"reason": reason,
309309
}
310310

311-
await self.send_email(email_address, summary_text, template_vars)
311+
await self.send_email(
312+
email_address, summary_text, template_vars, unsubscribe_link
313+
)
312314

313315
async def send_email(
314-
self, email_address: str, subject: str, extra_template_vars: TemplateVars
316+
self,
317+
email_address: str,
318+
subject: str,
319+
extra_template_vars: TemplateVars,
320+
unsubscribe_link: Optional[str] = None,
315321
) -> None:
316322
"""Send an email with the given information and template text"""
317323
template_vars: TemplateVars = {
@@ -330,6 +336,23 @@ async def send_email(
330336
app_name=self.app_name,
331337
html=html_text,
332338
text=plain_text,
339+
# Include the List-Unsubscribe header which some clients render in the UI.
340+
# Per RFC 2369, this can be a URL or mailto URL. See
341+
# https://www.rfc-editor.org/rfc/rfc2369.html#section-3.2
342+
#
343+
# It is preferred to use email, but Synapse doesn't support incoming email.
344+
#
345+
# Also include the List-Unsubscribe-Post header from RFC 8058. See
346+
# https://www.rfc-editor.org/rfc/rfc8058.html#section-3.1
347+
#
348+
# Note that many email clients will not render the unsubscribe link
349+
# unless DKIM, etc. is properly setup.
350+
additional_headers={
351+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
352+
"List-Unsubscribe": f"<{unsubscribe_link}>",
353+
}
354+
if unsubscribe_link
355+
else None,
333356
)
334357

335358
async def _get_room_vars(

synapse/rest/synapse/client/unsubscribe.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ def __init__(self, hs: "HomeServer"):
3838
self.macaroon_generator = hs.get_macaroon_generator()
3939

4040
async def _async_render_GET(self, request: SynapseRequest) -> None:
41+
"""
42+
Handle a user opening an unsubscribe link in the browser, either via an
43+
HTML/Text email or via the List-Unsubscribe header.
44+
"""
4145
token = parse_string(request, "access_token", required=True)
4246
app_id = parse_string(request, "app_id", required=True)
4347
pushkey = parse_string(request, "pushkey", required=True)
@@ -62,3 +66,16 @@ async def _async_render_GET(self, request: SynapseRequest) -> None:
6266
200,
6367
UnsubscribeResource.SUCCESS_HTML,
6468
)
69+
70+
async def _async_render_POST(self, request: SynapseRequest) -> None:
71+
"""
72+
Handle a mail user agent POSTing to the unsubscribe URL via the
73+
List-Unsubscribe & List-Unsubscribe-Post headers.
74+
"""
75+
76+
# TODO Assert that the body has a single field
77+
78+
# Assert the body has form encoded key/value pair of
79+
# List-Unsubscribe=One-Click.
80+
81+
await self._async_render_GET(request)

tests/push/test_email.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
# limitations under the License.
1414
import email.message
1515
import os
16+
from http import HTTPStatus
1617
from typing import Any, Dict, List, Sequence, Tuple
1718

1819
import attr
1920
import pkg_resources
21+
from parameterized import parameterized
2022

2123
from twisted.internet.defer import Deferred
2224
from twisted.test.proto_helpers import MemoryReactor
@@ -25,9 +27,11 @@
2527
from synapse.api.errors import Codes, SynapseError
2628
from synapse.push.emailpusher import EmailPusher
2729
from synapse.rest.client import login, room
30+
from synapse.rest.synapse.client.unsubscribe import UnsubscribeResource
2831
from synapse.server import HomeServer
2932
from synapse.util import Clock
3033

34+
from tests.server import FakeSite, make_request
3135
from tests.unittest import HomeserverTestCase
3236

3337

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

176180
self._check_for_mail()
177181

182+
@parameterized.expand([(False,), (True,)])
183+
def test_unsubscribe(self, use_post: bool) -> None:
184+
# Create a simple room with two users
185+
room = self.helper.create_room_as(self.user_id, tok=self.access_token)
186+
self.helper.invite(
187+
room=room, src=self.user_id, tok=self.access_token, targ=self.others[0].id
188+
)
189+
self.helper.join(room=room, user=self.others[0].id, tok=self.others[0].token)
190+
191+
# The other user sends a single message.
192+
self.helper.send(room, body="Hi!", tok=self.others[0].token)
193+
194+
# We should get emailed about that message
195+
args, kwargs = self._check_for_mail()
196+
197+
# That email should contain an unsubscribe link in the body and header.
198+
msg: bytes = args[5]
199+
200+
# Multipart: plain text, base 64 encoded; html, base 64 encoded
201+
multipart_msg = email.message_from_bytes(msg)
202+
txt = multipart_msg.get_payload()[0].get_payload(decode=True).decode()
203+
html = multipart_msg.get_payload()[1].get_payload(decode=True).decode()
204+
self.assertIn("/_synapse/client/unsubscribe", txt)
205+
self.assertIn("/_synapse/client/unsubscribe", html)
206+
207+
# The unsubscribe headers should exist.
208+
assert multipart_msg.get("List-Unsubscribe") is not None
209+
self.assertIsNotNone(multipart_msg.get("List-Unsubscribe-Post"))
210+
211+
# Open the unsubscribe link.
212+
unsubscribe_link = multipart_msg["List-Unsubscribe"].strip("<>")
213+
unsubscribe_resource = UnsubscribeResource(self.hs)
214+
channel = make_request(
215+
self.reactor,
216+
FakeSite(unsubscribe_resource, self.reactor),
217+
"POST" if use_post else "GET",
218+
unsubscribe_link,
219+
shorthand=False,
220+
)
221+
self.assertEqual(HTTPStatus.OK, channel.code, channel.result)
222+
223+
# Ensure the pusher was removed.
224+
pushers = list(
225+
self.get_success(
226+
self.hs.get_datastores().main.get_pushers_by(
227+
{"user_name": self.user_id}
228+
)
229+
)
230+
)
231+
self.assertEqual(pushers, [])
232+
178233
def test_invite_sends_email(self) -> None:
179234
# Create a room and invite the user to it
180235
room = self.helper.create_room_as(self.others[0].id, tok=self.others[0].token)

0 commit comments

Comments
 (0)