wbmailing 2.2.1__py2.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.

Potentially problematic release.


This version of wbmailing might be problematic. Click here for more details.

Files changed (57) hide show
  1. wbmailing/__init__.py +1 -0
  2. wbmailing/admin.py +74 -0
  3. wbmailing/apps.py +14 -0
  4. wbmailing/backend.py +131 -0
  5. wbmailing/celery.py +0 -0
  6. wbmailing/dynamic_preferences_registry.py +35 -0
  7. wbmailing/factories.py +211 -0
  8. wbmailing/filters/__init__.py +8 -0
  9. wbmailing/filters/mailing_lists.py +84 -0
  10. wbmailing/filters/mails.py +74 -0
  11. wbmailing/management/__init__.py +22 -0
  12. wbmailing/migrations/0001_initial_squashed_squashed_0008_alter_mail_bcc_email_alter_mail_cc_email_and_more.py +649 -0
  13. wbmailing/migrations/0002_delete_mailingsettings.py +16 -0
  14. wbmailing/migrations/0003_alter_mailinglistsubscriberchangerequest_options.py +25 -0
  15. wbmailing/migrations/__init__.py +0 -0
  16. wbmailing/models/__init__.py +6 -0
  17. wbmailing/models/mailing_lists.py +386 -0
  18. wbmailing/models/mails.py +895 -0
  19. wbmailing/serializers/__init__.py +19 -0
  20. wbmailing/serializers/mailing_lists.py +209 -0
  21. wbmailing/serializers/mails.py +251 -0
  22. wbmailing/tasks.py +37 -0
  23. wbmailing/templatetags/__init__.py +0 -0
  24. wbmailing/templatetags/mailing_tags.py +22 -0
  25. wbmailing/tests/__init__.py +0 -0
  26. wbmailing/tests/conftest.py +30 -0
  27. wbmailing/tests/models/__init__.py +0 -0
  28. wbmailing/tests/models/test_mailing_lists.py +297 -0
  29. wbmailing/tests/models/test_mails.py +205 -0
  30. wbmailing/tests/signals.py +124 -0
  31. wbmailing/tests/test_serializers.py +28 -0
  32. wbmailing/tests/test_tasks.py +49 -0
  33. wbmailing/tests/test_viewsets.py +216 -0
  34. wbmailing/tests/tests.py +142 -0
  35. wbmailing/urls.py +90 -0
  36. wbmailing/viewsets/__init__.py +32 -0
  37. wbmailing/viewsets/analytics.py +110 -0
  38. wbmailing/viewsets/buttons/__init__.py +10 -0
  39. wbmailing/viewsets/buttons/mailing_lists.py +91 -0
  40. wbmailing/viewsets/buttons/mails.py +98 -0
  41. wbmailing/viewsets/display/__init__.py +16 -0
  42. wbmailing/viewsets/display/mailing_lists.py +175 -0
  43. wbmailing/viewsets/display/mails.py +318 -0
  44. wbmailing/viewsets/endpoints/__init__.py +8 -0
  45. wbmailing/viewsets/endpoints/mailing_lists.py +86 -0
  46. wbmailing/viewsets/endpoints/mails.py +51 -0
  47. wbmailing/viewsets/mailing_lists.py +320 -0
  48. wbmailing/viewsets/mails.py +425 -0
  49. wbmailing/viewsets/menu/__init__.py +5 -0
  50. wbmailing/viewsets/menu/mailing_lists.py +37 -0
  51. wbmailing/viewsets/menu/mails.py +25 -0
  52. wbmailing/viewsets/titles/__init__.py +17 -0
  53. wbmailing/viewsets/titles/mailing_lists.py +63 -0
  54. wbmailing/viewsets/titles/mails.py +55 -0
  55. wbmailing-2.2.1.dist-info/METADATA +5 -0
  56. wbmailing-2.2.1.dist-info/RECORD +57 -0
  57. wbmailing-2.2.1.dist-info/WHEEL +5 -0
@@ -0,0 +1,895 @@
1
+ import json
2
+ from datetime import timedelta
3
+
4
+ from anymail.exceptions import AnymailRecipientsRefused
5
+ from anymail.signals import tracking
6
+ from celery import shared_task
7
+ from django.apps import apps
8
+ from django.conf import settings
9
+ from django.contrib.postgres.fields import ArrayField
10
+ from django.core.files.base import ContentFile
11
+ from django.core.mail import EmailMultiAlternatives
12
+ from django.db import models
13
+ from django.db.models import Count, OuterRef, Subquery
14
+ from django.dispatch import receiver
15
+ from django.template import Context, Template
16
+ from django.utils import timezone
17
+ from django.utils.html import strip_tags
18
+ from django.utils.translation import gettext
19
+ from django.utils.translation import gettext_lazy as _
20
+ from django.utils.translation import pgettext_lazy
21
+ from django_fsm import FSMField, transition
22
+ from dynamic_preferences.registries import global_preferences_registry
23
+ from psycopg.types.range import TimestamptzRange
24
+ from rest_framework.reverse import reverse
25
+ from sentry_sdk import capture_message
26
+ from wbcore.contrib.color.enums import WBColor
27
+ from wbcore.contrib.directory.models import EmailContact
28
+ from wbcore.contrib.documents.models import Document, DocumentType
29
+ from wbcore.contrib.documents.models.mixins import DocumentMixin
30
+ from wbcore.contrib.icons import WBIcon
31
+ from wbcore.enums import RequestType
32
+ from wbcore.markdown.template import resolve_markdown
33
+ from wbcore.metadata.configs.buttons import ActionButton, ButtonDefaultColor
34
+ from wbcore.metadata.configs.display.instance_display.shortcuts import (
35
+ create_simple_display,
36
+ )
37
+ from wbcore.models import WBModel
38
+ from wbcore.utils.html import convert_html2text
39
+
40
+ from .mailing_lists import MailingListEmailContactThroughModel
41
+
42
+
43
+ def can_administrate_mail(mail, user):
44
+ return user.has_perm("wbmailing.can_administrate_mail")
45
+
46
+
47
+ class MassMail(DocumentMixin, WBModel):
48
+ class Status(models.TextChoices):
49
+ DRAFT = "DRAFT", _("Draft")
50
+ PENDING = "PENDING", _("Pending")
51
+ SENT = "SENT", _("Sent")
52
+ SEND_LATER = "SEND LATER", _("Send later")
53
+ DENIED = "DENIED", _("Denied")
54
+
55
+ class Meta:
56
+ verbose_name = _("Mass Mail")
57
+ verbose_name_plural = _("Mass Mails")
58
+ permissions = (("can_administrate_mail", "Can administrate mail"),)
59
+
60
+ status = FSMField(default=Status.DRAFT, choices=Status.choices, verbose_name=_("Status"))
61
+
62
+ @classmethod
63
+ def subquery_expected_mails(cls, massmail_name="pk"):
64
+ """
65
+ Create subquery to count expected mails
66
+
67
+ Arguments:
68
+ massmail_name {str} -- Outerref field
69
+ """
70
+ return Subquery(
71
+ EmailContact.objects.filter(subscriptions__mails=OuterRef(massmail_name))
72
+ .values("subscriptions__mails")
73
+ .annotate(c=Count("subscriptions__mails"))
74
+ .values("c")[:1],
75
+ output_field=models.IntegerField(),
76
+ )
77
+
78
+ BUTTON_SUBMIT = ActionButton(
79
+ method=RequestType.PATCH,
80
+ identifiers=("wbmailing:massmail",),
81
+ icon=WBIcon.SEND.icon,
82
+ color=ButtonDefaultColor.WARNING,
83
+ key="submit",
84
+ label=pgettext_lazy("Massmail draft", "Submit"),
85
+ action_label=_("Submitting"),
86
+ description_fields=_("<p>Subject: {{subject}}</p>"),
87
+ )
88
+
89
+ @transition(
90
+ field=status,
91
+ source=[Status.DRAFT],
92
+ target=Status.PENDING,
93
+ custom={"_transition_button": BUTTON_SUBMIT},
94
+ )
95
+ def submit(self, by=None, description=None, **kwargs):
96
+ pass
97
+
98
+ BUTTON_DENIED = ActionButton(
99
+ method=RequestType.PATCH,
100
+ identifiers=("wbmailing:massmail",),
101
+ icon=WBIcon.REJECT.icon,
102
+ color=ButtonDefaultColor.ERROR,
103
+ key="deby",
104
+ label=_("Deny"),
105
+ action_label=_("Denial"),
106
+ description_fields=_("<p>Subject: {{subject}}</p>"),
107
+ )
108
+
109
+ @transition(
110
+ field=status,
111
+ source=[Status.PENDING],
112
+ target=Status.DENIED,
113
+ permission=can_administrate_mail,
114
+ custom={"_transition_button": BUTTON_DENIED},
115
+ )
116
+ def deny(self, by=None, description=None, **kwargs):
117
+ pass
118
+
119
+ BUTTON_REVISE = ActionButton(
120
+ method=RequestType.PATCH,
121
+ identifiers=("wbmailing:massmail",),
122
+ icon=WBIcon.EDIT.icon,
123
+ color=ButtonDefaultColor.WARNING,
124
+ key="revise",
125
+ label=_("Revise"),
126
+ action_label=_("Revision"),
127
+ description_fields=_("<p>Subject: {{subject}}</p>"),
128
+ )
129
+
130
+ @transition(
131
+ field=status,
132
+ source=[Status.PENDING],
133
+ target=Status.DRAFT,
134
+ permission=can_administrate_mail,
135
+ custom={"_transition_button": BUTTON_REVISE},
136
+ )
137
+ def revise(self, by=None, description=None, **kwargs):
138
+ pass
139
+
140
+ BUTTON_SEND = ActionButton(
141
+ method=RequestType.PATCH,
142
+ identifiers=("wbmailing:massmail",),
143
+ icon=WBIcon.MAIL.icon,
144
+ color=ButtonDefaultColor.SUCCESS,
145
+ key="send",
146
+ label=_("Send"),
147
+ action_label=_("Sending"),
148
+ description_fields=_("<p>Subject: {{subject}}</p><p>Mailing Lists: {{mailing_lists}}</p>"),
149
+ )
150
+
151
+ @transition(
152
+ field=status,
153
+ source=[Status.PENDING, Status.SEND_LATER],
154
+ target=Status.SENT,
155
+ permission=lambda instance, user: user.has_perm("wbmailing.can_administrate_mail") and not instance.send_at,
156
+ custom={"_transition_button": BUTTON_SEND},
157
+ )
158
+ def send(self, by=None, description=None, **kwargs):
159
+ send_mail_task.delay(self.id)
160
+
161
+ BUTTON_SEND_LATER = ActionButton(
162
+ method=RequestType.PATCH,
163
+ identifiers=("wbmailing:massmail",),
164
+ icon=WBIcon.SEND_LATER.icon,
165
+ key="sendlater",
166
+ label=_("Send Later"),
167
+ action_label=_("Sending later"),
168
+ description_fields=_("<p>Subject: {{subject}}</p><p>Mailing Lists: {{mailing_lists}}</p>"),
169
+ instance_display=create_simple_display([["send_at"]]),
170
+ )
171
+
172
+ @transition(
173
+ field=status,
174
+ source=[Status.PENDING, Status.SENT, Status.SEND_LATER],
175
+ target=Status.SEND_LATER,
176
+ permission=lambda instance, user: user.has_perm("wbmailing.can_administrate_mail")
177
+ and (not instance.send_at or instance.send_at > timezone.now()),
178
+ custom={"_transition_button": BUTTON_SEND_LATER},
179
+ )
180
+ def sendlater(self, by=None, description=None, **kwargs):
181
+ pass
182
+
183
+ @classmethod
184
+ def get_emails(cls, included_mailing_list_ids: list[int], excluded_mailing_list_ids: list[int] = None):
185
+ included_emails_id = MailingListEmailContactThroughModel.objects.filter(
186
+ status=MailingListEmailContactThroughModel.Status.SUBSCRIBED, mailing_list__in=included_mailing_list_ids
187
+ ).values_list("email_contact", flat=True)
188
+
189
+ excluded_addresses = (
190
+ MailingListEmailContactThroughModel.objects.filter(
191
+ status=MailingListEmailContactThroughModel.Status.SUBSCRIBED,
192
+ mailing_list__in=excluded_mailing_list_ids,
193
+ ).values_list("email_contact__address", flat=True)
194
+ if excluded_mailing_list_ids
195
+ else []
196
+ )
197
+ return (
198
+ EmailContact.objects.filter(id__in=included_emails_id)
199
+ .exclude(address__in=list(excluded_addresses))
200
+ .distinct("address")
201
+ )
202
+
203
+ def get_mail_addresses(self):
204
+ return self.get_emails(
205
+ self.mailing_lists.values_list("id"), self.excluded_mailing_lists.values_list("id")
206
+ ).values("address")
207
+
208
+ def create_email(self, email):
209
+ global_preferences = global_preferences_registry.manager()
210
+ context = MassMail.get_context(email, self.subject, self.attachment_url)
211
+ if self.documents and self.documents.exists():
212
+ context.update(
213
+ {"urls": [attachment.generate_shareable_link().link for attachment in self.documents.all()]}
214
+ )
215
+ rendered_subject = Template(self.subject).render(Context(context))
216
+ body = resolve_markdown(self.body, extensions=["sane_lists"])
217
+ if self.template:
218
+ rendered_body = self.template.render_content(body, extra_context=context)
219
+ else:
220
+ rendered_body = Template(body).render(Context(context))
221
+
222
+ from_mail = self.from_email or global_preferences["wbmailing__default_source_mail"]
223
+
224
+ return Mail.get_mailmessage(
225
+ rendered_subject, rendered_body, from_mail, [email], attachments=self.documents, mass_mail=self
226
+ )
227
+
228
+ def send_test_mail(self, email):
229
+ """
230
+ Send a test mail
231
+
232
+ Arguments:
233
+ email {str} -- The address to send the test mail to
234
+ """
235
+ self.subject = _("Test mail: {}").format(self.subject)
236
+
237
+ msg = self.create_email(email)
238
+ msg.send()
239
+
240
+ from_email = models.EmailField(null=True, blank=True, verbose_name=_("From"))
241
+ template = models.ForeignKey(
242
+ "MailTemplate",
243
+ related_name="mass_mails",
244
+ null=True,
245
+ blank=True,
246
+ on_delete=models.SET_NULL,
247
+ verbose_name=_("Template"),
248
+ )
249
+ mailing_lists = models.ManyToManyField(
250
+ "MailingList",
251
+ related_name="mails",
252
+ verbose_name=_("Mailing Lists"),
253
+ help_text=_("The mailing lists to extract emails from. Duplicates will be skipped."),
254
+ )
255
+ excluded_mailing_lists = models.ManyToManyField(
256
+ "MailingList",
257
+ related_name="excluded_mails",
258
+ verbose_name=_("Excluded Mailing Lists"),
259
+ help_text=_(
260
+ "The mailing lists to exlude emails from. The resulting list of emails is equals to Mailing Lists - Excluded Mailing Lists "
261
+ ),
262
+ blank=True,
263
+ )
264
+ subject = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Subject"))
265
+ body = models.TextField(default="", verbose_name=_("Body"))
266
+
267
+ attachment_url = models.URLField(null=True, blank=True, verbose_name=_("Attachment (URL)"))
268
+
269
+ body_json = models.JSONField(default=dict, null=True, blank=True)
270
+ created = models.DateTimeField(auto_now_add=True, verbose_name=_("Created"))
271
+ creator = models.ForeignKey(
272
+ "directory.Person",
273
+ related_name="created_mails",
274
+ on_delete=models.CASCADE,
275
+ blank=True,
276
+ null=True,
277
+ verbose_name=_("Creator"),
278
+ )
279
+
280
+ send_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Send At"))
281
+
282
+ def __str__(self):
283
+ return self.subject
284
+
285
+ @classmethod
286
+ def get_endpoint_basename(cls) -> str:
287
+ return "wbmailing:massmail"
288
+
289
+ @classmethod
290
+ def get_representation_endpoint(cls):
291
+ return "wbmailing:massmailrepresentation-list"
292
+
293
+ @classmethod
294
+ def get_representation_value_key(cls):
295
+ return "id"
296
+
297
+ @classmethod
298
+ def get_representation_label_key(cls):
299
+ return "{{subject}}"
300
+
301
+ @classmethod
302
+ def get_context(cls, email, subject=None, attachment_url=None):
303
+ emails = EmailContact.objects.filter(address=email)
304
+ context = {"email": email}
305
+ if subject:
306
+ context["subject"] = subject
307
+ if attachment_url:
308
+ context["attachment_url"] = attachment_url
309
+ if emails.exists():
310
+ email_contact = emails.first()
311
+ unsubscribe_url = f'{settings.BASE_ENDPOINT_URL}{reverse("wbmailing:manage_mailing_list_subscriptions", args=[email_contact.id])}'
312
+ context["unsubscribe"] = f"<a href={unsubscribe_url}>" + _("Unsubscribe</a>")
313
+ entry = email_contact.entry
314
+ if entry:
315
+ if salutation := entry.salutation:
316
+ context["salutation"] = salutation
317
+ casted_entry = entry.get_casted_entry()
318
+ if hasattr(casted_entry, "first_name"):
319
+ context["first_name"] = casted_entry.first_name
320
+ if hasattr(casted_entry, "last_name"):
321
+ context["last_name"] = casted_entry.last_name
322
+ return context
323
+
324
+
325
+ class Mail(DocumentMixin, WBModel):
326
+ class Status(models.TextChoices):
327
+ OPENED = "OPENED", _("Opened")
328
+ DELIVERED = "DELIVERED", _("Delivered")
329
+ BOUNCED = "BOUNCED", _("Bounced")
330
+ OTHER = "OTHER", _("Other")
331
+
332
+ created = models.DateTimeField(auto_now_add=True, verbose_name=_("Created"))
333
+ last_send = models.DateTimeField(verbose_name=_("Last Sent"), default=timezone.now)
334
+ template = models.ForeignKey(
335
+ "MailTemplate",
336
+ related_name="mails",
337
+ null=True,
338
+ blank=True,
339
+ on_delete=models.SET_NULL,
340
+ verbose_name=_("Template"),
341
+ )
342
+
343
+ message_ids = ArrayField(models.CharField(max_length=255, null=True, blank=True), default=list)
344
+ mass_mail = models.ForeignKey(
345
+ "MassMail", related_name="mails", null=True, blank=True, on_delete=models.SET_NULL, verbose_name=_("Mass Mail")
346
+ )
347
+
348
+ from_email = models.EmailField(verbose_name=_("From"))
349
+ to_email = models.ManyToManyField("directory.EmailContact", related_name="mail_to", verbose_name=_("To"))
350
+ cc_email = models.ManyToManyField(
351
+ "directory.EmailContact", related_name="mail_cc", blank=True, verbose_name=_("CC")
352
+ )
353
+ bcc_email = models.ManyToManyField(
354
+ "directory.EmailContact", related_name="mail_bcc", blank=True, verbose_name=_("BCC")
355
+ )
356
+
357
+ subject = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Subject"))
358
+ body = models.TextField(blank=True, null=True, verbose_name=_("body"))
359
+ body_json = models.JSONField(default=dict, null=True, blank=True)
360
+
361
+ def resend(self):
362
+ """
363
+ Resend that mail
364
+ """
365
+ context = {}
366
+ if self.to_email.count() == 1:
367
+ context = MassMail.get_context(self.to_email.first().address, self.subject)
368
+ if self.documents.exists():
369
+ context.update(
370
+ {"urls": [attachment.generate_shareable_link().link for attachment in self.documents.all()]}
371
+ )
372
+ rendered_subject = Template(self.subject).render(Context(context))
373
+
374
+ if self.mass_mail:
375
+ body = resolve_markdown(self.mass_mail.body, extensions=["sane_lists"])
376
+ template = self.mass_mail.template
377
+ if self.mass_mail.template:
378
+ rendered_body = template.render_content(body, extra_context=context)
379
+ else:
380
+ rendered_body = Template(body).render(Context(context))
381
+ else:
382
+ rendered_body = self.body
383
+
384
+ msg = {
385
+ "subject": rendered_subject,
386
+ "body": rendered_body,
387
+ "from_email": self.from_email,
388
+ "to": list(self.to_email.values_list("address", flat=True)),
389
+ "bcc": list(self.bcc_email.values_list("address", flat=True)),
390
+ "cc": list(self.cc_email.values_list("address", flat=True)),
391
+ "mail_id": self.id,
392
+ }
393
+ if self.mass_mail:
394
+ msg["mass_mail_id"] = self.mass_mail.id
395
+ send_mail_as_task.delay(**msg)
396
+
397
+ def convert_files_to_documents(self, attachments, alternatives):
398
+ # If DMS sends this email, we expect to find an alternative containing a encoded dictionary with the
399
+ # necessary information to retreive the document object
400
+ dms_alternatives = list(
401
+ map(
402
+ lambda x: json.loads(x[0].decode("ascii")),
403
+ filter(lambda x: x[1] == "wbdms/document", alternatives), # leave this mimetype. Used by wbcore
404
+ )
405
+ )
406
+
407
+ document_type, created = DocumentType.objects.get_or_create(name="mailing")
408
+ for name, payload, mimetype in attachments:
409
+ # If an alternative sent from DMS is found, we match against the attachment email.
410
+ dms_elements = list(filter(lambda x: x["filename"] == name, dms_alternatives))
411
+ if (len(dms_elements) == 1) and (document_id := dms_elements[0].get("id", None)):
412
+ document = Document.objects.get(id=document_id)
413
+ # Otherwise, we update or create the corresponding Document objects based on its generated system_key
414
+ else:
415
+ content_file = ContentFile(payload, name=name)
416
+ system_key_base = f"mail-{self.id}" if not self.mass_mail else f"massmail-{self.mass_mail.id}"
417
+ document, created = Document.objects.update_or_create(
418
+ document_type=document_type,
419
+ system_created=True,
420
+ system_key=f"{system_key_base}-{name}",
421
+ defaults={"file": content_file},
422
+ )
423
+ document.link(self)
424
+
425
+ class Meta:
426
+ verbose_name = _("Mail")
427
+ verbose_name_plural = _("Mails")
428
+
429
+ def __str__(self):
430
+ return self.subject or str(self.id)
431
+
432
+ @classmethod
433
+ def get_mailmessage(
434
+ cls,
435
+ rendered_subject,
436
+ rendered_body,
437
+ from_email,
438
+ to,
439
+ bcc=None,
440
+ cc=None,
441
+ attachments=None,
442
+ mass_mail=None,
443
+ mail=None,
444
+ ):
445
+ """
446
+ Get a set of parameters and returns the custom mail message alternative
447
+
448
+ Args:
449
+ rendered_subject (str): The mail subject
450
+ rendered_body (str): Mail Body
451
+ from_email (str): from email address
452
+ to (list<string>): List of destination addresses
453
+ bcc (list<string>): List of BCC addressses
454
+ cc (list<string>): List of CC addresses
455
+ attachments (list<File>): List of File to attach to the mail
456
+ mass_mail (MassMail): The originating mass mail, None if a direct mail
457
+ mail (Mail): The Mail object in case of a resend
458
+ """
459
+ msg = EmailMultiAlternatives(
460
+ strip_tags(rendered_subject), convert_html2text(rendered_body), from_email, to=to, cc=cc, bcc=bcc
461
+ )
462
+
463
+ msg.mass_mail = mass_mail
464
+ msg.mail = mail
465
+ msg.attach_alternative(rendered_body, "text/html")
466
+
467
+ if attachments:
468
+ for attachment in attachments:
469
+ msg.attach(attachment.file.name, attachment.file.read())
470
+ return msg
471
+
472
+ @classmethod
473
+ def create_mail_from_mailmessage(cls, msg, user=None):
474
+ def get_or_create_emails(emails):
475
+ for email in emails:
476
+ if e := EmailContact.objects.filter(address=email).first():
477
+ yield e
478
+ else:
479
+ yield EmailContact.objects.create(address=email)
480
+
481
+ args = {}
482
+
483
+ if mass_mail := getattr(msg, "mass_mail", None):
484
+ args["mass_mail"] = mass_mail
485
+ args["subject"] = mass_mail.subject
486
+ else:
487
+ args["subject"] = msg.subject
488
+ args["body"] = msg.body
489
+ for content, mimetype in msg.alternatives:
490
+ if mimetype == "text/html":
491
+ args["body"] = content
492
+
493
+ mail = Mail.objects.create(from_email=msg.from_email, **args)
494
+
495
+ if msg.attachments:
496
+ mail.convert_files_to_documents(msg.attachments, msg.alternatives)
497
+ if hasattr(msg, "clear_attachments"):
498
+ msg.attachments = []
499
+ if msg.to:
500
+ _email = get_or_create_emails(msg.to)
501
+ if _email:
502
+ mail.to_email.set(_email)
503
+ if msg.cc:
504
+ _email = get_or_create_emails(msg.cc)
505
+ if _email:
506
+ mail.cc_email.set(_email)
507
+ if msg.bcc:
508
+ _email = get_or_create_emails(msg.bcc)
509
+ if _email:
510
+ mail.bcc_email.set(_email)
511
+ mail.save()
512
+ return mail
513
+
514
+ @classmethod
515
+ def subquery_send_mails(cls, mass_mail_name="pk"):
516
+ """
517
+ Create subquery to count number of mails created from sent Mass Mail
518
+ """
519
+ return Subquery(
520
+ cls.objects.filter(mass_mail=OuterRef(mass_mail_name))
521
+ .values("mass_mail")
522
+ .annotate(c=Count("mass_mail"))
523
+ .values("c")[:1],
524
+ output_field=models.IntegerField(),
525
+ )
526
+
527
+ @classmethod
528
+ def get_endpoint_basename(cls):
529
+ return "wbmailing:mail"
530
+
531
+ @classmethod
532
+ def get_representation_endpoint(cls):
533
+ return "wbmailing:mailrepresentation-list"
534
+
535
+ @classmethod
536
+ def get_representation_value_key(cls):
537
+ return "id"
538
+
539
+ @classmethod
540
+ def get_representation_label_key(cls):
541
+ return "{{subject}}"
542
+
543
+
544
+ class MailEvent(models.Model):
545
+ class EventType(models.TextChoices):
546
+ """Constants for normalized Anymail event types"""
547
+
548
+ CREATED = "CREATED", _("Created") # Default Type
549
+ QUEUED = "QUEUED", _("Queued") # the ESP has accepted the message and will try to send it (possibly later)
550
+ SENT = "SENT", _("Sent") # the ESP has sent the message (though it may or may not get delivered)
551
+ RESENT = "RESENT", _("Resent")
552
+ REJECTED = (
553
+ "REJECTED",
554
+ _("Rejected"),
555
+ ) # the ESP refused to send the messsage (e.g., suppression list, policy, invalid email)
556
+ FAILED = "FAILED", _("Failed") # the ESP was unable to send the message (e.g., template rendering error)
557
+ BOUNCED = "BOUNCED", _("Bounced") # rejected or blocked by receiving MTA
558
+ DEFERRED = (
559
+ "DEFERRED",
560
+ _("Deferred"),
561
+ ) # delayed by receiving MTA; should be followed by a later BOUNCED or DELIVERED
562
+ DELIVERED = "DELIVERED", _("Delivered") # accepted by receiving MTA
563
+ AUTORESPONDED = "AUTORESPONDED", _("Autoresponded") # a bot replied
564
+ OPENED = "OPENED", _("Opened") # open tracking
565
+ CLICKED = "CLICKED", _("Clicked") # click tracking
566
+ COMPLAINED = "COMPLAINED", _("Complained") # recipient reported as spam (e.g., through feedback loop)
567
+ UNSUBSCRIBED = "UNSUBSCRIBED", _("Unsubscribed") # recipient attempted to unsubscribe
568
+ SUBSCRIBED = "SUBSCRIBED", _("Subscribed") # signed up for mailing list through ESP-hosted form
569
+ INBOUND = "INBOUND", _("Inbound") # received message
570
+ INBOUND_FAILED = "INBOUND_FAILED", _("Inbound Failed")
571
+ UNKNOWN = "UNKNOWN", _("Unknown") # anything else
572
+
573
+ @classmethod
574
+ def get_color(cls, name):
575
+ return {
576
+ "CREATED": WBColor.GREY.value,
577
+ "QUEUED": WBColor.BLUE.value,
578
+ "SENT": WBColor.BLUE_LIGHT.value,
579
+ "RESENT": WBColor.BLUE_DARK.value,
580
+ "REJECTED": WBColor.RED_DARK.value,
581
+ "FAILED": WBColor.RED_DARK.value,
582
+ "BOUNCED": WBColor.RED.value,
583
+ "DEFERRED": WBColor.RED_LIGHT.value,
584
+ "DELIVERED": WBColor.YELLOW_LIGHT.value,
585
+ "AUTORESPONDED": WBColor.YELLOW.value,
586
+ "OPENED": WBColor.GREEN_LIGHT.value,
587
+ "CLICKED": WBColor.GREEN.value,
588
+ "COMPLAINED": WBColor.RED_DARK.value,
589
+ "UNSUBSCRIBED": WBColor.RED_DARK.value,
590
+ "SUBSCRIBED": WBColor.BLUE_DARK.value,
591
+ "INBOUND": WBColor.YELLOW.value,
592
+ "INBOUND_FAILED": WBColor.RED_DARK.value,
593
+ "UNKNOWN": WBColor.YELLOW_DARK.value,
594
+ }[name]
595
+
596
+ class RejectReason(models.TextChoices):
597
+ """Constants for normalized Anymail reject/drop reasons"""
598
+
599
+ INVALID = "INVALID", _("invalid") # bad address format
600
+ BOUNCED = "BOUNCED", _("bounced") # (previous) bounce from recipient
601
+ TIMED_OUT = "TIMED_OUT", _("timed_out") # (previous) repeated failed delivery attempts
602
+ BLOCKED = "BLOCKED", _("blocked") # ESP policy suppression
603
+ SPAM = "SPAM", _("spam") # (previous) spam complaint from recipient
604
+ UNSUBSCRIBED = "UNSUBSCRIBED", _("unsubscribed") # (previous) unsubscribe request from recipient
605
+ OTHER = "OTHER", _("other")
606
+
607
+ mail = models.ForeignKey("Mail", related_name="events", on_delete=models.CASCADE, verbose_name=_("Mail"))
608
+ timestamp = models.DateTimeField(default=timezone.now, verbose_name=_("Datetime"))
609
+ event_type = models.CharField(
610
+ max_length=64, default=EventType.CREATED, choices=EventType.choices, verbose_name=_("Type")
611
+ )
612
+ reject_reason = models.CharField(
613
+ max_length=64, null=True, blank=True, choices=RejectReason.choices, verbose_name=_("Rejection Reason")
614
+ )
615
+ description = models.TextField(null=True, blank=True, verbose_name=_("Description"))
616
+ recipient = models.EmailField(null=True, blank=True, verbose_name=_("Recipient"))
617
+ click_url = models.URLField(
618
+ max_length=2048, null=True, blank=True, verbose_name=_("Clicked URL")
619
+ ) # 2048 is the maximum allowed url size by browser.
620
+ ip = models.CharField(max_length=126, null=True, blank=True, verbose_name=_("IP Used To Send Mail"))
621
+ user_agent = models.TextField(null=True, blank=True)
622
+ raw_data = models.JSONField(default=dict, null=True, blank=True, verbose_name=_("Raw Data"))
623
+ metadata = models.JSONField(default=dict, null=True, blank=True, verbose_name=_("Metadata"))
624
+ tags = ArrayField(models.CharField(max_length=64, null=True, blank=True), default=list)
625
+
626
+ class Meta:
627
+ verbose_name = _("Mail Event")
628
+ verbose_name_plural = _("Mail Events")
629
+
630
+ @classmethod
631
+ def subquery_delivered_mails(cls, mass_mail_name="pk"):
632
+ """
633
+ Create subquery to count number of delivered mails
634
+ """
635
+ return Subquery(
636
+ cls.objects.filter(event_type=MailEvent.EventType.DELIVERED, mail__mass_mail=OuterRef(mass_mail_name))
637
+ .values("mail__mass_mail")
638
+ .annotate(c=Count("mail__mass_mail"))
639
+ .values("c")[:1],
640
+ output_field=models.IntegerField(),
641
+ )
642
+
643
+ @classmethod
644
+ def subquery_opened_mails(cls, mass_mail_name="pk"):
645
+ """
646
+ Create subquery to count number of opened mails
647
+ """
648
+ return Subquery(
649
+ cls.objects.filter(event_type=MailEvent.EventType.OPENED, mail__mass_mail=OuterRef(mass_mail_name))
650
+ .values("mail__mass_mail")
651
+ .annotate(c=Count("mail__to_email", distinct=True))
652
+ .values("c")[:1],
653
+ output_field=models.IntegerField(),
654
+ )
655
+
656
+ @classmethod
657
+ def subquery_clicked_mails(cls, mass_mail_name="pk"):
658
+ """
659
+ Create subquery to count number of clicked mails
660
+ """
661
+ return Subquery(
662
+ cls.objects.filter(event_type=MailEvent.EventType.CLICKED, mail__mass_mail=OuterRef(mass_mail_name))
663
+ .values("mail__mass_mail")
664
+ .annotate(c=Count("mail__mass_mail"))
665
+ .values("c")[:1],
666
+ output_field=models.IntegerField(),
667
+ )
668
+
669
+ @classmethod
670
+ def get_endpoint_basename(cls):
671
+ return "wbmailing:mailevent"
672
+
673
+ @classmethod
674
+ def get_representation_value_key(cls):
675
+ return "id"
676
+
677
+ @classmethod
678
+ def get_representation_label_key(cls):
679
+ return "{{event_type}} ({{mail__subject}})"
680
+
681
+
682
+ class MailTemplate(WBModel):
683
+ title = models.CharField(max_length=255)
684
+ template = models.TextField()
685
+
686
+ def render_content(self, content, extra_context=None):
687
+ context = {"content": content}
688
+ if extra_context:
689
+ context = {**context, **extra_context}
690
+ return Template(self.template).render(Context(context))
691
+
692
+ class Meta:
693
+ verbose_name = _("Mail Template")
694
+ verbose_name_plural = _("Mail Templates")
695
+
696
+ def __str__(self):
697
+ return self.title
698
+
699
+ @classmethod
700
+ def get_endpoint_basename(cls):
701
+ return "wbmailing:mailtemplate"
702
+
703
+ @classmethod
704
+ def get_representation_endpoint(cls):
705
+ return "wbmailing:mailtemplaterepresentation-list"
706
+
707
+ @classmethod
708
+ def get_representation_value_key(cls):
709
+ return "id"
710
+
711
+ @classmethod
712
+ def get_representation_label_key(cls):
713
+ return "{{title}}"
714
+
715
+
716
+ @receiver(tracking)
717
+ def handle_mail_tracking(sender, event, esp_name, **kwargs):
718
+ """
719
+ Signal triggered by the Sendgrid sdk. Create MailEvent
720
+ """
721
+ global_preferences = global_preferences_registry.manager()
722
+
723
+ def handle_subscription_change(
724
+ recipient, event_type, description, mass_mail, automatically_approve=False, reject_reason=None
725
+ ):
726
+ email_contacts = EmailContact.objects.filter(address=recipient)
727
+ for email_contact in email_contacts:
728
+ for mailing_list in mass_mail.mailing_lists.all():
729
+ if event_type == MailEvent.EventType.SUBSCRIBED:
730
+ mailing_list.subscribe(
731
+ email_contact,
732
+ reason=gettext(
733
+ "Received an ESP resubscription request for {}. Please check the pending subscription change request"
734
+ ).format(recipient),
735
+ )
736
+ else:
737
+ mailing_list.unsubscribe(
738
+ email_contact,
739
+ reason=gettext(
740
+ "Received an ESP unsubscription request for {}:\n <p>Event Type: {}</p>\n<p>Rejection Reason: {}</p>\n<p>Description: {}</p>\n<p>Automatically approve: {}</p>"
741
+ ).format(recipient, event_type, reject_reason, description, automatically_approve),
742
+ automatically_approve=automatically_approve,
743
+ )
744
+
745
+ def get_mail(message_id, tags=[]):
746
+ mail = Mail.objects.filter(message_ids__contains=[event.message_id]).first()
747
+ if mail:
748
+ return mail
749
+
750
+ # We support only one tag
751
+ tags = tags[0].split("-") if tags and isinstance(tags[0], str) else []
752
+ if len(tags) == 2:
753
+ try:
754
+ _id = int(tags[1])
755
+ if tags[0] == "massmail":
756
+ mail = Mail.objects.filter(mass_mail=_id, to_email__address=event.recipient).first()
757
+ elif tags[0] == "mail":
758
+ mail = Mail.objects.get(id=_id)
759
+ except ValueError:
760
+ pass
761
+ return mail
762
+
763
+ event_type = event.event_type.upper()
764
+ if event_type not in MailEvent.EventType.names:
765
+ event_type = MailEvent.EventType.UNKNOWN
766
+
767
+ reject_reason = None
768
+ if event.reject_reason:
769
+ reject_reason = event.reject_reason.upper()
770
+ if reject_reason not in MailEvent.RejectReason.names:
771
+ reject_reason = MailEvent.RejectReason.OTHER
772
+
773
+ if event_type == MailEvent.EventType.UNKNOWN and event.esp_event.get("RecordType", None) == "SubscriptionChange":
774
+ if event.esp_event.get("SuppressSending", True):
775
+ event_type = MailEvent.EventType.UNSUBSCRIBED
776
+ if reason := event.esp_event.get("SuppressionReason", None):
777
+ if reason == "HardBounce":
778
+ reject_reason = MailEvent.RejectReason.BOUNCED
779
+ elif reason == "SpamComplaint":
780
+ reject_reason = MailEvent.RejectReason.SPAM
781
+ else:
782
+ reject_reason = MailEvent.RejectReason.UNSUBSCRIBED
783
+ else:
784
+ event_type = MailEvent.EventType.SUBSCRIBED
785
+
786
+ mail = get_mail(event.message_id, event.tags)
787
+ if mail:
788
+ MailEvent.objects.create(
789
+ mail=mail,
790
+ timestamp=event.timestamp if event.timestamp else timezone.now(),
791
+ event_type=event_type,
792
+ recipient=event.recipient,
793
+ reject_reason=reject_reason,
794
+ user_agent=event.user_agent,
795
+ click_url=event.click_url,
796
+ description=event.description,
797
+ raw_data=event.esp_event,
798
+ tags=event.tags,
799
+ )
800
+ is_hard_bounce = event.esp_event.get("Type", None) == "HardBounce"
801
+ if mail.mass_mail: # we handle unsubscription and subscription only if the mail comes from a mass mail
802
+ # Handle PostMark unsubcription notification
803
+ if event_type in [
804
+ MailEvent.EventType.REJECTED,
805
+ MailEvent.EventType.COMPLAINED,
806
+ MailEvent.EventType.UNSUBSCRIBED,
807
+ MailEvent.EventType.SUBSCRIBED,
808
+ ] or (event_type == MailEvent.EventType.BOUNCED and is_hard_bounce):
809
+ handle_subscription_change(
810
+ event.recipient,
811
+ event_type,
812
+ event.description,
813
+ mail.mass_mail,
814
+ automatically_approve=is_hard_bounce
815
+ and global_preferences["wbmailing__automatically_approve_unsubscription_request_from_hard_bounce"],
816
+ reject_reason=reject_reason,
817
+ )
818
+
819
+ else:
820
+ capture_message(gettext("Received event but could not find related mail"))
821
+
822
+
823
+ @shared_task
824
+ def send_mail_as_task(
825
+ subject=None,
826
+ body=None,
827
+ from_email=None,
828
+ to=None,
829
+ bcc=None,
830
+ cc=None,
831
+ mass_mail_id=None,
832
+ mail_id=None,
833
+ ):
834
+ msg = Mail.get_mailmessage(
835
+ subject,
836
+ body,
837
+ from_email,
838
+ to,
839
+ bcc=bcc,
840
+ cc=cc,
841
+ mass_mail=MassMail.objects.get(id=mass_mail_id) if mass_mail_id else None,
842
+ mail=Mail.objects.get(id=mail_id) if mail_id else None,
843
+ )
844
+ msg.send()
845
+
846
+
847
+ @shared_task
848
+ def send_mass_mail_as_task(mass_mail_id: int, email_address: str):
849
+ mass_mail = MassMail.objects.get(id=mass_mail_id)
850
+ try:
851
+ msg = mass_mail.create_email(email_address)
852
+ msg.send()
853
+ except AnymailRecipientsRefused:
854
+ global_preferences = global_preferences_registry.manager()
855
+ for mailing_list in mass_mail.mailing_lists.exclude(id__in=mass_mail.excluded_mailing_lists.values("id")):
856
+ for rel in MailingListEmailContactThroughModel.objects.filter(
857
+ mailing_list=mailing_list,
858
+ email_contact__address=email_address,
859
+ status=MailingListEmailContactThroughModel.Status.SUBSCRIBED,
860
+ ):
861
+ rel.change_state(
862
+ automatically_approve=global_preferences[
863
+ "wbmailing__automatically_approve_unsubscription_request_from_hard_bounce"
864
+ ],
865
+ reason="Email address was rejected by our ESP.",
866
+ )
867
+
868
+
869
+ @shared_task
870
+ def send_mail_task(mass_mail_id):
871
+ """
872
+ MailingListSubscriberChangeRequest post_save signal: Automatically approve if user is superuser/manager
873
+
874
+ Arguments:
875
+ mass_mail {MassMail} -- MassMail to send mails from
876
+ """
877
+ mass_mail = MassMail.objects.filter(id=mass_mail_id).first()
878
+
879
+ if apps.is_installed("wbcrm.Activity"): # Until we find something else
880
+ from wbcrm.models import Activity, ActivityType
881
+
882
+ activity_type, created = ActivityType.objects.get_or_create(slugify_title="email", defaults={"title": "Email"})
883
+ Activity.objects.create(
884
+ status=Activity.Status.REVIEWED,
885
+ type=activity_type,
886
+ title=_("Mass mail sent: {}").format(mass_mail.subject),
887
+ description=mass_mail.body,
888
+ period=TimestamptzRange(timezone.now(), timezone.now() + timedelta(minutes=1)),
889
+ creator=mass_mail.creator,
890
+ assigned_to=mass_mail.creator,
891
+ )
892
+
893
+ mass_mail.mails.all().delete()
894
+ for subscriber in mass_mail.get_mail_addresses():
895
+ send_mass_mail_as_task.delay(mass_mail.id, subscriber["address"])