diff --git a/synapse/handlers/send_email.py b/synapse/handlers/send_email.py
index 05e21509de..4f5fe62fe8 100644
--- a/synapse/handlers/send_email.py
+++ b/synapse/handlers/send_email.py
@@ -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)
diff --git a/synapse/push/mailer.py b/synapse/push/mailer.py
index 79e0627b6a..b6cad18c2d 100644
--- a/synapse/push/mailer.py
+++ b/synapse/push/mailer.py
@@ -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(
diff --git a/synapse/rest/synapse/client/unsubscribe.py b/synapse/rest/synapse/client/unsubscribe.py
index 60321018f9..050fd7bba1 100644
--- a/synapse/rest/synapse/client/unsubscribe.py
+++ b/synapse/rest/synapse/client/unsubscribe.py
@@ -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)
|