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
|
from __future__ import unicode_literals
|
||||||
import datetime
|
import datetime
|
||||||
|
import textwrap
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
|
@ -32,7 +33,10 @@ class Task(models.Model):
|
||||||
completed = models.BooleanField(default=False)
|
completed = models.BooleanField(default=False)
|
||||||
completed_date = models.DateField(blank=True, null=True)
|
completed_date = models.DateField(blank=True, null=True)
|
||||||
created_by = models.ForeignKey(
|
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(
|
assigned_to = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
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.
|
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)
|
task = models.ForeignKey(Task, on_delete=models.CASCADE)
|
||||||
date = models.DateTimeField(default=datetime.datetime.now)
|
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)
|
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):
|
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
|
# 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):
|
def __str__(self):
|
||||||
return self.snippet()
|
return self.snippet()
|
||||||
|
|
|
@ -96,8 +96,7 @@
|
||||||
<h5>Comments on this task</h5>
|
<h5>Comments on this task</h5>
|
||||||
{% for comment in comment_list %}
|
{% for comment in comment_list %}
|
||||||
<p>
|
<p>
|
||||||
<strong>{{ comment.author.first_name }}
|
<strong>{{ comment.author_text }},
|
||||||
{{ comment.author.last_name }},
|
|
||||||
{{ comment.date|date:"F d Y P" }}
|
{{ comment.date|date:"F d Y P" }}
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue