dispatch.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. # Copyright 2013-2016 The Distro Tracker Developers
  2. # See the COPYRIGHT file at the top-level directory of this distribution and
  3. # at http://deb.li/DTAuthors
  4. #
  5. # This file is part of Distro Tracker. It is subject to the license terms
  6. # in the LICENSE file found in the top-level directory of this
  7. # distribution and at http://deb.li/DTLicense. No part of Distro Tracker,
  8. # including this file, may be copied, modified, propagated, or distributed
  9. # except according to the terms contained in the LICENSE file.
  10. """
  11. Implements the processing of received package messages in order to dispatch
  12. them to subscribers.
  13. """
  14. from __future__ import unicode_literals
  15. from copy import deepcopy
  16. from datetime import datetime
  17. import logging
  18. import re
  19. from django.core.mail import get_connection
  20. from django.utils import six
  21. from django.utils import timezone
  22. from django.core.mail import EmailMessage
  23. from django.conf import settings
  24. from distro_tracker import vendor
  25. from distro_tracker.core.models import PackageName
  26. from distro_tracker.core.models import Keyword
  27. from distro_tracker.core.models import Team
  28. from distro_tracker.core.utils import extract_email_address_from_header
  29. from distro_tracker.core.utils import get_or_none
  30. from distro_tracker.core.utils import distro_tracker_render_to_string
  31. from distro_tracker.core.utils import verp
  32. from distro_tracker.core.utils.email_messages import CustomEmailMessage
  33. from distro_tracker.core.utils.email_messages import (
  34. patch_message_for_django_compat)
  35. from distro_tracker.mail.models import UserEmailBounceStats
  36. DISTRO_TRACKER_CONTROL_EMAIL = settings.DISTRO_TRACKER_CONTROL_EMAIL
  37. DISTRO_TRACKER_FQDN = settings.DISTRO_TRACKER_FQDN
  38. logger = logging.getLogger(__name__)
  39. class SkipMessage(Exception):
  40. """This exception can be raised by the vendor provided classify_message()
  41. to tell the dispatch code to skip processing this message being processed.
  42. The mail is then silently dropped."""
  43. def _get_logdata(msg, package, keyword):
  44. return {
  45. 'from': extract_email_address_from_header(msg.get('From', '')),
  46. 'msgid': msg.get('Message-ID', 'no-msgid-present@localhost'),
  47. 'package': package or '<unknown>',
  48. 'keyword': keyword or '<unknown>',
  49. }
  50. def process(msg, package=None, keyword=None):
  51. """
  52. Dispatches received messages by identifying where they should
  53. be sent and then by forwarding them.
  54. :param msg: The received message
  55. :type msg: :py:class:`email.message.Message`
  56. :param str package: The package to which the message was sent.
  57. :param str keyword: The keyword under which the message must be dispatched.
  58. """
  59. logdata = _get_logdata(msg, package, keyword)
  60. logger.info("dispatch :: received from %(from)s :: %(msgid)s",
  61. logdata)
  62. try:
  63. package, keyword = classify_message(msg, package, keyword)
  64. except SkipMessage:
  65. logger.info('dispatch :: skipping %(msgid)s', logdata)
  66. return
  67. if package is None:
  68. logger.warning('dispatch :: no package identified for %(msgid)s',
  69. logdata)
  70. return
  71. if isinstance(package, (list, set)):
  72. for pkg in package:
  73. forward(msg, pkg, keyword)
  74. else:
  75. forward(msg, package, keyword)
  76. def forward(msg, package, keyword):
  77. """
  78. Forwards a received message to the various subscribers of the
  79. given package/keyword combination.
  80. :param msg: The received message
  81. :type msg: :py:class:`email.message.Message`
  82. :param str package: The package name.
  83. :param str keyword: The keyword under which the message must be forwarded.
  84. """
  85. logdata = _get_logdata(msg, package, keyword)
  86. logger.info("dispatch :: forward to %(package)s %(keyword)s :: %(msgid)s",
  87. logdata)
  88. # Check loop
  89. dispatch_email = 'dispatch@{}'.format(DISTRO_TRACKER_FQDN)
  90. if dispatch_email in msg.get_all('X-Loop', ()):
  91. # Bad X-Loop, discard the message
  92. logger.info('dispatch :: discarded %(msgid)s due to X-Loop', logdata)
  93. return
  94. # Default keywords require special approvement
  95. if keyword == 'default' and not approved_default(msg):
  96. logger.info('dispatch :: discarded non-approved message %(msgid)s',
  97. logdata)
  98. return
  99. # Now send the message to subscribers
  100. add_new_headers(msg, package, keyword)
  101. send_to_subscribers(msg, package, keyword)
  102. send_to_teams(msg, package, keyword)
  103. def classify_message(msg, package=None, keyword=None):
  104. """
  105. Analyzes a message to identify what package it is about and
  106. what keyword is appropriate.
  107. :param msg: The received message
  108. :type msg: :py:class:`email.message.Message`
  109. :param str package: The suggested package name.
  110. :param str keyword: The suggested keyword under which the message can be
  111. forwarded.
  112. """
  113. if package is None:
  114. package = msg.get('X-Distro-Tracker-Package')
  115. if keyword is None:
  116. keyword = msg.get('X-Distro-Tracker-Keyword')
  117. result, implemented = vendor.call('classify_message', msg,
  118. package=package, keyword=keyword)
  119. if implemented:
  120. package, keyword = result
  121. if package and keyword is None:
  122. keyword = 'default'
  123. return (package, keyword)
  124. def approved_default(msg):
  125. """
  126. The function checks whether a message tagged with the default keyword should
  127. be approved, meaning that it gets forwarded to subscribers.
  128. :param msg: The received package message
  129. :type msg: :py:class:`email.message.Message` or an equivalent interface
  130. object
  131. """
  132. if 'X-Distro-Tracker-Approved' in msg:
  133. return True
  134. approved, implemented = vendor.call('approve_default_message', msg)
  135. if implemented:
  136. return approved
  137. else:
  138. return False
  139. def add_new_headers(received_message, package_name, keyword):
  140. """
  141. The function adds new distro-tracker specific headers to the received
  142. message. This is used before forwarding the message to subscribers.
  143. The headers added by this function are used regardless whether the
  144. message is forwarded due to direct package subscriptions or a team
  145. subscription.
  146. :param received_message: The received package message
  147. :type received_message: :py:class:`email.message.Message` or an equivalent
  148. interface object
  149. :param package_name: The name of the package for which this message was
  150. intended.
  151. :type package_name: string
  152. :param keyword: The keyword with which the message should be tagged
  153. :type keyword: string
  154. """
  155. new_headers = [
  156. ('X-Loop', 'dispatch@{}'.format(DISTRO_TRACKER_FQDN)),
  157. ('X-Distro-Tracker-Package', package_name),
  158. ('X-Distro-Tracker-Keyword', keyword),
  159. ('List-Id', '<{}.{}>'.format(package_name, DISTRO_TRACKER_FQDN)),
  160. ]
  161. extra_vendor_headers, implemented = vendor.call(
  162. 'add_new_headers', received_message, package_name, keyword)
  163. if implemented:
  164. new_headers.extend(extra_vendor_headers)
  165. add_headers(received_message, new_headers)
  166. def add_direct_subscription_headers(received_message, package_name, keyword):
  167. """
  168. The function adds headers to the received message which are specific for
  169. messages to be sent to users that are directly subscribed to the package.
  170. """
  171. new_headers = [
  172. ('Precedence', 'list'),
  173. ('List-Unsubscribe',
  174. '<mailto:{control_email}?body=unsubscribe%20{package}>'.format(
  175. control_email=DISTRO_TRACKER_CONTROL_EMAIL,
  176. package=package_name)),
  177. ]
  178. add_headers(received_message, new_headers)
  179. def add_team_membership_headers(received_message, package_name, keyword, team):
  180. """
  181. The function adds headers to the received message which are specific for
  182. messages to be sent to users that are members of a team.
  183. """
  184. new_headers = [
  185. ('X-Distro-Tracker-Team', team.slug),
  186. ]
  187. add_headers(received_message, new_headers)
  188. def add_headers(message, new_headers):
  189. """
  190. Adds the given headers to the given message in a safe way.
  191. """
  192. for header_name, header_value in new_headers:
  193. # With Python 2, make sure we are adding bytes to the message
  194. if six.PY2:
  195. header_name, header_value = (
  196. header_name.encode('utf-8'),
  197. header_value.encode('utf-8'))
  198. message[header_name] = header_value
  199. def send_to_teams(received_message, package_name, keyword):
  200. """
  201. Sends the given email message to all members of each team that has the
  202. given package.
  203. The message is only sent to those users who have not muted the team
  204. and have the given keyword in teir set of keywords for the team
  205. membership.
  206. :param received_message: The modified received package message to be sent
  207. to the subscribers.
  208. :type received_message: :py:class:`email.message.Message` or an equivalent
  209. interface object
  210. :param package_name: The name of the package for which this message was
  211. intended.
  212. :type package_name: string
  213. :param keyword: The keyword with which the message should be tagged
  214. :type keyword: string
  215. """
  216. keyword = get_or_none(Keyword, name=keyword)
  217. package = get_or_none(PackageName, name=package_name)
  218. if not keyword or not package:
  219. return
  220. # Get all teams that have the given package
  221. teams = Team.objects.filter(packages=package)
  222. teams = teams.prefetch_related('team_membership_set')
  223. date = timezone.now().date()
  224. messages_to_send = []
  225. for team in teams:
  226. logger.info('dispatch :: sending to team %s', team.slug)
  227. team_message = deepcopy(received_message)
  228. add_team_membership_headers(
  229. team_message, package_name, keyword.name, team)
  230. # Send the message to each member of the team
  231. for membership in team.team_membership_set.all():
  232. # Do not send messages to muted memberships
  233. if membership.is_muted(package):
  234. continue
  235. # Do not send the message if the user has disabled the keyword
  236. if keyword not in membership.get_keywords(package):
  237. continue
  238. messages_to_send.append(prepare_message(
  239. team_message, membership.user_email.email, date))
  240. send_messages(messages_to_send, date)
  241. def send_to_subscribers(received_message, package_name, keyword):
  242. """
  243. Sends the given email message to all subscribers of the package with the
  244. given name and those that accept messages tagged with the given keyword.
  245. :param received_message: The modified received package message to be sent
  246. to the subscribers.
  247. :type received_message: :py:class:`email.message.Message` or an equivalent
  248. interface object
  249. :param package_name: The name of the package for which this message was
  250. intended.
  251. :type package_name: string
  252. :param keyword: The keyword with which the message should be tagged
  253. :type keyword: string
  254. """
  255. # Make a copy of the message to be sent and add any headers which are
  256. # specific for users that are directly subscribed to the package.
  257. received_message = deepcopy(received_message)
  258. add_direct_subscription_headers(received_message, package_name, keyword)
  259. package = get_or_none(PackageName, name=package_name)
  260. if not package:
  261. return
  262. # Build a list of all messages to be sent
  263. date = timezone.now().date()
  264. messages_to_send = [
  265. prepare_message(received_message,
  266. subscription.email_settings.user_email.email,
  267. date)
  268. for subscription in package.subscription_set.all_active(keyword)
  269. ]
  270. send_messages(messages_to_send, date)
  271. def send_messages(messages_to_send, date):
  272. """
  273. Sends all the given email messages over a single SMTP connection.
  274. """
  275. connection = get_connection()
  276. connection.send_messages(messages_to_send)
  277. for message in messages_to_send:
  278. logger.info("dispatch => %s", message.to[0])
  279. UserEmailBounceStats.objects.add_sent_for_user(email=message.to[0],
  280. date=date)
  281. def prepare_message(received_message, to_email, date):
  282. """
  283. Converts a message which is to be sent to a subscriber to a
  284. :py:class:`CustomEmailMessage
  285. <distro_tracker.core.utils.email_messages.CustomEmailMessage>`
  286. so that it can be sent out using Django's API.
  287. It also sets the required evelope-to value in order to track the bounce for
  288. the message.
  289. :param received_message: The modified received package message to be sent
  290. to the subscribers.
  291. :type received_message: :py:class:`email.message.Message` or an equivalent
  292. interface object
  293. :param to_email: The email of the subscriber to whom the message is to be
  294. sent
  295. :type to_email: string
  296. :param date: The date which should be used as the message's sent date.
  297. :type date: :py:class:`datetime.datetime`
  298. """
  299. bounce_address = 'bounces+{date}@{distro_tracker_fqdn}'.format(
  300. date=date.strftime('%Y%m%d'),
  301. distro_tracker_fqdn=DISTRO_TRACKER_FQDN)
  302. message = CustomEmailMessage(
  303. msg=patch_message_for_django_compat(received_message),
  304. from_email=verp.encode(bounce_address, to_email),
  305. to=[to_email])
  306. return message
  307. def handle_bounces(sent_to_address):
  308. """
  309. Handles a received bounce message.
  310. :param sent_to_address: The envelope-to (return path) address to which the
  311. bounced email was returned.
  312. :type sent_to_address: string
  313. """
  314. bounce_email, user_email = verp.decode(sent_to_address)
  315. match = re.match(r'^bounces\+(\d{8})@' + DISTRO_TRACKER_FQDN, bounce_email)
  316. if not match:
  317. logger.warning('bounces :: invalid address %s', bounce_email)
  318. return
  319. try:
  320. date = datetime.strptime(match.group(1), '%Y%m%d')
  321. except ValueError:
  322. logger.warning('bounces :: invalid date in address %s', bounce_email)
  323. return
  324. logger.info('bounces :: received one for %s/%s', user_email, date)
  325. try:
  326. user = UserEmailBounceStats.objects.get(email__iexact=user_email)
  327. except UserEmailBounceStats.DoesNotExist:
  328. logger.warning('bounces :: unknown user email %s', user_email)
  329. return
  330. UserEmailBounceStats.objects.add_bounce_for_user(email=user_email,
  331. date=date)
  332. if user.has_too_many_bounces():
  333. logger.info('bounces => %s has too many bounces', user_email)
  334. packages = [p.name for p in user.emailsettings.packagename_set.all()]
  335. email_body = distro_tracker_render_to_string(
  336. 'dispatch/unsubscribed-due-to-bounces-email.txt', {
  337. 'email': user_email,
  338. 'packages': packages,
  339. })
  340. EmailMessage(
  341. subject='All your package subscriptions have been cancelled',
  342. from_email=settings.DISTRO_TRACKER_BOUNCES_LIKELY_SPAM_EMAIL,
  343. to=[user_email],
  344. cc=[settings.DISTRO_TRACKER_CONTACT_EMAIL],
  345. body=email_body,
  346. headers={
  347. 'From': settings.DISTRO_TRACKER_CONTACT_EMAIL,
  348. },
  349. ).send()
  350. user.emailsettings.unsubscribe_all()
  351. for package in packages:
  352. logger.info('bounces :: removed %s from %s', user_email, package)