django-mail-analytics 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_mail_analytics/__init__.py +17 -0
- django_mail_analytics/admin.py +158 -0
- django_mail_analytics/apps.py +14 -0
- django_mail_analytics/mail.py +135 -0
- django_mail_analytics/metadata.py +10 -0
- django_mail_analytics/migrations/0001_initial.py +44 -0
- django_mail_analytics/migrations/__init__.py +0 -0
- django_mail_analytics/models.py +53 -0
- django_mail_analytics/templates/django_mail_analytics/mail_list.html +52 -0
- django_mail_analytics/tests.py +3 -0
- django_mail_analytics/urls.py +13 -0
- django_mail_analytics/views.py +67 -0
- django_mail_analytics-1.0.0.dist-info/LICENSE.txt +21 -0
- django_mail_analytics-1.0.0.dist-info/METADATA +195 -0
- django_mail_analytics-1.0.0.dist-info/RECORD +17 -0
- django_mail_analytics-1.0.0.dist-info/WHEEL +5 -0
- django_mail_analytics-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from django_mail_analytics.metadata import (
|
|
2
|
+
__author__,
|
|
3
|
+
__copyright__,
|
|
4
|
+
__description__,
|
|
5
|
+
__license__,
|
|
6
|
+
__title__,
|
|
7
|
+
__version__,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"__author__",
|
|
12
|
+
"__copyright__",
|
|
13
|
+
"__description__",
|
|
14
|
+
"__license__",
|
|
15
|
+
"__title__",
|
|
16
|
+
"__version__",
|
|
17
|
+
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
from django.db.models import Count, Q
|
|
3
|
+
from django.utils.safestring import mark_safe
|
|
4
|
+
|
|
5
|
+
from .models import Mail, MailRecipient, MailRecipientAction
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MailRecipientActionInline(admin.TabularInline):
|
|
9
|
+
model = MailRecipientAction
|
|
10
|
+
fields = ("pk", "created", "action")
|
|
11
|
+
readonly_fields = ("pk", "created", "action")
|
|
12
|
+
can_delete = False
|
|
13
|
+
extra = 0
|
|
14
|
+
max_num = 0
|
|
15
|
+
show_change_link = True
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@admin.register(MailRecipientAction)
|
|
19
|
+
class MailRecipientActionAdmin(admin.ModelAdmin):
|
|
20
|
+
"""Define admin model for MailRecipientAction model."""
|
|
21
|
+
|
|
22
|
+
list_display = ("id", "created", "action", "recipient_email", "recipient_mail_key")
|
|
23
|
+
readonly_fields = ("id", "created", "recipient", "action")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MailRecipientInline(admin.TabularInline):
|
|
27
|
+
model = MailRecipient
|
|
28
|
+
fields = ("pk", "created", "email", "sent", "opened", "openings", "clicks")
|
|
29
|
+
readonly_fields = ("pk", "created", "email", "sent", "opened", "openings", "clicks")
|
|
30
|
+
can_delete = False
|
|
31
|
+
extra = 0
|
|
32
|
+
max_num = 0
|
|
33
|
+
show_change_link = True
|
|
34
|
+
|
|
35
|
+
def get_queryset(self, request):
|
|
36
|
+
opening = Q(actions__action="")
|
|
37
|
+
hasAction = Q(actions__isnull=False)
|
|
38
|
+
qs = super().get_queryset(request)
|
|
39
|
+
qs = qs.annotate(opened=Count("id", filter=hasAction & opening, distinct=True))
|
|
40
|
+
qs = qs.annotate(sent=Count("id", distinct=True))
|
|
41
|
+
qs = qs.annotate(openings=Count("actions", filter=opening))
|
|
42
|
+
qs = qs.annotate(clicks=Count("actions", filter=~opening))
|
|
43
|
+
return qs
|
|
44
|
+
|
|
45
|
+
@admin.display(description="Opened")
|
|
46
|
+
def opened(self, instance):
|
|
47
|
+
return instance.opened
|
|
48
|
+
|
|
49
|
+
@admin.display(description="Sent")
|
|
50
|
+
def sent(self, instance):
|
|
51
|
+
return instance.sent
|
|
52
|
+
|
|
53
|
+
@admin.display(description="Openings")
|
|
54
|
+
def openings(self, instance):
|
|
55
|
+
return instance.openings
|
|
56
|
+
|
|
57
|
+
@admin.display(description="Clicks")
|
|
58
|
+
def clicks(self, instance):
|
|
59
|
+
return instance.clicks
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@admin.register(MailRecipient)
|
|
63
|
+
class MailRecipientAdmin(admin.ModelAdmin):
|
|
64
|
+
"""Define admin model for MailRecipient model."""
|
|
65
|
+
|
|
66
|
+
def get_queryset(self, request):
|
|
67
|
+
opening = Q(actions__action="")
|
|
68
|
+
hasAction = Q(actions__isnull=False)
|
|
69
|
+
|
|
70
|
+
qs = super().get_queryset(request)
|
|
71
|
+
qs = qs.annotate(opened=Count("id", filter=hasAction & opening, distinct=True))
|
|
72
|
+
qs = qs.annotate(sent=Count("id", distinct=True))
|
|
73
|
+
qs = qs.annotate(openings=Count("actions", filter=opening))
|
|
74
|
+
qs = qs.annotate(clicks=Count("actions", filter=~opening))
|
|
75
|
+
return qs
|
|
76
|
+
|
|
77
|
+
@admin.display(description="Success Rate")
|
|
78
|
+
def rate(self, instance):
|
|
79
|
+
return f"{int(100*(instance.opened/instance.sent))}%"
|
|
80
|
+
|
|
81
|
+
@admin.display(description="Opened")
|
|
82
|
+
def opened(self, instance):
|
|
83
|
+
return instance.opened
|
|
84
|
+
|
|
85
|
+
@admin.display(description="Sent")
|
|
86
|
+
def sent(self, instance):
|
|
87
|
+
return instance.sent
|
|
88
|
+
|
|
89
|
+
@admin.display(description="Openings")
|
|
90
|
+
def openings(self, instance):
|
|
91
|
+
return instance.openings
|
|
92
|
+
|
|
93
|
+
@admin.display(description="Clicks")
|
|
94
|
+
def clicks(self, instance):
|
|
95
|
+
return instance.clicks
|
|
96
|
+
|
|
97
|
+
list_display = ("id", "created", "mail_key", "email", "rate", "opened", "sent", "openings", "clicks")
|
|
98
|
+
readonly_fields = (
|
|
99
|
+
"id",
|
|
100
|
+
"mail",
|
|
101
|
+
"recipient",
|
|
102
|
+
)
|
|
103
|
+
inlines = [
|
|
104
|
+
MailRecipientActionInline,
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@admin.register(Mail)
|
|
109
|
+
class MailAdmin(admin.ModelAdmin):
|
|
110
|
+
"""Define admin model for Mail model."""
|
|
111
|
+
|
|
112
|
+
def get_queryset(self, request):
|
|
113
|
+
opening = Q(recipients__actions__action="")
|
|
114
|
+
hasAction = Q(recipients__actions__isnull=False)
|
|
115
|
+
|
|
116
|
+
qs = super().get_queryset(request)
|
|
117
|
+
qs = qs.annotate(opened=Count("recipients", filter=hasAction & opening, distinct=True))
|
|
118
|
+
qs = qs.annotate(sent=Count("recipients", distinct=True))
|
|
119
|
+
qs = qs.annotate(openings=Count("recipients__actions", filter=opening))
|
|
120
|
+
qs = qs.annotate(clicks=Count("recipients__actions", filter=~opening))
|
|
121
|
+
return qs
|
|
122
|
+
|
|
123
|
+
def body_html(self, obj):
|
|
124
|
+
return mark_safe(obj.body)
|
|
125
|
+
|
|
126
|
+
@admin.display(description="Success Rate")
|
|
127
|
+
def rate(self, instance):
|
|
128
|
+
return f"{int(100*(instance.opened/instance.sent))}%"
|
|
129
|
+
|
|
130
|
+
@admin.display(description="Opened")
|
|
131
|
+
def opened(self, instance):
|
|
132
|
+
return instance.opened
|
|
133
|
+
|
|
134
|
+
@admin.display(description="Sent")
|
|
135
|
+
def sent(self, instance):
|
|
136
|
+
return instance.sent
|
|
137
|
+
|
|
138
|
+
@admin.display(description="Openings")
|
|
139
|
+
def openings(self, instance):
|
|
140
|
+
return instance.openings
|
|
141
|
+
|
|
142
|
+
@admin.display(description="Clicks")
|
|
143
|
+
def clicks(self, instance):
|
|
144
|
+
return instance.clicks
|
|
145
|
+
|
|
146
|
+
exclude = ("body",)
|
|
147
|
+
readonly_fields = (
|
|
148
|
+
"id",
|
|
149
|
+
"key",
|
|
150
|
+
"sender",
|
|
151
|
+
"subject",
|
|
152
|
+
"body_html",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
list_display = ("id", "created", "key", "subject", "rate", "opened", "sent", "openings", "clicks")
|
|
156
|
+
inlines = [
|
|
157
|
+
MailRecipientInline,
|
|
158
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DjangoMailAnalyticsConfig(AppConfig):
|
|
5
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
+
name = "django_mail_analytics"
|
|
7
|
+
|
|
8
|
+
def ready(self):
|
|
9
|
+
# pylint: disable=import-outside-toplevel
|
|
10
|
+
|
|
11
|
+
# monky patch mail functions
|
|
12
|
+
from .mail import send
|
|
13
|
+
|
|
14
|
+
_ = send # just there to avoid autoremoval of the import
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# import django.core.mail as mail
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from functools import cache
|
|
6
|
+
|
|
7
|
+
import wrapt
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
from django.urls import reverse
|
|
10
|
+
from django.utils.http import urlencode
|
|
11
|
+
from hashids import Hashids
|
|
12
|
+
|
|
13
|
+
from .models import Mail, MailRecipient, MailRecipientAction
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@cache
|
|
19
|
+
def mail_settings() -> dict:
|
|
20
|
+
default_settings = getattr(settings, "MAIL_ANALYTICS", {})
|
|
21
|
+
if "SALT" not in default_settings:
|
|
22
|
+
default_settings["SALT"] = getattr(settings, "SECRET_KEY", "salt")
|
|
23
|
+
if "LENGTH" not in default_settings:
|
|
24
|
+
default_settings["LENGTH"] = 6
|
|
25
|
+
return default_settings
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@cache
|
|
29
|
+
def get_hasher():
|
|
30
|
+
salt = mail_settings()["SALT"]
|
|
31
|
+
length = mail_settings()["LENGTH"]
|
|
32
|
+
return Hashids(salt=salt, min_length=length)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_pixel_tag(q):
|
|
36
|
+
scheme = mail_settings()["SCHEME"]
|
|
37
|
+
domain = mail_settings()["DOMAIN"]
|
|
38
|
+
uri = reverse("mail_pixel")
|
|
39
|
+
qp = urlencode({"q": get_hasher().encode(q)})
|
|
40
|
+
url = f"{scheme}://{domain}{uri}?{qp}"
|
|
41
|
+
return f"""<img src="{url}" height="0px" width="0px"/>"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_proxy_url(q, url):
|
|
45
|
+
scheme = mail_settings()["SCHEME"]
|
|
46
|
+
domain = mail_settings()["DOMAIN"]
|
|
47
|
+
if url.startswith(f"{scheme}://{domain}"):
|
|
48
|
+
url = url.replace(f"{scheme}://{domain}", "")
|
|
49
|
+
|
|
50
|
+
uri = reverse("mail_proxy")
|
|
51
|
+
qp = urlencode({"q": get_hasher().encode(q), "u": url})
|
|
52
|
+
return f"{scheme}://{domain}{uri}?{qp}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def replace_href_by_proxy(q, href_match):
|
|
56
|
+
url = href_match.group(1)
|
|
57
|
+
proxy = get_proxy_url(q, url)
|
|
58
|
+
return f'href="{proxy}"'
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@wrapt.patch_function_wrapper("django.core.mail", "EmailMessage.send")
|
|
62
|
+
def send(wrapped, instance, args, kwargs):
|
|
63
|
+
def get_mailId(instance, html_message, tracker):
|
|
64
|
+
subject = instance.subject
|
|
65
|
+
from_email = instance.from_email
|
|
66
|
+
recipient_list = instance.recipients()
|
|
67
|
+
|
|
68
|
+
mail, _ = Mail.objects.update_or_create(
|
|
69
|
+
key=tracker or subject[:25],
|
|
70
|
+
date=datetime.now().date(),
|
|
71
|
+
create_defaults=dict(
|
|
72
|
+
sender=from_email,
|
|
73
|
+
subject=subject[:2048],
|
|
74
|
+
body=html_message,
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
mailRecipient, _ = MailRecipient.objects.update_or_create(
|
|
79
|
+
mail=mail, recipient=",".join(recipient_list)
|
|
80
|
+
)
|
|
81
|
+
return mail.id, mailRecipient.id
|
|
82
|
+
|
|
83
|
+
mailId, mailRId = 0, 0
|
|
84
|
+
|
|
85
|
+
# check for tracker code from email recipent list
|
|
86
|
+
trackers = [x.rsplit("@", 1)[0] for x in instance.to if x.lower().endswith("@dma")]
|
|
87
|
+
instance.to = [x for x in instance.to if not x.lower().endswith("@dma")]
|
|
88
|
+
|
|
89
|
+
tracker = trackers[-1] if trackers else None
|
|
90
|
+
if hasattr(instance, "alternatives"):
|
|
91
|
+
for I, x in enumerate(instance.alternatives):
|
|
92
|
+
alternative, mime_type = x
|
|
93
|
+
if mime_type == "text/html" and alternative:
|
|
94
|
+
html_message = alternative
|
|
95
|
+
|
|
96
|
+
if not mailId or not mailRId:
|
|
97
|
+
mailId, mailRId = get_mailId(instance, html_message, tracker)
|
|
98
|
+
|
|
99
|
+
if "</body>" in html_message:
|
|
100
|
+
html_message = html_message.replace(
|
|
101
|
+
"</body>", f"{get_pixel_tag(mailRId)}\n</body>"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# check if there tags with href attributes and replace them by a proxy
|
|
105
|
+
def sub_replacor(href_match, mailRId=mailRId):
|
|
106
|
+
return replace_href_by_proxy(mailRId, href_match)
|
|
107
|
+
|
|
108
|
+
html_message, _ = re.subn(
|
|
109
|
+
'''href="(.*?)"''', sub_replacor, html_message
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
instance.alternatives[I] = (html_message, mime_type)
|
|
113
|
+
|
|
114
|
+
return wrapped(*args, **kwargs)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def register_action(q, url=""):
|
|
118
|
+
if q is None:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
# decode query
|
|
123
|
+
decoded = get_hasher().decode(q)
|
|
124
|
+
assert decoded
|
|
125
|
+
q = decoded[0]
|
|
126
|
+
|
|
127
|
+
exists = await MailRecipient.objects.filter(id=q).aexists()
|
|
128
|
+
if not exists:
|
|
129
|
+
logging.warning(f"ignoring unexistant recipient_id {q}")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
await MailRecipientAction.objects.acreate(recipient_id=q, action=url)
|
|
133
|
+
|
|
134
|
+
except Exception as e:
|
|
135
|
+
logging.error("ERROR", type(e), e.__dict__)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
__author__ = "Loic Quertenmont"
|
|
2
|
+
__copyright__ = "Copyright (c) 2014-present Loic Quertenmont"
|
|
3
|
+
__description__ = (
|
|
4
|
+
"django package adding email tracking features."
|
|
5
|
+
"Both email opening and link clicking are tracked."
|
|
6
|
+
)
|
|
7
|
+
__email__ = "loic@deeperanalytics.be"
|
|
8
|
+
__license__ = "MIT"
|
|
9
|
+
__title__ = "django-mail-analytics"
|
|
10
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Generated by Django 5.1 on 2024-10-03 14:41
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
initial = True
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
operations = [
|
|
15
|
+
migrations.CreateModel(
|
|
16
|
+
name='Mail',
|
|
17
|
+
fields=[
|
|
18
|
+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
|
19
|
+
('key', models.CharField(db_index=True, max_length=128)),
|
|
20
|
+
('created', models.DateTimeField(auto_now_add=True)),
|
|
21
|
+
('sender', models.CharField(max_length=2048, null=True)),
|
|
22
|
+
('subject', models.CharField(max_length=2048, null=True)),
|
|
23
|
+
('body', models.TextField(blank=True, null=True)),
|
|
24
|
+
('date', models.DateField(editable=False)),
|
|
25
|
+
],
|
|
26
|
+
),
|
|
27
|
+
migrations.CreateModel(
|
|
28
|
+
name='MailRecipient',
|
|
29
|
+
fields=[
|
|
30
|
+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
|
31
|
+
('recipient', models.CharField(max_length=2048)),
|
|
32
|
+
('mail', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recipients', to='django_mail_analytics.mail')),
|
|
33
|
+
],
|
|
34
|
+
),
|
|
35
|
+
migrations.CreateModel(
|
|
36
|
+
name='MailRecipientAction',
|
|
37
|
+
fields=[
|
|
38
|
+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
|
39
|
+
('created', models.DateTimeField(auto_now_add=True)),
|
|
40
|
+
('action', models.CharField(max_length=2048)),
|
|
41
|
+
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to='django_mail_analytics.mailrecipient')),
|
|
42
|
+
],
|
|
43
|
+
),
|
|
44
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Mail(models.Model):
|
|
5
|
+
id = models.AutoField(primary_key=True, auto_created=True)
|
|
6
|
+
key = models.CharField(null=False, max_length=128, db_index=True)
|
|
7
|
+
date = models.DateField(null=False, editable=False)
|
|
8
|
+
created = models.DateTimeField(auto_now_add=True, editable=False)
|
|
9
|
+
sender = models.CharField(null=True, max_length=2048) # noqa: DJ001
|
|
10
|
+
subject = models.CharField(null=True, max_length=2048) # noqa: DJ001
|
|
11
|
+
body = models.TextField(null=True, blank=True) # noqa: DJ001
|
|
12
|
+
|
|
13
|
+
def __str__(self):
|
|
14
|
+
return f"Mail-{self.pk}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MailRecipient(models.Model):
|
|
18
|
+
id = models.AutoField(primary_key=True, auto_created=True)
|
|
19
|
+
mail = models.ForeignKey(Mail, on_delete=models.CASCADE, null=False, related_name="recipients")
|
|
20
|
+
recipient = models.CharField(null=False, max_length=2048)
|
|
21
|
+
|
|
22
|
+
def __str__(self):
|
|
23
|
+
return f"MailRecipient-{self.pk}"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def email(self):
|
|
27
|
+
return self.recipient
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def created(self):
|
|
31
|
+
return self.mail.created
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def mail_key(self):
|
|
35
|
+
return self.mail.key
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MailRecipientAction(models.Model):
|
|
39
|
+
id = models.AutoField(primary_key=True, auto_created=True)
|
|
40
|
+
recipient = models.ForeignKey(MailRecipient, on_delete=models.CASCADE, null=False, related_name="actions")
|
|
41
|
+
created = models.DateTimeField(auto_now_add=True, editable=False)
|
|
42
|
+
action = models.CharField(null=False, max_length=2048)
|
|
43
|
+
|
|
44
|
+
def __str__(self):
|
|
45
|
+
return f"MailRecipientAction-{self.pk}"
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def recipient_email(self):
|
|
49
|
+
return self.recipient.recipient
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def recipient_mail_key(self):
|
|
53
|
+
return self.recipient.mail.key
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<style type="text/css">
|
|
2
|
+
tr:first-child > th {
|
|
3
|
+
border-bottom: 1px solid black;
|
|
4
|
+
}
|
|
5
|
+
{% comment %} tr:last-child > td {
|
|
6
|
+
border-bottom: 1px solid black;
|
|
7
|
+
} {% endcomment %}
|
|
8
|
+
</style>
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
<table width="100%" style="text-align:right; padding-inline:2em; border-spacing: 0.9em; ">
|
|
13
|
+
<tr width="100%">
|
|
14
|
+
<th>Date</th>
|
|
15
|
+
<th>Key</th>
|
|
16
|
+
<th>Subject</th>
|
|
17
|
+
<th>Success Rate</th>
|
|
18
|
+
<th>#Opened</th>
|
|
19
|
+
<th>#Openings</th>
|
|
20
|
+
<th>#Clicks</th>
|
|
21
|
+
</tr>
|
|
22
|
+
{% for mail in page_obj %}
|
|
23
|
+
<tr>
|
|
24
|
+
<td>{{ mail.created }}</td>
|
|
25
|
+
<td>{{ mail.key|upper }}</td>
|
|
26
|
+
<td>{{ mail.subject }}</td>
|
|
27
|
+
<td>{{ mail.rate|stringformat:"d%%" }}</td>
|
|
28
|
+
<td>{{ mail.opened }} / {{ mail.sent }}</td>
|
|
29
|
+
<td>{{ mail.openings }}</td>
|
|
30
|
+
<td>{{ mail.clicks }}</td>
|
|
31
|
+
</tr>
|
|
32
|
+
{% endfor %}
|
|
33
|
+
</table>
|
|
34
|
+
|
|
35
|
+
<br/>
|
|
36
|
+
<div class="pagination" style="text-align:center;">
|
|
37
|
+
<span class="step-links">
|
|
38
|
+
{% if page_obj.has_previous %}
|
|
39
|
+
<a href="?page=1">« first</a>
|
|
40
|
+
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
|
|
41
|
+
{% endif %}
|
|
42
|
+
|
|
43
|
+
<span class="current">
|
|
44
|
+
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}.
|
|
45
|
+
</span>
|
|
46
|
+
|
|
47
|
+
{% if page_obj.has_next %}
|
|
48
|
+
<a href="?page={{ page_obj.next_page_number }}">next</a>
|
|
49
|
+
<a href="?page={{ page_obj.paginator.num_pages }}">last »</a>
|
|
50
|
+
{% endif %}
|
|
51
|
+
</span>
|
|
52
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module define url patterns for this app
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from django.urls import re_path
|
|
6
|
+
|
|
7
|
+
from . import views
|
|
8
|
+
|
|
9
|
+
urlpatterns = [
|
|
10
|
+
re_path(r"^i$", views.pixel, name="mail_pixel"),
|
|
11
|
+
re_path(r"^p$", views.proxy, name="mail_proxy"),
|
|
12
|
+
re_path(r"^l$", views.MailListView.as_view(), name="mail_list"),
|
|
13
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Create your views here.
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from django.contrib.auth.mixins import PermissionRequiredMixin
|
|
5
|
+
from django.db.models import Count, F, Q
|
|
6
|
+
from django.http import HttpResponse, HttpResponseRedirect
|
|
7
|
+
from django.views.generic import ListView
|
|
8
|
+
|
|
9
|
+
from .mail import mail_settings, register_action
|
|
10
|
+
from .models import Mail
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
#def generate_pixel_bytes(request):
|
|
14
|
+
# # generate the pixel bytes from scratch
|
|
15
|
+
# import io
|
|
16
|
+
#
|
|
17
|
+
# from PIL import Image
|
|
18
|
+
#
|
|
19
|
+
# pixel = Image.new("RGBA", (1, 1), (255, 255, 255, 0))
|
|
20
|
+
# content = io.BytesIO()
|
|
21
|
+
# pixel.save(content, "PNG")
|
|
22
|
+
# pixel_bytes = content.getvalue()
|
|
23
|
+
# return pixel_bytes
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def proxy(request):
|
|
27
|
+
q = request.GET.get("q", None)
|
|
28
|
+
url = request.GET.get("u", None)
|
|
29
|
+
if not url:
|
|
30
|
+
scheme = mail_settings()["SCHEME"]
|
|
31
|
+
domain = mail_settings()["DOMAIN"]
|
|
32
|
+
HttpResponseRedirect(f"{scheme}://{domain}/")
|
|
33
|
+
|
|
34
|
+
await register_action(q, url)
|
|
35
|
+
return HttpResponseRedirect(url)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def pixel(request):
|
|
39
|
+
q = request.GET.get("q", None)
|
|
40
|
+
|
|
41
|
+
# hardcode pixel value to save time, it never changes, use generate_pixel_bytes if you need to update
|
|
42
|
+
pixel_bytes = (
|
|
43
|
+
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00"
|
|
44
|
+
b"\x00\x00\rIDATx\x9cc\xf8\xff\xff?\x03\x00\x08\xfc\x02\xfe\xa7\x9a\xa0\xa0\x00\x00\x00\x00IEND\xaeB`\x82"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
await register_action(q)
|
|
48
|
+
return HttpResponse(content=pixel_bytes, content_type="image/png")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MailListView(PermissionRequiredMixin, ListView):
|
|
52
|
+
permission_required = ["is_staff"]
|
|
53
|
+
|
|
54
|
+
paginate_by = 25
|
|
55
|
+
model = Mail
|
|
56
|
+
|
|
57
|
+
def get_queryset(self):
|
|
58
|
+
opening = Q(recipients__actions__action="")
|
|
59
|
+
hasAction = Q(recipients__actions__isnull=False)
|
|
60
|
+
qs = Mail.objects.all()
|
|
61
|
+
qs = qs.annotate(opened=Count("recipients", filter=hasAction & opening, distinct=True))
|
|
62
|
+
qs = qs.annotate(sent=Count("recipients", distinct=True))
|
|
63
|
+
qs = qs.annotate(openings=Count("recipients__actions", filter=opening))
|
|
64
|
+
qs = qs.annotate(clicks=Count("recipients__actions", filter=~opening))
|
|
65
|
+
qs = qs.annotate(rate=(F("opened") / F("sent")) * 100)
|
|
66
|
+
qs = qs.order_by("-created")
|
|
67
|
+
return qs
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-present Loic Quertenmont
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: django-mail-analytics
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: django package adding email tracking features. Both email opening and link clicking are tracked.
|
|
5
|
+
Author-email: Loic Quertenmont <loic@deeperanalytics.be>
|
|
6
|
+
Maintainer-email: Loic Quertenmont <loic@deeperanalytics.be>
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2024-present Loic Quertenmont
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
|
|
29
|
+
Project-URL: Homepage, https://github.com/quertenmont/django-mail-analytics
|
|
30
|
+
Project-URL: Download, https://github.com/quertenmont/django-mail-analytics/releases
|
|
31
|
+
Project-URL: Documentation, https://github.com/quertenmont/django-mail-analytics#readme
|
|
32
|
+
Project-URL: Issues, https://github.com/quertenmont/django-mail-analytics/issues
|
|
33
|
+
Project-URL: Funding, https://github.com/sponsors/quertenmont/
|
|
34
|
+
Project-URL: Twitter, https://twitter.com/LoicQuertenmont
|
|
35
|
+
Keywords: django,mail,analytics,tracking,tracker,python
|
|
36
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
37
|
+
Classifier: Environment :: Web Environment
|
|
38
|
+
Classifier: Framework :: Django
|
|
39
|
+
Classifier: Framework :: Django :: 5.0
|
|
40
|
+
Classifier: Intended Audience :: Developers
|
|
41
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
42
|
+
Classifier: Natural Language :: English
|
|
43
|
+
Classifier: Operating System :: OS Independent
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
45
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
46
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
47
|
+
Description-Content-Type: text/markdown
|
|
48
|
+
License-File: LICENSE.txt
|
|
49
|
+
|
|
50
|
+
[](https://www.python.org/)
|
|
51
|
+
[](https://www.djangoproject.com/)
|
|
52
|
+
|
|
53
|
+
[](https://pypi.org/project/django-mail-analytics/)
|
|
54
|
+
[](https://pepy.tech/project/django-mail-analytics)
|
|
55
|
+
[](https://github.com/quertenmont/django-mail-analytics/stargazers)
|
|
56
|
+
[](https://github.com/quertenmont/django-mail-analytics/blob/main/LICENSE.txt)
|
|
57
|
+
|
|
58
|
+
[](https://results.pre-commit.ci/latest/github/quertenmont/django-mail-analytics/main)
|
|
59
|
+
[](https://github.com/quertenmont/django-mail-analytics)
|
|
60
|
+
[](https://codecov.io/gh/quertenmont/django-mail-analytics)
|
|
61
|
+
[](https://www.codacy.com/app/quertenmont/django-mail-analytics)
|
|
62
|
+
[](https://github.com/psf/black)
|
|
63
|
+
[](https://github.com/astral-sh/ruff)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# django-mail-analytics
|
|
68
|
+
|
|
69
|
+
Simple python module that add email tracking features for both mail opening (via a hidden pixel) and for link clicks (via a redirecting proxy)
|
|
70
|
+
- This works only for email with an html body
|
|
71
|
+
- The changes to your code is minimal as we monkey patch the standard django.core.mail.EmailMessage object
|
|
72
|
+
- Three new models are added to save mail sent, mail recipients and mail recipient's action such as opening and link clicking
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Installation
|
|
77
|
+
- Run `pip install django-mail-analytics`
|
|
78
|
+
- Add `django_mail_analytics` to your installed INSTALLED_APPS
|
|
79
|
+
- Add `re_path(r"^m/", include("django_mail_analytics.urls"))` to your urls
|
|
80
|
+
- Add this to your settings:
|
|
81
|
+
```
|
|
82
|
+
MAIL_ANALYTICS = {
|
|
83
|
+
"DOMAIN": "localhost", #add the domain of your app to be used in the pixel/proxy url of your mail sent
|
|
84
|
+
"SCHEME": "http", #http or https
|
|
85
|
+
# "SALT": "my-salt", #comment to use global SECRET_KEY instead
|
|
86
|
+
"LENGTH": 6, # minimal length of encoded id
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Usage
|
|
93
|
+
|
|
94
|
+
All email sent with an html body will be modified on the fly to incorporate a pixel tracking and to rewrite link urls:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
from django.core.mail import send_mail
|
|
98
|
+
|
|
99
|
+
send_mail(
|
|
100
|
+
"Subject here",
|
|
101
|
+
"Here is the message.",
|
|
102
|
+
"from@example.com",
|
|
103
|
+
["to@example.com"],
|
|
104
|
+
fail_silently=False,
|
|
105
|
+
html_message="""
|
|
106
|
+
<body>
|
|
107
|
+
<a href="https://google.com">link</a>
|
|
108
|
+
</body>
|
|
109
|
+
""",
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
The above code will actually send an html body email that is rewritten like this:
|
|
113
|
+
```
|
|
114
|
+
<body>
|
|
115
|
+
<a href="http://localhost/m/p?q=OJe2wr&u=https%3A%2F%2Fgoogle.com">link</a>
|
|
116
|
+
<img src="http://localhost/m/i?q=OJe2wr" height="0px" width="0px"/>
|
|
117
|
+
</body>
|
|
118
|
+
```
|
|
119
|
+
There is no perciple change for the user that receive the email.
|
|
120
|
+
But as soon as it opens (or reopens) the email, we create a MailRecipientAction with an empty action.
|
|
121
|
+
If the user clicks on the link we would create another MailRecipientAction with an action equal to the link url: "https://google.com" in this example.
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
Every email send trigger the creation of a Mail object.
|
|
125
|
+
In addition, a MailRecipient object is also created for every recipients of the email.
|
|
126
|
+
|
|
127
|
+
Note, that multiple emails sharing the same "key" and sending "date" are merged together into a single Mail Object.
|
|
128
|
+
So no need to worry about sending mass emails (they will only create one single Mail object).
|
|
129
|
+
The email "key" is either the email subject OR the first recipient with a "@DMA" email address. This recipient is ignored from the sending list, it's just an easy way to track mails of a given category.
|
|
130
|
+
|
|
131
|
+
So typical usage is like this:
|
|
132
|
+
```
|
|
133
|
+
send_mail(
|
|
134
|
+
subject="subject",
|
|
135
|
+
message="message",
|
|
136
|
+
html_message="html_message,
|
|
137
|
+
from_email=None,
|
|
138
|
+
recipient_list=[user.email, f"EMAIL_KEY@DMA"],
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Admin
|
|
145
|
+
|
|
146
|
+
There is a django admin for the three added models that can easilly be used to monitor your email success rate and typical use behaviour.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Remarks
|
|
151
|
+
|
|
152
|
+
Email were not developped for tracking in the first place.
|
|
153
|
+
So adding a pixel tracking and link proxy is just a workaround to add tracking capabilities to email.
|
|
154
|
+
One of the limitation of this workaround is that sending the email to multiple recipients doesn't allow to distinguish who among the recipients is currently opening the email... just that someone did it.
|
|
155
|
+
Similarly if the email is forwarded to somebody else, we can only know that the email was opened a second time, but not that it's from a different people.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Testing
|
|
160
|
+
```bash
|
|
161
|
+
# clone repository
|
|
162
|
+
git clone https://github.com/quertenmont/django-mail-analytics.git && cd django_mail_analytics
|
|
163
|
+
|
|
164
|
+
# create virtualenv and activate it
|
|
165
|
+
python -m venv venv && . venv/bin/activate
|
|
166
|
+
|
|
167
|
+
# upgrade pip
|
|
168
|
+
python -m pip install --upgrade pip
|
|
169
|
+
|
|
170
|
+
# install requirements
|
|
171
|
+
pip install -r requirements.txt -r requirements-test.txt
|
|
172
|
+
|
|
173
|
+
# install pre-commit to run formatters and linters
|
|
174
|
+
pre-commit install --install-hooks
|
|
175
|
+
|
|
176
|
+
# run tests
|
|
177
|
+
tox
|
|
178
|
+
# or
|
|
179
|
+
python runtests.py
|
|
180
|
+
# or
|
|
181
|
+
python -m django test --settings "tests.settings"
|
|
182
|
+
```
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
Released under [MIT License](LICENSE.txt).
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Supporting
|
|
191
|
+
|
|
192
|
+
- :star: Star this project on [GitHub](https://github.com/quertenmont/django-mail-analytics)
|
|
193
|
+
- :octocat: Follow me on [GitHub](https://github.com/quertenmont)
|
|
194
|
+
- :blue_heart: Follow me on [Twitter](https://twitter.com/LoicQuertenmont)
|
|
195
|
+
- :moneybag: Sponsor me on [Github](https://github.com/sponsors/quertenmont)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
django_mail_analytics/__init__.py,sha256=Rw7KzEMSJIlkafzbNsOa7qg9rZWLz8_fPtNefHRAVQ0,284
|
|
2
|
+
django_mail_analytics/admin.py,sha256=6TH4jzxnW_XQ3q-sznleoX0DGPXXxQe60lI2Ba60jbo,4910
|
|
3
|
+
django_mail_analytics/apps.py,sha256=4IBQdU2vWSLd5-LxuowZ6UNXcQIjcWoo4K94H2FKx40,380
|
|
4
|
+
django_mail_analytics/mail.py,sha256=Fvltx0Nm-HkykmhqNDJz4oBf6reoxf_AueoWLkPSVck,4226
|
|
5
|
+
django_mail_analytics/metadata.py,sha256=0ICHrRT1CdR0K9u7sjSDdlv6CRHfLVQjtwJodAMPkOU,341
|
|
6
|
+
django_mail_analytics/models.py,sha256=6vU5gqO16HB7AQ7_slg70GMvjLEFLa6EXbSGRvEvsxo,1711
|
|
7
|
+
django_mail_analytics/tests.py,sha256=oGgalLR6qiJdN-bHSH1ZLbRNhJHX_MyZtKsaR4jRy_Q,62
|
|
8
|
+
django_mail_analytics/urls.py,sha256=7Co-SXyJLXgEnIEFyjt3qQYGwCRot2JuxtaMezFpI8E,301
|
|
9
|
+
django_mail_analytics/views.py,sha256=z4buoekc0XaX3kq_xxCPVpTzjXqNti_YVibepNSvmII,2221
|
|
10
|
+
django_mail_analytics/migrations/0001_initial.py,sha256=P7PBN9ygPdNa1yWfDDMqFASlwrWMSi87nki4IJiEZaA,1790
|
|
11
|
+
django_mail_analytics/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
django_mail_analytics/templates/django_mail_analytics/mail_list.html,sha256=v6fx59myjzq4j8ocr4cTdkQfJ2aQQSMnYDDIloz1bXQ,1336
|
|
13
|
+
django_mail_analytics-1.0.0.dist-info/LICENSE.txt,sha256=CJM8-vNzIH7SamshwlaQibTv41ZXEqGSwABj1uLM0tg,1081
|
|
14
|
+
django_mail_analytics-1.0.0.dist-info/METADATA,sha256=D2KaeLIAiFgpVVxfkGFnghf7B4lWQY5RFEzmUaiwcV4,8812
|
|
15
|
+
django_mail_analytics-1.0.0.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
|
|
16
|
+
django_mail_analytics-1.0.0.dist-info/top_level.txt,sha256=-HR_aWNYYGqN9ByZJK9ia9VfSEZ6C8ANeCiVE7kUYf0,22
|
|
17
|
+
django_mail_analytics-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
django_mail_analytics
|