mailer.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  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.util.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):
  70. self.hs = hs
  71. self.store = self.hs.get_datastore()
  72. self.auth_handler = self.hs.get_auth_handler()
  73. self.state_handler = self.hs.get_state_handler()
  74. loader = jinja2.FileSystemLoader(self.hs.config.email_template_dir)
  75. self.app_name = app_name
  76. logger.info("Created Mailer for app_name %s" % app_name)
  77. env = jinja2.Environment(loader=loader)
  78. env.filters["format_ts"] = format_ts_filter
  79. env.filters["mxc_to_http"] = self.mxc_to_http_filter
  80. self.notif_template_html = env.get_template(
  81. self.hs.config.email_notif_template_html
  82. )
  83. self.notif_template_text = env.get_template(
  84. self.hs.config.email_notif_template_text
  85. )
  86. @defer.inlineCallbacks
  87. def send_notification_mail(self, app_id, user_id, email_address,
  88. push_actions, reason):
  89. try:
  90. from_string = self.hs.config.email_notif_from % {
  91. "app": self.app_name
  92. }
  93. except TypeError:
  94. from_string = self.hs.config.email_notif_from
  95. raw_from = email.utils.parseaddr(from_string)[1]
  96. raw_to = email.utils.parseaddr(email_address)[1]
  97. if raw_to == '':
  98. raise RuntimeError("Invalid 'to' address")
  99. rooms_in_order = deduped_ordered_list(
  100. [pa['room_id'] for pa in push_actions]
  101. )
  102. notif_events = yield self.store.get_events(
  103. [pa['event_id'] for pa in push_actions]
  104. )
  105. notifs_by_room = {}
  106. for pa in push_actions:
  107. notifs_by_room.setdefault(pa["room_id"], []).append(pa)
  108. # collect the current state for all the rooms in which we have
  109. # notifications
  110. state_by_room = {}
  111. try:
  112. user_display_name = yield self.store.get_profile_displayname(
  113. UserID.from_string(user_id).localpart
  114. )
  115. if user_display_name is None:
  116. user_display_name = user_id
  117. except StoreError:
  118. user_display_name = user_id
  119. @defer.inlineCallbacks
  120. def _fetch_room_state(room_id):
  121. room_state = yield self.state_handler.get_current_state(room_id)
  122. state_by_room[room_id] = room_state
  123. # Run at most 3 of these at once: sync does 10 at a time but email
  124. # notifs are much less realtime than sync so we can afford to wait a bit.
  125. yield concurrently_execute(_fetch_room_state, rooms_in_order, 3)
  126. # actually sort our so-called rooms_in_order list, most recent room first
  127. rooms_in_order.sort(
  128. key=lambda r: -(notifs_by_room[r][-1]['received_ts'] or 0)
  129. )
  130. rooms = []
  131. for r in rooms_in_order:
  132. roomvars = yield self.get_room_vars(
  133. r, user_id, notifs_by_room[r], notif_events, state_by_room[r]
  134. )
  135. rooms.append(roomvars)
  136. reason['room_name'] = calculate_room_name(
  137. state_by_room[reason['room_id']], user_id, fallback_to_members=True
  138. )
  139. summary_text = self.make_summary_text(
  140. notifs_by_room, state_by_room, notif_events, user_id, reason
  141. )
  142. template_vars = {
  143. "user_display_name": user_display_name,
  144. "unsubscribe_link": self.make_unsubscribe_link(
  145. user_id, app_id, email_address
  146. ),
  147. "summary_text": summary_text,
  148. "app_name": self.app_name,
  149. "rooms": rooms,
  150. "reason": reason,
  151. }
  152. html_text = self.notif_template_html.render(**template_vars)
  153. html_part = MIMEText(html_text, "html", "utf8")
  154. plain_text = self.notif_template_text.render(**template_vars)
  155. text_part = MIMEText(plain_text, "plain", "utf8")
  156. multipart_msg = MIMEMultipart('alternative')
  157. multipart_msg['Subject'] = "[%s] %s" % (self.app_name, summary_text)
  158. multipart_msg['From'] = from_string
  159. multipart_msg['To'] = email_address
  160. multipart_msg['Date'] = email.utils.formatdate()
  161. multipart_msg['Message-ID'] = email.utils.make_msgid()
  162. multipart_msg.attach(text_part)
  163. multipart_msg.attach(html_part)
  164. logger.info("Sending email push notification to %s" % email_address)
  165. # logger.debug(html_text)
  166. yield sendmail(
  167. self.hs.config.email_smtp_host,
  168. raw_from, raw_to, multipart_msg.as_string(),
  169. port=self.hs.config.email_smtp_port
  170. )
  171. @defer.inlineCallbacks
  172. def get_room_vars(self, room_id, user_id, notifs, notif_events, room_state):
  173. my_member_event = room_state[("m.room.member", user_id)]
  174. is_invite = my_member_event.content["membership"] == "invite"
  175. room_vars = {
  176. "title": calculate_room_name(room_state, user_id),
  177. "hash": string_ordinal_total(room_id), # See sender avatar hash
  178. "notifs": [],
  179. "invite": is_invite,
  180. "link": self.make_room_link(room_id),
  181. }
  182. if not is_invite:
  183. for n in notifs:
  184. notifvars = yield self.get_notif_vars(
  185. n, user_id, notif_events[n['event_id']], room_state
  186. )
  187. # merge overlapping notifs together.
  188. # relies on the notifs being in chronological order.
  189. merge = False
  190. if room_vars['notifs'] and 'messages' in room_vars['notifs'][-1]:
  191. prev_messages = room_vars['notifs'][-1]['messages']
  192. for message in notifvars['messages']:
  193. pm = filter(lambda pm: pm['id'] == message['id'], prev_messages)
  194. if pm:
  195. if not message["is_historical"]:
  196. pm[0]["is_historical"] = False
  197. merge = True
  198. elif merge:
  199. # we're merging, so append any remaining messages
  200. # in this notif to the previous one
  201. prev_messages.append(message)
  202. if not merge:
  203. room_vars['notifs'].append(notifvars)
  204. defer.returnValue(room_vars)
  205. @defer.inlineCallbacks
  206. def get_notif_vars(self, notif, user_id, notif_event, room_state):
  207. results = yield self.store.get_events_around(
  208. notif['room_id'], notif['event_id'],
  209. before_limit=CONTEXT_BEFORE, after_limit=CONTEXT_AFTER
  210. )
  211. ret = {
  212. "link": self.make_notif_link(notif),
  213. "ts": notif['received_ts'],
  214. "messages": [],
  215. }
  216. the_events = yield filter_events_for_client(
  217. self.store, user_id, results["events_before"]
  218. )
  219. the_events.append(notif_event)
  220. for event in the_events:
  221. messagevars = self.get_message_vars(notif, event, room_state)
  222. if messagevars is not None:
  223. ret['messages'].append(messagevars)
  224. defer.returnValue(ret)
  225. def get_message_vars(self, notif, event, room_state):
  226. if event.type != EventTypes.Message:
  227. return None
  228. sender_state_event = room_state[("m.room.member", event.sender)]
  229. sender_name = name_from_member_event(sender_state_event)
  230. sender_avatar_url = None
  231. if "avatar_url" in sender_state_event.content:
  232. sender_avatar_url = sender_state_event.content["avatar_url"]
  233. # 'hash' for deterministically picking default images: use
  234. # sender_hash % the number of default images to choose from
  235. sender_hash = string_ordinal_total(event.sender)
  236. ret = {
  237. "msgtype": event.content["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 event.content["msgtype"] == "m.text":
  246. self.add_text_message_vars(ret, event)
  247. elif event.content["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. return ret
  252. def add_text_message_vars(self, messagevars, event):
  253. if "format" in event.content:
  254. msgformat = event.content["format"]
  255. else:
  256. msgformat = None
  257. messagevars["format"] = msgformat
  258. if msgformat == "org.matrix.custom.html":
  259. messagevars["body_text_html"] = safe_markup(event.content["formatted_body"])
  260. else:
  261. messagevars["body_text_html"] = safe_text(event.content["body"])
  262. return messagevars
  263. def add_image_message_vars(self, messagevars, event):
  264. messagevars["image_url"] = event.content["url"]
  265. return messagevars
  266. def make_summary_text(self, notifs_by_room, state_by_room,
  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 = calculate_room_name(
  275. state_by_room[room_id], user_id, fallback_to_members=False
  276. )
  277. my_member_event = state_by_room[room_id][("m.room.member", user_id)]
  278. if my_member_event.content["membership"] == "invite":
  279. inviter_member_event = state_by_room[room_id][
  280. ("m.room.member", my_member_event.sender)
  281. ]
  282. inviter_name = name_from_member_event(inviter_member_event)
  283. if room_name is None:
  284. return INVITE_FROM_PERSON % {
  285. "person": inviter_name,
  286. "app": self.app_name
  287. }
  288. else:
  289. return INVITE_FROM_PERSON_TO_ROOM % {
  290. "person": inviter_name,
  291. "room": room_name,
  292. "app": self.app_name,
  293. }
  294. sender_name = None
  295. if len(notifs_by_room[room_id]) == 1:
  296. # There is just the one notification, so give some detail
  297. event = notif_events[notifs_by_room[room_id][0]["event_id"]]
  298. if ("m.room.member", event.sender) in state_by_room[room_id]:
  299. state_event = state_by_room[room_id][("m.room.member", event.sender)]
  300. sender_name = name_from_member_event(state_event)
  301. if sender_name is not None and room_name is not None:
  302. return MESSAGE_FROM_PERSON_IN_ROOM % {
  303. "person": sender_name,
  304. "room": room_name,
  305. "app": self.app_name,
  306. }
  307. elif sender_name is not None:
  308. return MESSAGE_FROM_PERSON % {
  309. "person": sender_name,
  310. "app": self.app_name,
  311. }
  312. else:
  313. # There's more than one notification for this room, so just
  314. # say there are several
  315. if room_name is not None:
  316. return MESSAGES_IN_ROOM % {
  317. "room": room_name,
  318. "app": self.app_name,
  319. }
  320. else:
  321. # If the room doesn't have a name, say who the messages
  322. # are from explicitly to avoid, "messages in the Bob room"
  323. sender_ids = list(set([
  324. notif_events[n['event_id']].sender
  325. for n in notifs_by_room[room_id]
  326. ]))
  327. return MESSAGES_FROM_PERSON % {
  328. "person": descriptor_from_member_events([
  329. state_by_room[room_id][("m.room.member", s)]
  330. for s in sender_ids
  331. ]),
  332. "app": self.app_name,
  333. }
  334. else:
  335. # Stuff's happened in multiple different rooms
  336. # ...but we still refer to the 'reason' room which triggered the mail
  337. if reason['room_name'] is not None:
  338. return MESSAGES_IN_ROOM_AND_OTHERS % {
  339. "room": reason['room_name'],
  340. "app": self.app_name,
  341. }
  342. else:
  343. # If the reason room doesn't have a name, say who the messages
  344. # are from explicitly to avoid, "messages in the Bob room"
  345. sender_ids = list(set([
  346. notif_events[n['event_id']].sender
  347. for n in notifs_by_room[reason['room_id']]
  348. ]))
  349. return MESSAGES_FROM_PERSON_AND_OTHERS % {
  350. "person": descriptor_from_member_events([
  351. state_by_room[reason['room_id']][("m.room.member", s)]
  352. for s in sender_ids
  353. ]),
  354. "app": self.app_name,
  355. }
  356. def make_room_link(self, room_id):
  357. # need /beta for Universal Links to work on iOS
  358. if self.app_name == "Vector":
  359. return "https://vector.im/beta/#/room/%s" % (room_id,)
  360. else:
  361. return "https://matrix.to/#/%s" % (room_id,)
  362. def make_notif_link(self, notif):
  363. # need /beta for Universal Links to work on iOS
  364. if self.app_name == "Vector":
  365. return "https://vector.im/beta/#/room/%s/%s" % (
  366. notif['room_id'], notif['event_id']
  367. )
  368. else:
  369. return "https://matrix.to/#/%s/%s" % (
  370. notif['room_id'], notif['event_id']
  371. )
  372. def make_unsubscribe_link(self, user_id, app_id, email_address):
  373. params = {
  374. "access_token": self.auth_handler.generate_delete_pusher_token(user_id),
  375. "app_id": app_id,
  376. "pushkey": email_address,
  377. }
  378. # XXX: make r0 once API is stable
  379. return "%s_matrix/client/unstable/pushers/remove?%s" % (
  380. self.hs.config.public_baseurl,
  381. urllib.urlencode(params),
  382. )
  383. def mxc_to_http_filter(self, value, width, height, resize_method="crop"):
  384. if value[0:6] != "mxc://":
  385. return ""
  386. serverAndMediaId = value[6:]
  387. fragment = None
  388. if '#' in serverAndMediaId:
  389. (serverAndMediaId, fragment) = serverAndMediaId.split('#', 1)
  390. fragment = "#" + fragment
  391. params = {
  392. "width": width,
  393. "height": height,
  394. "method": resize_method,
  395. }
  396. return "%s_matrix/media/v1/thumbnail/%s?%s%s" % (
  397. self.hs.config.public_baseurl,
  398. serverAndMediaId,
  399. urllib.urlencode(params),
  400. fragment or "",
  401. )
  402. def safe_markup(raw_html):
  403. return jinja2.Markup(bleach.linkify(bleach.clean(
  404. raw_html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS,
  405. # bleach master has this, but it isn't released yet
  406. # protocols=ALLOWED_SCHEMES,
  407. strip=True
  408. )))
  409. def safe_text(raw_text):
  410. """
  411. Process text: treat it as HTML but escape any tags (ie. just escape the
  412. HTML) then linkify it.
  413. """
  414. return jinja2.Markup(bleach.linkify(bleach.clean(
  415. raw_text, tags=[], attributes={},
  416. strip=False
  417. )))
  418. def deduped_ordered_list(l):
  419. seen = set()
  420. ret = []
  421. for item in l:
  422. if item not in seen:
  423. seen.add(item)
  424. ret.append(item)
  425. return ret
  426. def string_ordinal_total(s):
  427. tot = 0
  428. for c in s:
  429. tot += ord(c)
  430. return tot
  431. def format_ts_filter(value, format):
  432. return time.strftime(format, time.localtime(value / 1000))