diff --git a/todo/mail/__init__.py b/todo/mail/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/todo/mail/consumers/__init__.py b/todo/mail/consumers/__init__.py new file mode 100644 index 0000000..ea1f7d0 --- /dev/null +++ b/todo/mail/consumers/__init__.py @@ -0,0 +1,9 @@ +def tracker_consumer(**kwargs): + def tracker_factory(producer): + # the import needs to be delayed until call to enable + # using the wrapper in the django settings + from .tracker import tracker_consumer + + return tracker_consumer(producer, **kwargs) + + return tracker_factory diff --git a/todo/mail/consumers/tracker.py b/todo/mail/consumers/tracker.py new file mode 100644 index 0000000..f01650d --- /dev/null +++ b/todo/mail/consumers/tracker.py @@ -0,0 +1,102 @@ +import logging + +from django.db import transaction +from django.db.models import Count +from email.charset import Charset as EMailCharset +from html2text import html2text +from todo.models import Comment, Task, TaskList + +logger = logging.getLogger(__name__) + + +def part_decode(message): + charset = ("ascii", "ignore") + email_charset = message.get_content_charset() + if email_charset: + charset = (EMailCharset(email_charset).input_charset,) + + body = message.get_payload(decode=True) + return body.decode(*charset) + + +def message_find_mime(message, mime_type): + for submessage in message.walk(): + if submessage.get_content_type() == mime_type: + return submessage + + +def message_text(message): + text_part = message_find_mime(message, "text/plain") + if text_part is not None: + return part_decode(text_part) + + html_part = message_find_mime(message, "text/html") + if html_part is not None: + return html2text(part_decode(html_part)) + + return "" + + +def insert_message(task_list, message, priority): + if "message-id" not in message: + logger.warning("missing message id, ignoring message") + return + + if "from" not in message: + logger.warning('missing "From" header, ignoring message') + return + + if "subject" not in message: + logger.warning('missing "Subject" header, ignoring message') + return + + logger.info( + "received message:\t" + f"[Subject: {message['subject']}]\t" + f"[Message-ID: {message['message-id']}]\t" + f"[To: {message['to']}]\t" + f"[From: {message['from']}]" + ) + + message_id = message["message-id"] + message_from = message["from"] + text = message_text(message) + + related_messages = message.get("references", "").split() + + # find the most relevant task to add a comment on. + # among tasks in the selected task list, find the task having the + # most email comments the current message references + best_task = ( + Task.objects.filter( + task_list=task_list, comment__email_message_id__in=related_messages + ) + .annotate(num_comments=Count("comment")) + .order_by("-num_comments") + .only("id") + .first() + ) + + with transaction.atomic(): + if best_task is None: + best_task = Task.objects.create( + priority=priority, title=message["subject"], task_list=task_list + ) + logger.info(f"using task: {repr(best_task)}") + + comment, comment_created = Comment.objects.get_or_create( + task=best_task, + email_message_id=message_id, + defaults={"email_from": message_from, "body": text}, + ) + logger.info(f"created comment: {repr(comment)}") + + +def tracker_consumer(producer, group=None, task_list_slug=None, priority=1): + task_list = TaskList.objects.get(group__name=group, slug=task_list_slug) + for message in producer: + try: + insert_message(task_list, message, priority) + except Exception: + # ignore exceptions during insertion, in order to avoid + logger.exception("got exception while inserting message") diff --git a/todo/mail/producers/__init__.py b/todo/mail/producers/__init__.py new file mode 100644 index 0000000..8ff313b --- /dev/null +++ b/todo/mail/producers/__init__.py @@ -0,0 +1,9 @@ +def imap_producer(**kwargs): + def imap_producer_factory(): + # the import needs to be delayed until call to enable + # using the wrapper in the django settings + from .imap import imap_producer + + return imap_producer(**kwargs) + + return imap_producer_factory diff --git a/todo/mail/producers/imap.py b/todo/mail/producers/imap.py new file mode 100644 index 0000000..a48cd9c --- /dev/null +++ b/todo/mail/producers/imap.py @@ -0,0 +1,100 @@ +import email +import email.parser +import imaplib +import logging +import time + +from email.policy import default +from contextlib import contextmanager + +logger = logging.getLogger(__name__) + + +def imap_check(command_tuple): + status, ids = command_tuple + assert status == "OK", ids + + +@contextmanager +def imap_connect(host, port, user, password): + conn = imaplib.IMAP4_SSL(host=host, port=port) + conn.login(user, password) + imap_check(conn.list()) + try: + yield conn + finally: + conn.close() + + +def parse_message(message): + for response_part in message: + if not isinstance(response_part, tuple): + continue + + message_metadata, message_content = response_part + email_parser = email.parser.BytesFeedParser(policy=default) + email_parser.feed(message_content) + return email_parser.close() + + +def search_message(conn, *filters): + status, message_ids = conn.search(None, *filters) + for message_id in message_ids[0].split(): + status, message = conn.fetch(message_id, "(RFC822)") + yield message_id, parse_message(message) + + +def imap_producer( + process_all=False, + preserve=False, + host=None, + port=993, + user=None, + password=None, + nap_duration=1, + input_folder="INBOX", +): + logger.debug("starting IMAP worker") + imap_filter = "(ALL)" if process_all else "(UNSEEN)" + + def process_batch(): + logger.debug("starting to process batch") + # reconnect each time to avoid repeated failures due to a lost connection + with imap_connect(host, port, user, password) as conn: + # select the requested folder + imap_check(conn.select(input_folder, readonly=False)) + + try: + for message_uid, message in search_message(conn, imap_filter): + logger.info(f"received message {message_uid}") + try: + yield message + except Exception: + logger.exception( + f"something went wrong while processing {message_uid}" + ) + raise + + if not preserve: + # tag the message for deletion + conn.uid("STORE", message_uid, "+FLAGS", "(\\Deleted)") + else: + logger.debug("did not receive any message") + finally: + if not preserve: + # flush deleted messages + conn.expunge() + + while True: + try: + yield from process_batch() + except (GeneratorExit, KeyboardInterrupt): + # the generator was closed, due to the consumer + # breaking out of the loop, or an exception occuring + raise + except Exception: + logger.exception("mail fetching went wrong, retrying") + + # sleep to avoid using too much resources + # TODO: get notified when a new message arrives + time.sleep(nap_duration) diff --git a/todo/management/commands/mail_worker.py b/todo/management/commands/mail_worker.py new file mode 100644 index 0000000..e096d05 --- /dev/null +++ b/todo/management/commands/mail_worker.py @@ -0,0 +1,32 @@ +import logging +import sys + +from django.core.management.base import BaseCommand, CommandError +from django.conf import settings + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Starts a mail worker" + + def add_arguments(self, parser): + parser.add_argument("worker_name") + + def handle(self, *args, **options): + if not hasattr(settings, "TODO_MAIL_TRACKERS"): + logger.error("missing TODO_MAIL_TRACKERS setting") + sys.exit(1) + + worker_name = options["worker_name"] + tracker = settings.TODO_MAIL_TRACKERS.get(worker_name, None) + if tracker is None: + logger.error( + f"couldn't find configuration for {worker_name} in TODO_MAIL_TRACKERS" + ) + sys.exit(1) + + producer = tracker["producer"] + consumer = tracker["consumer"] + + consumer(producer()) diff --git a/todo/migrations/0008_mail_tracker.py b/todo/migrations/0008_mail_tracker.py new file mode 100644 index 0000000..ea7a484 --- /dev/null +++ b/todo/migrations/0008_mail_tracker.py @@ -0,0 +1,46 @@ +# Generated by Django 2.1.4 on 2018-12-21 14:01 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [("todo", "0007_auto_update_created_date")] + + operations = [ + migrations.AddField( + model_name="comment", + name="email_from", + field=models.CharField(blank=True, max_length=320, null=True), + ), + migrations.AddField( + model_name="comment", + name="email_message_id", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="comment", + name="author", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="task", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="todo_created_by", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterUniqueTogether( + name="comment", unique_together={("task", "email_message_id")} + ), + ] diff --git a/todo/models.py b/todo/models.py index 89a1837..cc67d7f 100644 --- a/todo/models.py +++ b/todo/models.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals import datetime +import textwrap from django.conf import settings from django.contrib.auth.models import Group @@ -32,7 +33,10 @@ class Task(models.Model): completed = models.BooleanField(default=False) completed_date = models.DateField(blank=True, null=True) created_by = models.ForeignKey( - settings.AUTH_USER_MODEL, related_name="todo_created_by", on_delete=models.CASCADE + settings.AUTH_USER_MODEL, + null=True, + related_name="todo_created_by", + on_delete=models.CASCADE, ) assigned_to = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -73,14 +77,35 @@ class Comment(models.Model): a comment and change task details at the same time. Rolling our own since it's easy. """ - author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + author = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True + ) task = models.ForeignKey(Task, on_delete=models.CASCADE) date = models.DateTimeField(default=datetime.datetime.now) + email_from = models.CharField(max_length=320, blank=True, null=True) + email_message_id = models.TextField(blank=True, null=True) + body = models.TextField(blank=True) + class Meta: + # an email should only appear once per task + unique_together = ("task", "email_message_id") + + @property + def author_text(self): + if self.author is not None: + return str(self.author) + + assert self.email_message_id is not None + return str(self.email_from) + + @property def snippet(self): + body_snippet = textwrap.shorten(self.body, width=35, placeholder="...") # Define here rather than in __str__ so we can use it in the admin list_display - return "{author} - {snippet}...".format(author=self.author, snippet=self.body[:35]) + return "{author} - {snippet}...".format( + author=self.author_text, snippet=body_snippet + ) def __str__(self): return self.snippet() diff --git a/todo/templates/todo/task_detail.html b/todo/templates/todo/task_detail.html index eddb130..b126909 100644 --- a/todo/templates/todo/task_detail.html +++ b/todo/templates/todo/task_detail.html @@ -96,8 +96,7 @@
Comments on this task
{% for comment in comment_list %}

- {{ comment.author.first_name }} - {{ comment.author.last_name }}, + {{ comment.author_text }}, {{ comment.date|date:"F d Y P" }}