You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

953 lines
33 KiB

  1. # Copyright 2016 OpenMarket Ltd
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. import logging
  15. import urllib.parse
  16. from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, TypeVar
  17. import bleach
  18. import jinja2
  19. from markupsafe import Markup
  20. from synapse.api.constants import EventTypes, Membership, RoomTypes
  21. from synapse.api.errors import StoreError
  22. from synapse.config.emailconfig import EmailSubjectConfig
  23. from synapse.events import EventBase
  24. from synapse.push.presentable_names import (
  25. calculate_room_name,
  26. descriptor_from_member_events,
  27. name_from_member_event,
  28. )
  29. from synapse.push.push_types import (
  30. EmailReason,
  31. MessageVars,
  32. NotifVars,
  33. RoomVars,
  34. TemplateVars,
  35. )
  36. from synapse.storage.databases.main.event_push_actions import EmailPushAction
  37. from synapse.types import StateMap, UserID
  38. from synapse.types.state import StateFilter
  39. from synapse.util.async_helpers import concurrently_execute
  40. from synapse.visibility import filter_events_for_client
  41. if TYPE_CHECKING:
  42. from synapse.server import HomeServer
  43. logger = logging.getLogger(__name__)
  44. T = TypeVar("T")
  45. CONTEXT_BEFORE = 1
  46. CONTEXT_AFTER = 1
  47. # From https://github.com/matrix-org/matrix-react-sdk/blob/master/src/HtmlUtils.js
  48. ALLOWED_TAGS = [
  49. "font", # custom to matrix for IRC-style font coloring
  50. "del", # for markdown
  51. # deliberately no h1/h2 to stop people shouting.
  52. "h3",
  53. "h4",
  54. "h5",
  55. "h6",
  56. "blockquote",
  57. "p",
  58. "a",
  59. "ul",
  60. "ol",
  61. "nl",
  62. "li",
  63. "b",
  64. "i",
  65. "u",
  66. "strong",
  67. "em",
  68. "strike",
  69. "code",
  70. "hr",
  71. "br",
  72. "div",
  73. "table",
  74. "thead",
  75. "caption",
  76. "tbody",
  77. "tr",
  78. "th",
  79. "td",
  80. "pre",
  81. ]
  82. ALLOWED_ATTRS = {
  83. # custom ones first:
  84. "font": ["color"], # custom to matrix
  85. "a": ["href", "name", "target"], # remote target: custom to matrix
  86. # We don't currently allow img itself by default, but this
  87. # would make sense if we did
  88. "img": ["src"],
  89. }
  90. # When bleach release a version with this option, we can specify schemes
  91. # ALLOWED_SCHEMES = ["http", "https", "ftp", "mailto"]
  92. class Mailer:
  93. def __init__(
  94. self,
  95. hs: "HomeServer",
  96. app_name: str,
  97. template_html: jinja2.Template,
  98. template_text: jinja2.Template,
  99. ):
  100. self.hs = hs
  101. self.template_html = template_html
  102. self.template_text = template_text
  103. self.send_email_handler = hs.get_send_email_handler()
  104. self.store = self.hs.get_datastores().main
  105. self._state_storage_controller = self.hs.get_storage_controllers().state
  106. self.macaroon_gen = self.hs.get_macaroon_generator()
  107. self.state_handler = self.hs.get_state_handler()
  108. self._storage_controllers = hs.get_storage_controllers()
  109. self.app_name = app_name
  110. self.email_subjects: EmailSubjectConfig = hs.config.email.email_subjects
  111. logger.info("Created Mailer for app_name %s" % app_name)
  112. async def send_password_reset_mail(
  113. self, email_address: str, token: str, client_secret: str, sid: str
  114. ) -> None:
  115. """Send an email with a password reset link to a user
  116. Args:
  117. email_address: Email address we're sending the password
  118. reset to
  119. token: Unique token generated by the server to verify
  120. the email was received
  121. client_secret: Unique token generated by the client to
  122. group together multiple email sending attempts
  123. sid: The generated session ID
  124. """
  125. params = {"token": token, "client_secret": client_secret, "sid": sid}
  126. link = (
  127. self.hs.config.server.public_baseurl
  128. + "_synapse/client/password_reset/email/submit_token?%s"
  129. % urllib.parse.urlencode(params)
  130. )
  131. template_vars: TemplateVars = {"link": link}
  132. await self.send_email(
  133. email_address,
  134. self.email_subjects.password_reset
  135. % {"server_name": self.hs.config.server.server_name, "app": self.app_name},
  136. template_vars,
  137. )
  138. async def send_registration_mail(
  139. self, email_address: str, token: str, client_secret: str, sid: str
  140. ) -> None:
  141. """Send an email with a registration confirmation link to a user
  142. Args:
  143. email_address: Email address we're sending the registration
  144. link to
  145. token: Unique token generated by the server to verify
  146. the email was received
  147. client_secret: Unique token generated by the client to
  148. group together multiple email sending attempts
  149. sid: The generated session ID
  150. """
  151. params = {"token": token, "client_secret": client_secret, "sid": sid}
  152. link = (
  153. self.hs.config.server.public_baseurl
  154. + "_matrix/client/unstable/registration/email/submit_token?%s"
  155. % urllib.parse.urlencode(params)
  156. )
  157. template_vars: TemplateVars = {"link": link}
  158. await self.send_email(
  159. email_address,
  160. self.email_subjects.email_validation
  161. % {"server_name": self.hs.config.server.server_name, "app": self.app_name},
  162. template_vars,
  163. )
  164. async def send_add_threepid_mail(
  165. self, email_address: str, token: str, client_secret: str, sid: str
  166. ) -> None:
  167. """Send an email with a validation link to a user for adding a 3pid to their account
  168. Args:
  169. email_address: Email address we're sending the validation link to
  170. token: Unique token generated by the server to verify the email was received
  171. client_secret: Unique token generated by the client to group together
  172. multiple email sending attempts
  173. sid: The generated session ID
  174. """
  175. params = {"token": token, "client_secret": client_secret, "sid": sid}
  176. link = (
  177. self.hs.config.server.public_baseurl
  178. + "_matrix/client/unstable/add_threepid/email/submit_token?%s"
  179. % urllib.parse.urlencode(params)
  180. )
  181. template_vars: TemplateVars = {"link": link}
  182. await self.send_email(
  183. email_address,
  184. self.email_subjects.email_validation
  185. % {"server_name": self.hs.config.server.server_name, "app": self.app_name},
  186. template_vars,
  187. )
  188. async def send_notification_mail(
  189. self,
  190. app_id: str,
  191. user_id: str,
  192. email_address: str,
  193. push_actions: Iterable[EmailPushAction],
  194. reason: EmailReason,
  195. ) -> None:
  196. """
  197. Send email regarding a user's room notifications
  198. Params:
  199. app_id: The application receiving the notification.
  200. user_id: The user receiving the notification.
  201. email_address: The email address receiving the notification.
  202. push_actions: All outstanding notifications.
  203. reason: The notification that was ready and is the cause of an email
  204. being sent.
  205. """
  206. rooms_in_order = deduped_ordered_list([pa.room_id for pa in push_actions])
  207. notif_events = await self.store.get_events([pa.event_id for pa in push_actions])
  208. notifs_by_room: Dict[str, List[EmailPushAction]] = {}
  209. for pa in push_actions:
  210. notifs_by_room.setdefault(pa.room_id, []).append(pa)
  211. # collect the current state for all the rooms in which we have
  212. # notifications
  213. state_by_room = {}
  214. try:
  215. user_display_name = await self.store.get_profile_displayname(
  216. UserID.from_string(user_id)
  217. )
  218. if user_display_name is None:
  219. user_display_name = user_id
  220. except StoreError:
  221. user_display_name = user_id
  222. async def _fetch_room_state(room_id: str) -> None:
  223. room_state = await self._state_storage_controller.get_current_state_ids(
  224. room_id
  225. )
  226. state_by_room[room_id] = room_state
  227. # Run at most 3 of these at once: sync does 10 at a time but email
  228. # notifs are much less realtime than sync so we can afford to wait a bit.
  229. await concurrently_execute(_fetch_room_state, rooms_in_order, 3)
  230. # actually sort our so-called rooms_in_order list, most recent room first
  231. rooms_in_order.sort(key=lambda r: -(notifs_by_room[r][-1].received_ts or 0))
  232. rooms: List[RoomVars] = []
  233. for r in rooms_in_order:
  234. roomvars = await self._get_room_vars(
  235. r, user_id, notifs_by_room[r], notif_events, state_by_room[r]
  236. )
  237. rooms.append(roomvars)
  238. reason["room_name"] = await calculate_room_name(
  239. self.store,
  240. state_by_room[reason["room_id"]],
  241. user_id,
  242. fallback_to_members=True,
  243. )
  244. if len(notifs_by_room) == 1:
  245. # Only one room has new stuff
  246. room_id = list(notifs_by_room.keys())[0]
  247. summary_text = await self._make_summary_text_single_room(
  248. room_id,
  249. notifs_by_room[room_id],
  250. state_by_room[room_id],
  251. notif_events,
  252. user_id,
  253. )
  254. else:
  255. summary_text = await self._make_summary_text(
  256. notifs_by_room, state_by_room, notif_events, reason
  257. )
  258. unsubscribe_link = self._make_unsubscribe_link(user_id, app_id, email_address)
  259. template_vars: TemplateVars = {
  260. "user_display_name": user_display_name,
  261. "unsubscribe_link": unsubscribe_link,
  262. "summary_text": summary_text,
  263. "rooms": rooms,
  264. "reason": reason,
  265. }
  266. await self.send_email(
  267. email_address, summary_text, template_vars, unsubscribe_link
  268. )
  269. async def send_email(
  270. self,
  271. email_address: str,
  272. subject: str,
  273. extra_template_vars: TemplateVars,
  274. unsubscribe_link: Optional[str] = None,
  275. ) -> None:
  276. """Send an email with the given information and template text"""
  277. template_vars: TemplateVars = {
  278. "app_name": self.app_name,
  279. "server_name": self.hs.config.server.server_name,
  280. }
  281. template_vars.update(extra_template_vars)
  282. html_text = self.template_html.render(**template_vars)
  283. plain_text = self.template_text.render(**template_vars)
  284. await self.send_email_handler.send_email(
  285. email_address=email_address,
  286. subject=subject,
  287. app_name=self.app_name,
  288. html=html_text,
  289. text=plain_text,
  290. # Include the List-Unsubscribe header which some clients render in the UI.
  291. # Per RFC 2369, this can be a URL or mailto URL. See
  292. # https://www.rfc-editor.org/rfc/rfc2369.html#section-3.2
  293. #
  294. # It is preferred to use email, but Synapse doesn't support incoming email.
  295. #
  296. # Also include the List-Unsubscribe-Post header from RFC 8058. See
  297. # https://www.rfc-editor.org/rfc/rfc8058.html#section-3.1
  298. #
  299. # Note that many email clients will not render the unsubscribe link
  300. # unless DKIM, etc. is properly setup.
  301. additional_headers={
  302. "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
  303. "List-Unsubscribe": f"<{unsubscribe_link}>",
  304. }
  305. if unsubscribe_link
  306. else None,
  307. )
  308. async def _get_room_vars(
  309. self,
  310. room_id: str,
  311. user_id: str,
  312. notifs: Iterable[EmailPushAction],
  313. notif_events: Dict[str, EventBase],
  314. room_state_ids: StateMap[str],
  315. ) -> RoomVars:
  316. """
  317. Generate the variables for notifications on a per-room basis.
  318. Args:
  319. room_id: The room ID
  320. user_id: The user receiving the notification.
  321. notifs: The outstanding push actions for this room.
  322. notif_events: The events related to the above notifications.
  323. room_state_ids: The event IDs of the current room state.
  324. Returns:
  325. A dictionary to be added to the template context.
  326. """
  327. # Check if one of the notifs is an invite event for the user.
  328. is_invite = False
  329. for n in notifs:
  330. ev = notif_events[n.event_id]
  331. if ev.type == EventTypes.Member and ev.state_key == user_id:
  332. if ev.content.get("membership") == Membership.INVITE:
  333. is_invite = True
  334. break
  335. room_name = await calculate_room_name(self.store, room_state_ids, user_id)
  336. room_vars: RoomVars = {
  337. "title": room_name,
  338. "hash": string_ordinal_total(room_id), # See sender avatar hash
  339. "notifs": [],
  340. "invite": is_invite,
  341. "link": self._make_room_link(room_id),
  342. "avatar_url": await self._get_room_avatar(room_state_ids),
  343. }
  344. if not is_invite:
  345. for n in notifs:
  346. notifvars = await self._get_notif_vars(
  347. n, user_id, notif_events[n.event_id], room_state_ids
  348. )
  349. # merge overlapping notifs together.
  350. # relies on the notifs being in chronological order.
  351. merge = False
  352. if room_vars["notifs"] and "messages" in room_vars["notifs"][-1]:
  353. prev_messages = room_vars["notifs"][-1]["messages"]
  354. for message in notifvars["messages"]:
  355. pm = list(
  356. filter(lambda pm: pm["id"] == message["id"], prev_messages)
  357. )
  358. if pm:
  359. if not message["is_historical"]:
  360. pm[0]["is_historical"] = False
  361. merge = True
  362. elif merge:
  363. # we're merging, so append any remaining messages
  364. # in this notif to the previous one
  365. prev_messages.append(message)
  366. if not merge:
  367. room_vars["notifs"].append(notifvars)
  368. return room_vars
  369. async def _get_room_avatar(
  370. self,
  371. room_state_ids: StateMap[str],
  372. ) -> Optional[str]:
  373. """
  374. Retrieve the avatar url for this room---if it exists.
  375. Args:
  376. room_state_ids: The event IDs of the current room state.
  377. Returns:
  378. room's avatar url if it's present and a string; otherwise None.
  379. """
  380. event_id = room_state_ids.get((EventTypes.RoomAvatar, ""))
  381. if event_id:
  382. ev = await self.store.get_event(event_id)
  383. url = ev.content.get("url")
  384. if isinstance(url, str):
  385. return url
  386. return None
  387. async def _get_notif_vars(
  388. self,
  389. notif: EmailPushAction,
  390. user_id: str,
  391. notif_event: EventBase,
  392. room_state_ids: StateMap[str],
  393. ) -> NotifVars:
  394. """
  395. Generate the variables for a single notification.
  396. Args:
  397. notif: The outstanding notification for this room.
  398. user_id: The user receiving the notification.
  399. notif_event: The event related to the above notification.
  400. room_state_ids: The event IDs of the current room state.
  401. Returns:
  402. A dictionary to be added to the template context.
  403. """
  404. results = await self.store.get_events_around(
  405. notif.room_id,
  406. notif.event_id,
  407. before_limit=CONTEXT_BEFORE,
  408. after_limit=CONTEXT_AFTER,
  409. )
  410. ret: NotifVars = {
  411. "link": self._make_notif_link(notif),
  412. "ts": notif.received_ts,
  413. "messages": [],
  414. }
  415. the_events = await filter_events_for_client(
  416. self._storage_controllers, user_id, results.events_before
  417. )
  418. the_events.append(notif_event)
  419. for event in the_events:
  420. messagevars = await self._get_message_vars(notif, event, room_state_ids)
  421. if messagevars is not None:
  422. ret["messages"].append(messagevars)
  423. return ret
  424. async def _get_message_vars(
  425. self, notif: EmailPushAction, event: EventBase, room_state_ids: StateMap[str]
  426. ) -> Optional[MessageVars]:
  427. """
  428. Generate the variables for a single event, if possible.
  429. Args:
  430. notif: The outstanding notification for this room.
  431. event: The event under consideration.
  432. room_state_ids: The event IDs of the current room state.
  433. Returns:
  434. A dictionary to be added to the template context, or None if the
  435. event cannot be processed.
  436. """
  437. if event.type != EventTypes.Message and event.type != EventTypes.Encrypted:
  438. return None
  439. # Get the sender's name and avatar from the room state.
  440. type_state_key = ("m.room.member", event.sender)
  441. sender_state_event_id = room_state_ids.get(type_state_key)
  442. if sender_state_event_id:
  443. sender_state_event: Optional[EventBase] = await self.store.get_event(
  444. sender_state_event_id
  445. )
  446. else:
  447. # Attempt to check the historical state for the room.
  448. historical_state = await self._state_storage_controller.get_state_for_event(
  449. event.event_id, StateFilter.from_types((type_state_key,))
  450. )
  451. sender_state_event = historical_state.get(type_state_key)
  452. if sender_state_event:
  453. sender_name = name_from_member_event(sender_state_event)
  454. sender_avatar_url: Optional[str] = sender_state_event.content.get(
  455. "avatar_url"
  456. )
  457. else:
  458. # No state could be found, fallback to the MXID.
  459. sender_name = event.sender
  460. sender_avatar_url = None
  461. # 'hash' for deterministically picking default images: use
  462. # sender_hash % the number of default images to choose from
  463. sender_hash = string_ordinal_total(event.sender)
  464. ret: MessageVars = {
  465. "event_type": event.type,
  466. "is_historical": event.event_id != notif.event_id,
  467. "id": event.event_id,
  468. "ts": event.origin_server_ts,
  469. "sender_name": sender_name,
  470. "sender_avatar_url": sender_avatar_url,
  471. "sender_hash": sender_hash,
  472. }
  473. # Encrypted messages don't have any additional useful information.
  474. if event.type == EventTypes.Encrypted:
  475. return ret
  476. msgtype = event.content.get("msgtype")
  477. if not isinstance(msgtype, str):
  478. msgtype = None
  479. ret["msgtype"] = msgtype
  480. if msgtype == "m.text":
  481. self._add_text_message_vars(ret, event)
  482. elif msgtype == "m.image":
  483. self._add_image_message_vars(ret, event)
  484. if "body" in event.content:
  485. ret["body_text_plain"] = event.content["body"]
  486. return ret
  487. def _add_text_message_vars(
  488. self, messagevars: MessageVars, event: EventBase
  489. ) -> None:
  490. """
  491. Potentially add a sanitised message body to the message variables.
  492. Args:
  493. messagevars: The template context to be modified.
  494. event: The event under consideration.
  495. """
  496. msgformat = event.content.get("format")
  497. if not isinstance(msgformat, str):
  498. msgformat = None
  499. formatted_body = event.content.get("formatted_body")
  500. body = event.content.get("body")
  501. if msgformat == "org.matrix.custom.html" and formatted_body:
  502. messagevars["body_text_html"] = safe_markup(formatted_body)
  503. elif body:
  504. messagevars["body_text_html"] = safe_text(body)
  505. def _add_image_message_vars(
  506. self, messagevars: MessageVars, event: EventBase
  507. ) -> None:
  508. """
  509. Potentially add an image URL to the message variables.
  510. Args:
  511. messagevars: The template context to be modified.
  512. event: The event under consideration.
  513. """
  514. if "url" in event.content:
  515. messagevars["image_url"] = event.content["url"]
  516. async def _make_summary_text_single_room(
  517. self,
  518. room_id: str,
  519. notifs: List[EmailPushAction],
  520. room_state_ids: StateMap[str],
  521. notif_events: Dict[str, EventBase],
  522. user_id: str,
  523. ) -> str:
  524. """
  525. Make a summary text for the email when only a single room has notifications.
  526. Args:
  527. room_id: The ID of the room.
  528. notifs: The push actions for this room.
  529. room_state_ids: The state map for the room.
  530. notif_events: A map of event ID -> notification event.
  531. user_id: The user receiving the notification.
  532. Returns:
  533. The summary text.
  534. """
  535. # If the room has some kind of name, use it, but we don't
  536. # want the generated-from-names one here otherwise we'll
  537. # end up with, "new message from Bob in the Bob room"
  538. room_name = await calculate_room_name(
  539. self.store, room_state_ids, user_id, fallback_to_members=False
  540. )
  541. # See if one of the notifs is an invite event for the user
  542. invite_event = None
  543. for n in notifs:
  544. ev = notif_events[n.event_id]
  545. if ev.type == EventTypes.Member and ev.state_key == user_id:
  546. if ev.content.get("membership") == Membership.INVITE:
  547. invite_event = ev
  548. break
  549. if invite_event:
  550. inviter_member_event_id = room_state_ids.get(
  551. ("m.room.member", invite_event.sender)
  552. )
  553. inviter_name = invite_event.sender
  554. if inviter_member_event_id:
  555. inviter_member_event = await self.store.get_event(
  556. inviter_member_event_id, allow_none=True
  557. )
  558. if inviter_member_event:
  559. inviter_name = name_from_member_event(inviter_member_event)
  560. if room_name is None:
  561. return self.email_subjects.invite_from_person % {
  562. "person": inviter_name,
  563. "app": self.app_name,
  564. }
  565. # If the room is a space, it gets a slightly different topic.
  566. create_event_id = room_state_ids.get(("m.room.create", ""))
  567. if create_event_id:
  568. create_event = await self.store.get_event(
  569. create_event_id, allow_none=True
  570. )
  571. if (
  572. create_event
  573. and create_event.content.get("room_type") == RoomTypes.SPACE
  574. ):
  575. return self.email_subjects.invite_from_person_to_space % {
  576. "person": inviter_name,
  577. "space": room_name,
  578. "app": self.app_name,
  579. }
  580. return self.email_subjects.invite_from_person_to_room % {
  581. "person": inviter_name,
  582. "room": room_name,
  583. "app": self.app_name,
  584. }
  585. if len(notifs) == 1:
  586. # There is just the one notification, so give some detail
  587. sender_name = None
  588. event = notif_events[notifs[0].event_id]
  589. if ("m.room.member", event.sender) in room_state_ids:
  590. state_event_id = room_state_ids[("m.room.member", event.sender)]
  591. state_event = await self.store.get_event(state_event_id)
  592. sender_name = name_from_member_event(state_event)
  593. if sender_name is not None and room_name is not None:
  594. return self.email_subjects.message_from_person_in_room % {
  595. "person": sender_name,
  596. "room": room_name,
  597. "app": self.app_name,
  598. }
  599. elif sender_name is not None:
  600. return self.email_subjects.message_from_person % {
  601. "person": sender_name,
  602. "app": self.app_name,
  603. }
  604. # The sender is unknown, just use the room name (or ID).
  605. return self.email_subjects.messages_in_room % {
  606. "room": room_name or room_id,
  607. "app": self.app_name,
  608. }
  609. else:
  610. # There's more than one notification for this room, so just
  611. # say there are several
  612. if room_name is not None:
  613. return self.email_subjects.messages_in_room % {
  614. "room": room_name,
  615. "app": self.app_name,
  616. }
  617. return await self._make_summary_text_from_member_events(
  618. room_id, notifs, room_state_ids, notif_events
  619. )
  620. async def _make_summary_text(
  621. self,
  622. notifs_by_room: Dict[str, List[EmailPushAction]],
  623. room_state_ids: Dict[str, StateMap[str]],
  624. notif_events: Dict[str, EventBase],
  625. reason: EmailReason,
  626. ) -> str:
  627. """
  628. Make a summary text for the email when multiple rooms have notifications.
  629. Args:
  630. notifs_by_room: A map of room ID to the push actions for that room.
  631. room_state_ids: A map of room ID to the state map for that room.
  632. notif_events: A map of event ID -> notification event.
  633. reason: The reason this notification is being sent.
  634. Returns:
  635. The summary text.
  636. """
  637. # Stuff's happened in multiple different rooms
  638. # ...but we still refer to the 'reason' room which triggered the mail
  639. if reason["room_name"] is not None:
  640. return self.email_subjects.messages_in_room_and_others % {
  641. "room": reason["room_name"],
  642. "app": self.app_name,
  643. }
  644. room_id = reason["room_id"]
  645. return await self._make_summary_text_from_member_events(
  646. room_id, notifs_by_room[room_id], room_state_ids[room_id], notif_events
  647. )
  648. async def _make_summary_text_from_member_events(
  649. self,
  650. room_id: str,
  651. notifs: List[EmailPushAction],
  652. room_state_ids: StateMap[str],
  653. notif_events: Dict[str, EventBase],
  654. ) -> str:
  655. """
  656. Make a summary text for the email when only a single room has notifications.
  657. Args:
  658. room_id: The ID of the room.
  659. notifs: The push actions for this room.
  660. room_state_ids: The state map for the room.
  661. notif_events: A map of event ID -> notification event.
  662. Returns:
  663. The summary text.
  664. """
  665. # If the room doesn't have a name, say who the messages
  666. # are from explicitly to avoid, "messages in the Bob room"
  667. # Find the latest event ID for each sender, note that the notifications
  668. # are already in descending received_ts.
  669. sender_ids = {}
  670. for n in notifs:
  671. sender = notif_events[n.event_id].sender
  672. if sender not in sender_ids:
  673. sender_ids[sender] = n.event_id
  674. # Get the actual member events (in order to calculate a pretty name for
  675. # the room).
  676. member_event_ids = []
  677. member_events = {}
  678. for sender_id, event_id in sender_ids.items():
  679. type_state_key = ("m.room.member", sender_id)
  680. sender_state_event_id = room_state_ids.get(type_state_key)
  681. if sender_state_event_id:
  682. member_event_ids.append(sender_state_event_id)
  683. else:
  684. # Attempt to check the historical state for the room.
  685. historical_state = (
  686. await self._state_storage_controller.get_state_for_event(
  687. event_id, StateFilter.from_types((type_state_key,))
  688. )
  689. )
  690. sender_state_event = historical_state.get(type_state_key)
  691. if sender_state_event:
  692. member_events[event_id] = sender_state_event
  693. member_events.update(await self.store.get_events(member_event_ids))
  694. if not member_events:
  695. # No member events were found! Maybe the room is empty?
  696. # Fallback to the room ID (note that if there was a room name this
  697. # would already have been used previously).
  698. return self.email_subjects.messages_in_room % {
  699. "room": room_id,
  700. "app": self.app_name,
  701. }
  702. # There was a single sender.
  703. if len(member_events) == 1:
  704. return self.email_subjects.messages_from_person % {
  705. "person": descriptor_from_member_events(member_events.values()),
  706. "app": self.app_name,
  707. }
  708. # There was more than one sender, use the first one and a tweaked template.
  709. return self.email_subjects.messages_from_person_and_others % {
  710. "person": descriptor_from_member_events(list(member_events.values())[:1]),
  711. "app": self.app_name,
  712. }
  713. def _make_room_link(self, room_id: str) -> str:
  714. """
  715. Generate a link to open a room in the web client.
  716. Args:
  717. room_id: The room ID to generate a link to.
  718. Returns:
  719. A link to open a room in the web client.
  720. """
  721. if self.hs.config.email.email_riot_base_url:
  722. base_url = "%s/#/room" % (self.hs.config.email.email_riot_base_url)
  723. elif self.app_name == "Vector":
  724. # need /beta for Universal Links to work on iOS
  725. base_url = "https://vector.im/beta/#/room"
  726. else:
  727. base_url = "https://matrix.to/#"
  728. return "%s/%s" % (base_url, room_id)
  729. def _make_notif_link(self, notif: EmailPushAction) -> str:
  730. """
  731. Generate a link to open an event in the web client.
  732. Args:
  733. notif: The notification to generate a link for.
  734. Returns:
  735. A link to open the notification in the web client.
  736. """
  737. if self.hs.config.email.email_riot_base_url:
  738. return "%s/#/room/%s/%s" % (
  739. self.hs.config.email.email_riot_base_url,
  740. notif.room_id,
  741. notif.event_id,
  742. )
  743. elif self.app_name == "Vector":
  744. # need /beta for Universal Links to work on iOS
  745. return "https://vector.im/beta/#/room/%s/%s" % (
  746. notif.room_id,
  747. notif.event_id,
  748. )
  749. else:
  750. return "https://matrix.to/#/%s/%s" % (notif.room_id, notif.event_id)
  751. def _make_unsubscribe_link(
  752. self, user_id: str, app_id: str, email_address: str
  753. ) -> str:
  754. """
  755. Generate a link to unsubscribe from email notifications.
  756. Args:
  757. user_id: The user receiving the notification.
  758. app_id: The application receiving the notification.
  759. email_address: The email address receiving the notification.
  760. Returns:
  761. A link to unsubscribe from email notifications.
  762. """
  763. params = {
  764. "access_token": self.macaroon_gen.generate_delete_pusher_token(
  765. user_id, app_id, email_address
  766. ),
  767. "app_id": app_id,
  768. "pushkey": email_address,
  769. }
  770. return "%s_synapse/client/unsubscribe?%s" % (
  771. self.hs.config.server.public_baseurl,
  772. urllib.parse.urlencode(params),
  773. )
  774. def safe_markup(raw_html: str) -> Markup:
  775. """
  776. Sanitise a raw HTML string to a set of allowed tags and attributes, and linkify any bare URLs.
  777. Args
  778. raw_html: Unsafe HTML.
  779. Returns:
  780. A Markup object ready to safely use in a Jinja template.
  781. """
  782. return Markup(
  783. bleach.linkify(
  784. bleach.clean(
  785. raw_html,
  786. tags=ALLOWED_TAGS,
  787. attributes=ALLOWED_ATTRS,
  788. # bleach master has this, but it isn't released yet
  789. # protocols=ALLOWED_SCHEMES,
  790. strip=True,
  791. )
  792. )
  793. )
  794. def safe_text(raw_text: str) -> Markup:
  795. """
  796. Sanitise text (escape any HTML tags), and then linkify any bare URLs.
  797. Args
  798. raw_text: Unsafe text which might include HTML markup.
  799. Returns:
  800. A Markup object ready to safely use in a Jinja template.
  801. """
  802. return Markup(
  803. bleach.linkify(bleach.clean(raw_text, tags=[], attributes=[], strip=False))
  804. )
  805. def deduped_ordered_list(it: Iterable[T]) -> List[T]:
  806. seen = set()
  807. ret = []
  808. for item in it:
  809. if item not in seen:
  810. seen.add(item)
  811. ret.append(item)
  812. return ret
  813. def string_ordinal_total(s: str) -> int:
  814. tot = 0
  815. for c in s:
  816. tot += ord(c)
  817. return tot