mailer.py 21 KB

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