mailer.py 21 KB

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