Added notification threading.

Fixes #409.
remotes/origin/logger
Brett Profitt 2015-08-11 13:15:22 -04:00
parent b43775f644
commit fb710e2981
4 changed files with 157 additions and 2 deletions

View File

@ -25,3 +25,4 @@ answer newbie questions, and generally made taiga that much better:
- Julien Palard - Julien Palard
- Ricky Posner <e@eposner.com> - Ricky Posner <e@eposner.com>
- Yamila Moreno <yamila.moreno@kaleidos.net> - Yamila Moreno <yamila.moreno@kaleidos.net>
- Brett Profitt <brett.profitt@gmail.com>

View File

@ -15,6 +15,7 @@
- Project can be starred or unstarred and the fans list can be obtained. - Project can be starred or unstarred and the fans list can be obtained.
- Now users can watch public issues, tasks and user stories. - Now users can watch public issues, tasks and user stories.
- Add endpoints to show the watchers list for issues, tasks and user stories. - Add endpoints to show the watchers list for issues, tasks and user stories.
- Add headers to allow threading for notification emails about changes to issues, tasks, user stories, and wiki pages. (thanks to [@brett](https://github.com/brettp)).
- i18n. - i18n.
- Add polish (pl) translation. - Add polish (pl) translation.
- Add portuguese (Brazil) (pt_BR) translation. - Add portuguese (Brazil) (pt_BR) translation.

View File

@ -14,6 +14,8 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime
from functools import partial from functools import partial
from django.apps import apps from django.apps import apps
@ -256,7 +258,7 @@ def send_sync_notifications(notification_id):
""" """
notification = HistoryChangeNotification.objects.select_for_update().get(pk=notification_id) notification = HistoryChangeNotification.objects.select_for_update().get(pk=notification_id)
# If the las modification is too recent we ignore it # If the last modification is too recent we ignore it
now = timezone.now() now = timezone.now()
time_diff = now - notification.updated_datetime time_diff = now - notification.updated_datetime
if time_diff.seconds < settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL: if time_diff.seconds < settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL:
@ -275,11 +277,34 @@ def send_sync_notifications(notification_id):
model = get_model_from_key(notification.key) model = get_model_from_key(notification.key)
template_name = _resolve_template_name(model, change_type=notification.history_type) template_name = _resolve_template_name(model, change_type=notification.history_type)
email = _make_template_mail(template_name) email = _make_template_mail(template_name)
domain = settings.SITES["api"]["domain"].split(":")[0] or settings.SITES["api"]["domain"]
if "ref" in obj.snapshot:
msg_id = obj.snapshot["ref"]
elif "slug" in obj.snapshot:
msg_id = obj.snapshot["slug"]
else:
msg_id = 'taiga-system'
now = datetime.datetime.now()
format_args = {"project_slug": notification.project.slug,
"project_name": notification.project.name,
"msg_id": msg_id,
"time": int(now.timestamp()),
"domain": domain}
headers = {"Message-ID": "<{project_slug}/{msg_id}/{time}@{domain}>".format(**format_args),
"In-Reply-To": "<{project_slug}/{msg_id}@{domain}>".format(**format_args),
"References": "<{project_slug}/{msg_id}@{domain}>".format(**format_args),
"List-ID": 'Taiga/{project_name} <taiga.{project_slug}@{domain}>'.format(**format_args),
"Thread-Index": make_ms_thread_index("<{project_slug}/{msg_id}@{domain}>".format(**format_args), now)}
for user in notification.notify_users.distinct(): for user in notification.notify_users.distinct():
context["user"] = user context["user"] = user
context["lang"] = user.lang or settings.LANGUAGE_CODE context["lang"] = user.lang or settings.LANGUAGE_CODE
email.send(user.email, context) email.send(user.email, context, headers=headers)
notification.delete() notification.delete()
@ -367,3 +392,33 @@ def set_notify_policy(notify_policy, notify_level):
notify_policy.notify_level = notify_level notify_policy.notify_level = notify_level
notify_policy.save() notify_policy.save()
def make_ms_thread_index(msg_id, dt):
"""
Create the 22-byte base of the thread-index string in the format:
6 bytes = First 6 significant bytes of the FILETIME stamp
16 bytes = GUID (we're using a md5 hash of the message id)
See http://www.meridiandiscovery.com/how-to/e-mail-conversation-index-metadata-computer-forensics/
"""
import base64
import hashlib
import struct
# Convert to FILETIME epoch (microseconds since 1601)
delta = datetime.date(1970, 1, 1) - datetime.date(1601, 1, 1)
filetime = int(dt.timestamp() + delta.total_seconds()) * 10000000
# only want the first 6 bytes
thread_bin = struct.pack(">Q", filetime)[:6]
# Make a guid. This is usually generated by Outlook.
# The format is usually >IHHQ, but we don't care since it's just a hash of the id
md5 = hashlib.md5(msg_id.encode('utf-8'))
thread_bin += md5.digest()
# base64 encode
return base64.b64encode(thread_bin)

View File

@ -17,6 +17,13 @@
import pytest import pytest
import time import time
import math
import base64
import datetime
import hashlib
import binascii
import struct
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
@ -409,6 +416,63 @@ def test_send_notifications_using_services_method(settings, mail):
services.process_sync_notifications() services.process_sync_notifications()
assert len(mail.outbox) == 12 assert len(mail.outbox) == 12
# test headers
events = [issue, us, task, wiki]
domain = settings.SITES["api"]["domain"].split(":")[0] or settings.SITES["api"]["domain"]
i = 0
for msg in mail.outbox:
# each event has 3 msgs
event = events[math.floor(i / 3)]
# each set of 3 should have the same headers
if i % 3 == 0:
if hasattr(event, 'ref'):
e_slug = event.ref
elif hasattr(event, 'slug'):
e_slug = event.slug
else:
e_slug = 'taiga-system'
m_id = "{project_slug}/{msg_id}".format(
project_slug=project.slug,
msg_id=e_slug
)
message_id = "<{m_id}/".format(m_id=m_id)
message_id_domain = "@{domain}>".format(domain=domain)
in_reply_to = "<{m_id}@{domain}>".format(m_id=m_id, domain=domain)
list_id = "Taiga/{p_name} <taiga.{p_slug}@{domain}>" \
.format(p_name=project.name, p_slug=project.slug, domain=domain)
assert msg.extra_headers
headers = msg.extra_headers
# can't test the time part because it's set when sending
# check what we can
assert 'Message-ID' in headers
assert message_id in headers.get('Message-ID')
assert message_id_domain in headers.get('Message-ID')
assert 'In-Reply-To' in headers
assert in_reply_to == headers.get('In-Reply-To')
assert 'References' in headers
assert in_reply_to == headers.get('References')
assert 'List-ID' in headers
assert list_id == headers.get('List-ID')
assert 'Thread-Index' in headers
# always is b64 encoded 22 bytes
assert len(base64.b64decode(headers.get('Thread-Index'))) == 22
# hashes should match for identical ids and times
# we check the actual method in test_ms_thread_id()
msg_time = headers.get('Message-ID').split('/')[2].split('@')[0]
msg_ts = datetime.datetime.fromtimestamp(int(msg_time))
assert services.make_ms_thread_index(in_reply_to, msg_ts) == headers.get('Thread-Index')
i += 1
def test_resource_notification_test(client, settings, mail): def test_resource_notification_test(client, settings, mail):
settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL = 1 settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL = 1
@ -613,3 +677,37 @@ def test_retrieve_notify_policies_by_anonymous_user(client):
response = client.get(url, content_type="application/json") response = client.get(url, content_type="application/json")
assert response.status_code == 404, response.status_code assert response.status_code == 404, response.status_code
assert response.data["_error_message"] == "No NotifyPolicy matches the given query.", str(response.content) assert response.data["_error_message"] == "No NotifyPolicy matches the given query.", str(response.content)
def test_ms_thread_id():
id = '<test/message@localhost>'
now = datetime.datetime.now()
index = services.make_ms_thread_index(id, now)
parsed = parse_ms_thread_index(index)
assert parsed[0] == hashlib.md5(id.encode('utf-8')).hexdigest()
# always only one time
assert (now - parsed[1][0]).seconds <= 2
# see http://stackoverflow.com/questions/27374077/parsing-thread-index-mail-header-with-python
def parse_ms_thread_index(index):
s = base64.b64decode(index)
# ours are always md5 digests
guid = binascii.hexlify(s[6:22]).decode('utf-8')
# if we had real guids, we'd do something like
# guid = struct.unpack('>IHHQ', s[6:22])
# guid = '%08X-%04X-%04X-%04X-%12X' % (guid[0], guid[1], guid[2], (guid[3] >> 48) & 0xFFFF, guid[3] & 0xFFFFFFFFFFFF)
f = struct.unpack('>Q', s[:6] + b'\0\0')[0]
ts = [datetime.datetime(1601, 1, 1) + datetime.timedelta(microseconds=f//10)]
# for the 5 byte appendixes that we won't use
for n in range(22, len(s), 5):
f = struct.unpack('>I', s[n:n+4])[0]
ts.append(ts[-1] + datetime.timedelta(microseconds=(f << 18)//10))
return guid, ts