Implement mail tracking

Signed-off-by: Victor "multun" Collod <victor.collod@prologin.org>
This commit is contained in:
Victor "multun" Collod 2018-12-19 23:27:22 +01:00
parent d0212b8a55
commit 99f4e34203
9 changed files with 327 additions and 5 deletions

0
todo/mail/__init__.py Normal file
View file

View file

@ -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

View file

@ -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")

View file

@ -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

100
todo/mail/producers/imap.py Normal file
View file

@ -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)

View file

@ -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())

View file

@ -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")}
),
]

View file

@ -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()

View file

@ -96,8 +96,7 @@
<h5>Comments on this task</h5>
{% for comment in comment_list %}
<p>
<strong>{{ comment.author.first_name }}
{{ comment.author.last_name }},
<strong>{{ comment.author_text }},
{{ comment.date|date:"F d Y P" }}
</strong>
</p>