mailer.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. # -*- coding: utf-8 -*-
  2. # Copyright 2016 OpenMarket Ltd
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. import email.mime.multipart
  16. import email.utils
  17. import logging
  18. import time
  19. from email.mime.multipart import MIMEMultipart
  20. from email.mime.text import MIMEText
  21. from six.moves import urllib
  22. import bleach
  23. import jinja2
  24. from twisted.internet import defer
  25. from synapse.api.constants import EventTypes
  26. from synapse.api.errors import StoreError
  27. from synapse.push.presentable_names import (
  28. calculate_room_name,
  29. descriptor_from_member_events,
  30. name_from_member_event,
  31. )
  32. from synapse.types import UserID
  33. from synapse.util.async_helpers import concurrently_execute
  34. from synapse.visibility import filter_events_for_client
  35. logger = logging.getLogger(__name__)
  36. MESSAGE_FROM_PERSON_IN_ROOM = "You have a message on %(app)s from %(person)s " \
  37. "in the %(room)s room..."
  38. MESSAGE_FROM_PERSON = "You have a message on %(app)s from %(person)s..."
  39. MESSAGES_FROM_PERSON = "You have messages on %(app)s from %(person)s..."
  40. MESSAGES_IN_ROOM = "You have messages on %(app)s in the %(room)s room..."
  41. MESSAGES_IN_ROOM_AND_OTHERS = \
  42. "You have messages on %(app)s in the %(room)s room and others..."
  43. MESSAGES_FROM_PERSON_AND_OTHERS = \
  44. "You have messages on %(app)s from %(person)s and others..."
  45. INVITE_FROM_PERSON_TO_ROOM = "%(person)s has invited you to join the " \
  46. "%(room)s room on %(app)s..."
  47. INVITE_FROM_PERSON = "%(person)s has invited you to chat on %(app)s..."
  48. CONTEXT_BEFORE = 1
  49. CONTEXT_AFTER = 1
  50. # From https://github.com/matrix-org/matrix-react-sdk/blob/master/src/HtmlUtils.js
  51. ALLOWED_TAGS = [
  52. 'font', # custom to matrix for IRC-style font coloring
  53. 'del', # for markdown
  54. # deliberately no h1/h2 to stop people shouting.
  55. 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol',
  56. 'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div',
  57. 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre'
  58. ]
  59. ALLOWED_ATTRS = {
  60. # custom ones first:
  61. "font": ["color"], # custom to matrix
  62. "a": ["href", "name", "target"], # remote target: custom to matrix
  63. # We don't currently allow img itself by default, but this
  64. # would make sense if we did
  65. "img": ["src"],
  66. }
  67. # When bleach release a version with this option, we can specify schemes
  68. # ALLOWED_SCHEMES = ["http", "https", "ftp", "mailto"]
  69. class Mailer(object):
  70. def __init__(self, hs, app_name, notif_template_html, notif_template_text):
  71. self.hs = hs
  72. self.notif_template_html = notif_template_html
  73. self.notif_template_text = notif_template_text
  74. self.sendmail = self.hs.get_sendmail()
  75. self.store = self.hs.get_datastore()
  76. self.macaroon_gen = self.hs.get_macaroon_generator()
  77. self.state_handler = self.hs.get_state_handler()
  78. self.app_name = app_name
  79. logger.info("Created Mailer for app_name %s" % app_name)
  80. @defer.inlineCallbacks
  81. def send_notification_mail(self, app_id, user_id, email_address,
  82. push_actions, reason):
  83. try:
  84. from_string = self.hs.config.email_notif_from % {
  85. "app": self.app_name
  86. }
  87. except TypeError:
  88. from_string = self.hs.config.email_notif_from
  89. raw_from = email.utils.parseaddr(from_string)[1]
  90. raw_to = email.utils.parseaddr(email_address)[1]
  91. if raw_to == '':
  92. raise RuntimeError("Invalid 'to' address")
  93. rooms_in_order = deduped_ordered_list(
  94. [pa['room_id'] for pa in push_actions]
  95. )
  96. notif_events = yield self.store.get_events(
  97. [pa['event_id'] for pa in push_actions]
  98. )
  99. notifs_by_room = {}
  100. for pa in push_actions:
  101. notifs_by_room.setdefault(pa["room_id"], []).append(pa)
  102. # collect the current state for all the rooms in which we have
  103. # notifications
  104. state_by_room = {}
  105. try:
  106. user_display_name = yield self.store.get_profile_displayname(
  107. UserID.from_string(user_id).localpart
  108. )
  109. if user_display_name is None:
  110. user_display_name = user_id
  111. except StoreError:
  112. user_display_name = user_id
  113. @defer.inlineCallbacks
  114. def _fetch_room_state(room_id):
  115. room_state = yield self.store.get_current_state_ids(room_id)
  116. state_by_room[room_id] = room_state
  117. # Run at most 3 of these at once: sync does 10 at a time but email
  118. # notifs are much less realtime than sync so we can afford to wait a bit.
  119. yield concurrently_execute(_fetch_room_state, rooms_in_order, 3)
  120. # actually sort our so-called rooms_in_order list, most recent room first
  121. rooms_in_order.sort(
  122. key=lambda r: -(notifs_by_room[r][-1]['received_ts'] or 0)
  123. )
  124. rooms = []
  125. for r in rooms_in_order:
  126. roomvars = yield self.get_room_vars(
  127. r, user_id, notifs_by_room[r], notif_events, state_by_room[r]
  128. )
  129. rooms.append(roomvars)
  130. reason['room_name'] = yield calculate_room_name(
  131. self.store, state_by_room[reason['room_id']], user_id,
  132. fallback_to_members=True
  133. )
  134. summary_text = yield self.make_summary_text(
  135. notifs_by_room, state_by_room, notif_events, user_id, reason
  136. )
  137. template_vars = {
  138. "user_display_name": user_display_name,
  139. "unsubscribe_link": self.make_unsubscribe_link(
  140. user_id, app_id, email_address
  141. ),
  142. "summary_text": summary_text,
  143. "app_name": self.app_name,
  144. "rooms": rooms,
  145. "reason": reason,
  146. }
  147. html_text = self.notif_template_html.render(**template_vars)
  148. html_part = MIMEText(html_text, "html", "utf8")
  149. plain_text = self.notif_template_text.render(**template_vars)
  150. text_part = MIMEText(plain_text, "plain", "utf8")
  151. multipart_msg = MIMEMultipart('alternative')
  152. multipart_msg['Subject'] = "[%s] %s" % (self.app_name, summary_text)
  153. multipart_msg['From'] = from_string
  154. multipart_msg['To'] = email_address
  155. multipart_msg['Date'] = email.utils.formatdate()
  156. multipart_msg['Message-ID'] = email.utils.make_msgid()
  157. multipart_msg.attach(text_part)
  158. multipart_msg.attach(html_part)
  159. logger.info("Sending email push notification to %s" % email_address)
  160. yield self.sendmail(
  161. self.hs.config.email_smtp_host,
  162. raw_from, raw_to, multipart_msg.as_string().encode('utf8'),
  163. reactor=self.hs.get_reactor(),
  164. port=self.hs.config.email_smtp_port,
  165. requireAuthentication=self.hs.config.email_smtp_user is not None,
  166. username=self.hs.config.email_smtp_user,
  167. password=self.hs.config.email_smtp_pass,
  168. requireTransportSecurity=self.hs.config.require_transport_security
  169. )
  170. @defer.inlineCallbacks
  171. def get_room_vars(self, room_id, user_id, notifs, notif_events, room_state_ids):
  172. my_member_event_id = room_state_ids[("m.room.member", user_id)]
  173. my_member_event = yield self.store.get_event(my_member_event_id)
  174. is_invite = my_member_event.content["membership"] == "invite"
  175. room_name = yield calculate_room_name(self.store, room_state_ids, user_id)
  176. room_vars = {
  177. "title": room_name,
  178. "hash": string_ordinal_total(room_id), # See sender avatar hash
  179. "notifs": [],
  180. "invite": is_invite,
  181. "link": self.make_room_link(room_id),
  182. }
  183. if not is_invite:
  184. for n in notifs:
  185. notifvars = yield self.get_notif_vars(
  186. n, user_id, notif_events[n['event_id']], room_state_ids
  187. )
  188. # merge overlapping notifs together.
  189. # relies on the notifs being in chronological order.
  190. merge = False
  191. if room_vars['notifs'] and 'messages' in room_vars['notifs'][-1]:
  192. prev_messages = room_vars['notifs'][-1]['messages']
  193. for message in notifvars['messages']:
  194. pm = list(filter(lambda pm: pm['id'] == message['id'],
  195. prev_messages))
  196. if pm:
  197. if not message["is_historical"]:
  198. pm[0]["is_historical"] = False
  199. merge = True
  200. elif merge:
  201. # we're merging, so append any remaining messages
  202. # in this notif to the previous one
  203. prev_messages.append(message)
  204. if not merge:
  205. room_vars['notifs'].append(notifvars)
  206. defer.returnValue(room_vars)
  207. @defer.inlineCallbacks
  208. def get_notif_vars(self, notif, user_id, notif_event, room_state_ids):
  209. results = yield self.store.get_events_around(
  210. notif['room_id'], notif['event_id'],
  211. before_limit=CONTEXT_BEFORE, after_limit=CONTEXT_AFTER
  212. )
  213. ret = {
  214. "link": self.make_notif_link(notif),
  215. "ts": notif['received_ts'],
  216. "messages": [],
  217. }
  218. the_events = yield filter_events_for_client(
  219. self.store, user_id, results["events_before"]
  220. )
  221. the_events.append(notif_event)
  222. for event in the_events:
  223. messagevars = yield self.get_message_vars(notif, event, room_state_ids)
  224. if messagevars is not None:
  225. ret['messages'].append(messagevars)
  226. defer.returnValue(ret)
  227. @defer.inlineCallbacks
  228. def get_message_vars(self, notif, event, room_state_ids):
  229. if event.type != EventTypes.Message:
  230. return
  231. sender_state_event_id = room_state_ids[("m.room.member", event.sender)]
  232. sender_state_event = yield self.store.get_event(sender_state_event_id)
  233. sender_name = name_from_member_event(sender_state_event)
  234. sender_avatar_url = sender_state_event.content.get("avatar_url")
  235. # 'hash' for deterministically picking default images: use
  236. # sender_hash % the number of default images to choose from
  237. sender_hash = string_ordinal_total(event.sender)
  238. msgtype = event.content.get("msgtype")
  239. ret = {
  240. "msgtype": msgtype,
  241. "is_historical": event.event_id != notif['event_id'],
  242. "id": event.event_id,
  243. "ts": event.origin_server_ts,
  244. "sender_name": sender_name,
  245. "sender_avatar_url": sender_avatar_url,
  246. "sender_hash": sender_hash,
  247. }
  248. if msgtype == "m.text":
  249. self.add_text_message_vars(ret, event)
  250. elif msgtype == "m.image":
  251. self.add_image_message_vars(ret, event)
  252. if "body" in event.content:
  253. ret["body_text_plain"] = event.content["body"]
  254. defer.returnValue(ret)
  255. def add_text_message_vars(self, messagevars, event):
  256. msgformat = event.content.get("format")
  257. messagevars["format"] = msgformat
  258. formatted_body = event.content.get("formatted_body")
  259. body = event.content.get("body")
  260. if msgformat == "org.matrix.custom.html" and formatted_body:
  261. messagevars["body_text_html"] = safe_markup(formatted_body)
  262. elif body:
  263. messagevars["body_text_html"] = safe_text(body)
  264. return messagevars
  265. def add_image_message_vars(self, messagevars, event):
  266. messagevars["image_url"] = event.content["url"]
  267. return messagevars
  268. @defer.inlineCallbacks
  269. def make_summary_text(self, notifs_by_room, room_state_ids,
  270. notif_events, user_id, reason):
  271. if len(notifs_by_room) == 1:
  272. # Only one room has new stuff
  273. room_id = list(notifs_by_room.keys())[0]
  274. # If the room has some kind of name, use it, but we don't
  275. # want the generated-from-names one here otherwise we'll
  276. # end up with, "new message from Bob in the Bob room"
  277. room_name = yield calculate_room_name(
  278. self.store, room_state_ids[room_id], user_id, fallback_to_members=False
  279. )
  280. my_member_event_id = room_state_ids[room_id][("m.room.member", user_id)]
  281. my_member_event = yield self.store.get_event(my_member_event_id)
  282. if my_member_event.content["membership"] == "invite":
  283. inviter_member_event_id = room_state_ids[room_id][
  284. ("m.room.member", my_member_event.sender)
  285. ]
  286. inviter_member_event = yield self.store.get_event(
  287. inviter_member_event_id
  288. )
  289. inviter_name = name_from_member_event(inviter_member_event)
  290. if room_name is None:
  291. defer.returnValue(INVITE_FROM_PERSON % {
  292. "person": inviter_name,
  293. "app": self.app_name
  294. })
  295. else:
  296. defer.returnValue(INVITE_FROM_PERSON_TO_ROOM % {
  297. "person": inviter_name,
  298. "room": room_name,
  299. "app": self.app_name,
  300. })
  301. sender_name = None
  302. if len(notifs_by_room[room_id]) == 1:
  303. # There is just the one notification, so give some detail
  304. event = notif_events[notifs_by_room[room_id][0]["event_id"]]
  305. if ("m.room.member", event.sender) in room_state_ids[room_id]:
  306. state_event_id = room_state_ids[room_id][
  307. ("m.room.member", event.sender)
  308. ]
  309. state_event = yield self.store.get_event(state_event_id)
  310. sender_name = name_from_member_event(state_event)
  311. if sender_name is not None and room_name is not None:
  312. defer.returnValue(MESSAGE_FROM_PERSON_IN_ROOM % {
  313. "person": sender_name,
  314. "room": room_name,
  315. "app": self.app_name,
  316. })
  317. elif sender_name is not None:
  318. defer.returnValue(MESSAGE_FROM_PERSON % {
  319. "person": sender_name,
  320. "app": self.app_name,
  321. })
  322. else:
  323. # There's more than one notification for this room, so just
  324. # say there are several
  325. if room_name is not None:
  326. defer.returnValue(MESSAGES_IN_ROOM % {
  327. "room": room_name,
  328. "app": self.app_name,
  329. })
  330. else:
  331. # If the room doesn't have a name, say who the messages
  332. # are from explicitly to avoid, "messages in the Bob room"
  333. sender_ids = list(set([
  334. notif_events[n['event_id']].sender
  335. for n in notifs_by_room[room_id]
  336. ]))
  337. member_events = yield self.store.get_events([
  338. room_state_ids[room_id][("m.room.member", s)]
  339. for s in sender_ids
  340. ])
  341. defer.returnValue(MESSAGES_FROM_PERSON % {
  342. "person": descriptor_from_member_events(member_events.values()),
  343. "app": self.app_name,
  344. })
  345. else:
  346. # Stuff's happened in multiple different rooms
  347. # ...but we still refer to the 'reason' room which triggered the mail
  348. if reason['room_name'] is not None:
  349. defer.returnValue(MESSAGES_IN_ROOM_AND_OTHERS % {
  350. "room": reason['room_name'],
  351. "app": self.app_name,
  352. })
  353. else:
  354. # If the reason room doesn't have a name, say who the messages
  355. # are from explicitly to avoid, "messages in the Bob room"
  356. sender_ids = list(set([
  357. notif_events[n['event_id']].sender
  358. for n in notifs_by_room[reason['room_id']]
  359. ]))
  360. member_events = yield self.store.get_events([
  361. room_state_ids[room_id][("m.room.member", s)]
  362. for s in sender_ids
  363. ])
  364. defer.returnValue(MESSAGES_FROM_PERSON_AND_OTHERS % {
  365. "person": descriptor_from_member_events(member_events.values()),
  366. "app": self.app_name,
  367. })
  368. def make_room_link(self, room_id):
  369. if self.hs.config.email_riot_base_url:
  370. base_url = "%s/#/room" % (self.hs.config.email_riot_base_url)
  371. elif self.app_name == "Vector":
  372. # need /beta for Universal Links to work on iOS
  373. base_url = "https://vector.im/beta/#/room"
  374. else:
  375. base_url = "https://matrix.to/#"
  376. return "%s/%s" % (base_url, room_id)
  377. def make_notif_link(self, notif):
  378. if self.hs.config.email_riot_base_url:
  379. return "%s/#/room/%s/%s" % (
  380. self.hs.config.email_riot_base_url,
  381. notif['room_id'], notif['event_id']
  382. )
  383. elif self.app_name == "Vector":
  384. # need /beta for Universal Links to work on iOS
  385. return "https://vector.im/beta/#/room/%s/%s" % (
  386. notif['room_id'], notif['event_id']
  387. )
  388. else:
  389. return "https://matrix.to/#/%s/%s" % (
  390. notif['room_id'], notif['event_id']
  391. )
  392. def make_unsubscribe_link(self, user_id, app_id, email_address):
  393. params = {
  394. "access_token": self.macaroon_gen.generate_delete_pusher_token(user_id),
  395. "app_id": app_id,
  396. "pushkey": email_address,
  397. }
  398. # XXX: make r0 once API is stable
  399. return "%s_matrix/client/unstable/pushers/remove?%s" % (
  400. self.hs.config.public_baseurl,
  401. urllib.parse.urlencode(params),
  402. )
  403. def safe_markup(raw_html):
  404. return jinja2.Markup(bleach.linkify(bleach.clean(
  405. raw_html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS,
  406. # bleach master has this, but it isn't released yet
  407. # protocols=ALLOWED_SCHEMES,
  408. strip=True
  409. )))
  410. def safe_text(raw_text):
  411. """
  412. Process text: treat it as HTML but escape any tags (ie. just escape the
  413. HTML) then linkify it.
  414. """
  415. return jinja2.Markup(bleach.linkify(bleach.clean(
  416. raw_text, tags=[], attributes={},
  417. strip=False
  418. )))
  419. def deduped_ordered_list(l):
  420. seen = set()
  421. ret = []
  422. for item in l:
  423. if item not in seen:
  424. seen.add(item)
  425. ret.append(item)
  426. return ret
  427. def string_ordinal_total(s):
  428. tot = 0
  429. for c in s:
  430. tot += ord(c)
  431. return tot
  432. def format_ts_filter(value, format):
  433. return time.strftime(format, time.localtime(value / 1000))
  434. def load_jinja2_templates(config):
  435. """Load the jinja2 email templates from disk
  436. Returns:
  437. (notif_template_html, notif_template_text)
  438. """
  439. logger.info("loading email templates from '%s'", config.email_template_dir)
  440. loader = jinja2.FileSystemLoader(config.email_template_dir)
  441. env = jinja2.Environment(loader=loader)
  442. env.filters["format_ts"] = format_ts_filter
  443. env.filters["mxc_to_http"] = _create_mxc_to_http_filter(config)
  444. notif_template_html = env.get_template(
  445. config.email_notif_template_html
  446. )
  447. notif_template_text = env.get_template(
  448. config.email_notif_template_text
  449. )
  450. return notif_template_html, notif_template_text
  451. def _create_mxc_to_http_filter(config):
  452. def mxc_to_http_filter(value, width, height, resize_method="crop"):
  453. if value[0:6] != "mxc://":
  454. return ""
  455. serverAndMediaId = value[6:]
  456. fragment = None
  457. if '#' in serverAndMediaId:
  458. (serverAndMediaId, fragment) = serverAndMediaId.split('#', 1)
  459. fragment = "#" + fragment
  460. params = {
  461. "width": width,
  462. "height": height,
  463. "method": resize_method,
  464. }
  465. return "%s_matrix/media/v1/thumbnail/%s?%s%s" % (
  466. config.public_baseurl,
  467. serverAndMediaId,
  468. urllib.parse.urlencode(params),
  469. fragment or "",
  470. )
  471. return mxc_to_http_filter