1
0

mailer.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  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 = list(filter(lambda pm: pm['id'] == message['id'],
  193. 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_ids):
  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 = yield self.get_message_vars(notif, event, room_state_ids)
  222. if messagevars is not None:
  223. ret['messages'].append(messagevars)
  224. defer.returnValue(ret)
  225. @defer.inlineCallbacks
  226. def get_message_vars(self, notif, event, room_state_ids):
  227. if event.type != EventTypes.Message:
  228. return
  229. sender_state_event_id = room_state_ids[("m.room.member", event.sender)]
  230. sender_state_event = yield self.store.get_event(sender_state_event_id)
  231. sender_name = name_from_member_event(sender_state_event)
  232. sender_avatar_url = sender_state_event.content.get("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. msgtype = event.content.get("msgtype")
  237. ret = {
  238. "msgtype": msgtype,
  239. "is_historical": event.event_id != notif['event_id'],
  240. "id": event.event_id,
  241. "ts": event.origin_server_ts,
  242. "sender_name": sender_name,
  243. "sender_avatar_url": sender_avatar_url,
  244. "sender_hash": sender_hash,
  245. }
  246. if msgtype == "m.text":
  247. self.add_text_message_vars(ret, event)
  248. elif msgtype == "m.image":
  249. self.add_image_message_vars(ret, event)
  250. if "body" in event.content:
  251. ret["body_text_plain"] = event.content["body"]
  252. defer.returnValue(ret)
  253. def add_text_message_vars(self, messagevars, event):
  254. msgformat = event.content.get("format")
  255. messagevars["format"] = msgformat
  256. formatted_body = event.content.get("formatted_body")
  257. body = event.content.get("body")
  258. if msgformat == "org.matrix.custom.html" and formatted_body:
  259. messagevars["body_text_html"] = safe_markup(formatted_body)
  260. elif body:
  261. messagevars["body_text_html"] = safe_text(body)
  262. return messagevars
  263. def add_image_message_vars(self, messagevars, event):
  264. messagevars["image_url"] = event.content["url"]
  265. return messagevars
  266. @defer.inlineCallbacks
  267. def make_summary_text(self, notifs_by_room, room_state_ids,
  268. notif_events, user_id, reason):
  269. if len(notifs_by_room) == 1:
  270. # Only one room has new stuff
  271. room_id = notifs_by_room.keys()[0]
  272. # If the room has some kind of name, use it, but we don't
  273. # want the generated-from-names one here otherwise we'll
  274. # end up with, "new message from Bob in the Bob room"
  275. room_name = yield calculate_room_name(
  276. self.store, room_state_ids[room_id], user_id, fallback_to_members=False
  277. )
  278. my_member_event_id = room_state_ids[room_id][("m.room.member", user_id)]
  279. my_member_event = yield self.store.get_event(my_member_event_id)
  280. if my_member_event.content["membership"] == "invite":
  281. inviter_member_event_id = room_state_ids[room_id][
  282. ("m.room.member", my_member_event.sender)
  283. ]
  284. inviter_member_event = yield self.store.get_event(
  285. inviter_member_event_id
  286. )
  287. inviter_name = name_from_member_event(inviter_member_event)
  288. if room_name is None:
  289. defer.returnValue(INVITE_FROM_PERSON % {
  290. "person": inviter_name,
  291. "app": self.app_name
  292. })
  293. else:
  294. defer.returnValue(INVITE_FROM_PERSON_TO_ROOM % {
  295. "person": inviter_name,
  296. "room": room_name,
  297. "app": self.app_name,
  298. })
  299. sender_name = None
  300. if len(notifs_by_room[room_id]) == 1:
  301. # There is just the one notification, so give some detail
  302. event = notif_events[notifs_by_room[room_id][0]["event_id"]]
  303. if ("m.room.member", event.sender) in room_state_ids[room_id]:
  304. state_event_id = room_state_ids[room_id][
  305. ("m.room.member", event.sender)
  306. ]
  307. state_event = yield self.store.get_event(state_event_id)
  308. sender_name = name_from_member_event(state_event)
  309. if sender_name is not None and room_name is not None:
  310. defer.returnValue(MESSAGE_FROM_PERSON_IN_ROOM % {
  311. "person": sender_name,
  312. "room": room_name,
  313. "app": self.app_name,
  314. })
  315. elif sender_name is not None:
  316. defer.returnValue(MESSAGE_FROM_PERSON % {
  317. "person": sender_name,
  318. "app": self.app_name,
  319. })
  320. else:
  321. # There's more than one notification for this room, so just
  322. # say there are several
  323. if room_name is not None:
  324. defer.returnValue(MESSAGES_IN_ROOM % {
  325. "room": room_name,
  326. "app": self.app_name,
  327. })
  328. else:
  329. # If the room doesn't have a name, say who the messages
  330. # are from explicitly to avoid, "messages in the Bob room"
  331. sender_ids = list(set([
  332. notif_events[n['event_id']].sender
  333. for n in notifs_by_room[room_id]
  334. ]))
  335. member_events = yield self.store.get_events([
  336. room_state_ids[room_id][("m.room.member", s)]
  337. for s in sender_ids
  338. ])
  339. defer.returnValue(MESSAGES_FROM_PERSON % {
  340. "person": descriptor_from_member_events(member_events.values()),
  341. "app": self.app_name,
  342. })
  343. else:
  344. # Stuff's happened in multiple different rooms
  345. # ...but we still refer to the 'reason' room which triggered the mail
  346. if reason['room_name'] is not None:
  347. defer.returnValue(MESSAGES_IN_ROOM_AND_OTHERS % {
  348. "room": reason['room_name'],
  349. "app": self.app_name,
  350. })
  351. else:
  352. # If the reason room doesn't have a name, say who the messages
  353. # are from explicitly to avoid, "messages in the Bob room"
  354. sender_ids = list(set([
  355. notif_events[n['event_id']].sender
  356. for n in notifs_by_room[reason['room_id']]
  357. ]))
  358. member_events = yield self.store.get_events([
  359. room_state_ids[room_id][("m.room.member", s)]
  360. for s in sender_ids
  361. ])
  362. defer.returnValue(MESSAGES_FROM_PERSON_AND_OTHERS % {
  363. "person": descriptor_from_member_events(member_events.values()),
  364. "app": self.app_name,
  365. })
  366. def make_room_link(self, room_id):
  367. if self.hs.config.email_riot_base_url:
  368. base_url = self.hs.config.email_riot_base_url
  369. elif self.app_name == "Vector":
  370. # need /beta for Universal Links to work on iOS
  371. base_url = "https://vector.im/beta/#/room"
  372. else:
  373. base_url = "https://matrix.to/#"
  374. return "%s/%s" % (base_url, room_id)
  375. def make_notif_link(self, notif):
  376. if self.hs.config.email_riot_base_url:
  377. return "%s/#/room/%s/%s" % (
  378. self.hs.config.email_riot_base_url,
  379. notif['room_id'], notif['event_id']
  380. )
  381. elif self.app_name == "Vector":
  382. # need /beta for Universal Links to work on iOS
  383. return "https://vector.im/beta/#/room/%s/%s" % (
  384. notif['room_id'], notif['event_id']
  385. )
  386. else:
  387. return "https://matrix.to/#/%s/%s" % (
  388. notif['room_id'], notif['event_id']
  389. )
  390. def make_unsubscribe_link(self, user_id, app_id, email_address):
  391. params = {
  392. "access_token": self.macaroon_gen.generate_delete_pusher_token(user_id),
  393. "app_id": app_id,
  394. "pushkey": email_address,
  395. }
  396. # XXX: make r0 once API is stable
  397. return "%s_matrix/client/unstable/pushers/remove?%s" % (
  398. self.hs.config.public_baseurl,
  399. urllib.urlencode(params),
  400. )
  401. def safe_markup(raw_html):
  402. return jinja2.Markup(bleach.linkify(bleach.clean(
  403. raw_html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS,
  404. # bleach master has this, but it isn't released yet
  405. # protocols=ALLOWED_SCHEMES,
  406. strip=True
  407. )))
  408. def safe_text(raw_text):
  409. """
  410. Process text: treat it as HTML but escape any tags (ie. just escape the
  411. HTML) then linkify it.
  412. """
  413. return jinja2.Markup(bleach.linkify(bleach.clean(
  414. raw_text, tags=[], attributes={},
  415. strip=False
  416. )))
  417. def deduped_ordered_list(l):
  418. seen = set()
  419. ret = []
  420. for item in l:
  421. if item not in seen:
  422. seen.add(item)
  423. ret.append(item)
  424. return ret
  425. def string_ordinal_total(s):
  426. tot = 0
  427. for c in s:
  428. tot += ord(c)
  429. return tot
  430. def format_ts_filter(value, format):
  431. return time.strftime(format, time.localtime(value / 1000))
  432. def load_jinja2_templates(config):
  433. """Load the jinja2 email templates from disk
  434. Returns:
  435. (notif_template_html, notif_template_text)
  436. """
  437. logger.info("loading jinja2")
  438. loader = jinja2.FileSystemLoader(config.email_template_dir)
  439. env = jinja2.Environment(loader=loader)
  440. env.filters["format_ts"] = format_ts_filter
  441. env.filters["mxc_to_http"] = _create_mxc_to_http_filter(config)
  442. notif_template_html = env.get_template(
  443. config.email_notif_template_html
  444. )
  445. notif_template_text = env.get_template(
  446. config.email_notif_template_text
  447. )
  448. return notif_template_html, notif_template_text
  449. def _create_mxc_to_http_filter(config):
  450. def mxc_to_http_filter(value, width, height, resize_method="crop"):
  451. if value[0:6] != "mxc://":
  452. return ""
  453. serverAndMediaId = value[6:]
  454. fragment = None
  455. if '#' in serverAndMediaId:
  456. (serverAndMediaId, fragment) = serverAndMediaId.split('#', 1)
  457. fragment = "#" + fragment
  458. params = {
  459. "width": width,
  460. "height": height,
  461. "method": resize_method,
  462. }
  463. return "%s_matrix/media/v1/thumbnail/%s?%s%s" % (
  464. config.public_baseurl,
  465. serverAndMediaId,
  466. urllib.urlencode(params),
  467. fragment or "",
  468. )
  469. return mxc_to_http_filter