mailer.py 31 KB

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