Implement mail tracking
Signed-off-by: Victor "multun" Collod <victor.collod@prologin.org>
This commit is contained in:
parent
d0212b8a55
commit
99f4e34203
9 changed files with 327 additions and 5 deletions
0
todo/mail/__init__.py
Normal file
0
todo/mail/__init__.py
Normal file
9
todo/mail/consumers/__init__.py
Normal file
9
todo/mail/consumers/__init__.py
Normal 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
|
102
todo/mail/consumers/tracker.py
Normal file
102
todo/mail/consumers/tracker.py
Normal 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")
|
9
todo/mail/producers/__init__.py
Normal file
9
todo/mail/producers/__init__.py
Normal 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
100
todo/mail/producers/imap.py
Normal 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)
|
32
todo/management/commands/mail_worker.py
Normal file
32
todo/management/commands/mail_worker.py
Normal 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())
|
46
todo/migrations/0008_mail_tracker.py
Normal file
46
todo/migrations/0008_mail_tracker.py
Normal 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")}
|
||||
),
|
||||
]
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue