tracker.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import re
  2. import logging
  3. from email.charset import Charset as EMailCharset
  4. from django.db import transaction
  5. from django.db.models import Count
  6. from django.contrib.auth import get_user_model
  7. from django.conf import settings
  8. from html2text import html2text
  9. from email.utils import parseaddr
  10. from tickets.models import Comment, Task, TaskList
  11. logger = logging.getLogger(__name__)
  12. def part_decode(message):
  13. charset = ("ascii", "ignore")
  14. email_charset = message.get_content_charset()
  15. if email_charset:
  16. charset = (EMailCharset(email_charset).input_charset,)
  17. body = message.get_payload(decode=True)
  18. return body.decode(*charset)
  19. def message_find_mime(message, mime_type):
  20. for submessage in message.walk():
  21. if submessage.get_content_type() == mime_type:
  22. return submessage
  23. return None
  24. def message_text(message):
  25. text_part = message_find_mime(message, "text/plain")
  26. if text_part is not None:
  27. return part_decode(text_part)
  28. html_part = message_find_mime(message, "text/html")
  29. if html_part is not None:
  30. return html2text(part_decode(html_part))
  31. # tickets: find something smart to do when no text if found
  32. return ""
  33. def format_task_title(format_string, message):
  34. return format_string.format(subject=message["subject"], author=message["from"])
  35. DJANGO_TICKETS_THREAD = re.compile(r"<thread-(\d+)@django-tickets>")
  36. def parse_references(task_list, references):
  37. related_messages = []
  38. answer_thread = None
  39. for related_message in references.split():
  40. logger.info("checking reference: %r", related_message)
  41. match = re.match(DJANGO_TICKETS_THREAD, related_message)
  42. if match is None:
  43. related_messages.append(related_message)
  44. continue
  45. thread_id = int(match.group(1))
  46. new_answer_thread = Task.objects.filter(task_list=task_list, pk=thread_id).first()
  47. if new_answer_thread is not None:
  48. answer_thread = new_answer_thread
  49. if answer_thread is None:
  50. logger.info("no answer thread found in references")
  51. else:
  52. logger.info("found an answer thread: %s", str(answer_thread))
  53. return related_messages, answer_thread
  54. def insert_message(task_list, message, priority, task_title_format):
  55. if "message-id" not in message:
  56. logger.warning("missing message id, ignoring message")
  57. return
  58. if "from" not in message:
  59. logger.warning('missing "From" header, ignoring message')
  60. return
  61. if "subject" not in message:
  62. logger.warning('missing "Subject" header, ignoring message')
  63. return
  64. logger.info(
  65. "received message:\t"
  66. f"[Subject: {message['subject']}]\t"
  67. f"[Message-ID: {message['message-id']}]\t"
  68. f"[References: {message['references']}]\t"
  69. f"[To: {message['to']}]\t"
  70. f"[From: {message['from']}]"
  71. )
  72. # Due to limitations in MySQL wrt unique_together and TextField (grrr),
  73. # we must use a CharField rather than TextField for message_id.
  74. # In the unlikeley event that we get a VERY long inbound
  75. # message_id, truncate it to the max_length of a MySQL CharField.
  76. original_message_id = message["message-id"]
  77. message_id = (
  78. (original_message_id[:252] + "...")
  79. if len(original_message_id) > 255
  80. else original_message_id
  81. )
  82. message_from = message["from"]
  83. text = message_text(message)
  84. related_messages, answer_thread = parse_references(task_list, message.get("references", ""))
  85. # find the most relevant task to add a comment on.
  86. # among tasks in the selected task list, find the task having the
  87. # most email comments the current message references
  88. best_task = (
  89. Task.objects.filter(task_list=task_list, comment__email_message_id__in=related_messages)
  90. .annotate(num_comments=Count("comment"))
  91. .order_by("-num_comments")
  92. .only("id")
  93. .first()
  94. )
  95. # if no related comment is found but a thread message-id
  96. # (generated by django-tickets) could be found, use it
  97. if best_task is None and answer_thread is not None:
  98. best_task = answer_thread
  99. with transaction.atomic():
  100. if best_task is None:
  101. best_task = Task.objects.create(
  102. priority=priority,
  103. title=format_task_title(task_title_format, message),
  104. task_list=task_list,
  105. created_by=match_user(message_from),
  106. )
  107. logger.info("using task: %r", best_task)
  108. comment, comment_created = Comment.objects.get_or_create(
  109. task=best_task,
  110. email_message_id=message_id,
  111. defaults={"email_from": message_from, "body": text},
  112. author=match_user(message_from), # tickets: Write test for this
  113. )
  114. logger.info("created comment: %r", comment)
  115. def tracker_consumer(
  116. producer, group=None, task_list_slug=None, priority=1, task_title_format="[MAIL] {subject}"
  117. ):
  118. task_list = TaskList.objects.get(group__name=group, slug=task_list_slug)
  119. for message in producer:
  120. try:
  121. insert_message(task_list, message, priority, task_title_format)
  122. except Exception:
  123. # ignore exceptions during insertion, in order to avoid
  124. logger.exception("got exception while inserting message")
  125. def match_user(email):
  126. """ This function takes an email and checks for a registered user."""
  127. if not settings.TICKETS_MAIL_USER_MAPPER:
  128. user = None
  129. else:
  130. try:
  131. # Find the first user that matches the email
  132. user = get_user_model().objects.get(email=parseaddr(email)[1])
  133. except get_user_model().DoesNotExist:
  134. user = None
  135. return user