SciPost Code Repository

Skip to content
Snippets Groups Projects
Commit 7f2beb44 authored by George Katsikas's avatar George Katsikas :goat:
Browse files

add bulk-email capability to Maillog

parent 7e4a0daf
No related branches found
No related tags found
No related merge requests found
...@@ -423,6 +423,7 @@ WEBPACK_LOADER = { ...@@ -423,6 +423,7 @@ WEBPACK_LOADER = {
} }
# Email # Email
BULK_EMAIL_THROTTLE = 5
EMAIL_BACKEND = "mails.backends.filebased.EmailBackend" EMAIL_BACKEND = "mails.backends.filebased.EmailBackend"
EMAIL_FILE_PATH = "local_files/email/" EMAIL_FILE_PATH = "local_files/email/"
EMAIL_SUBJECT_PREFIX = "[SciPost Server] " EMAIL_SUBJECT_PREFIX = "[SciPost Server] "
......
...@@ -19,8 +19,8 @@ class MailLogRelationInline(admin.TabularInline): ...@@ -19,8 +19,8 @@ class MailLogRelationInline(admin.TabularInline):
@admin.register(MailLog) @admin.register(MailLog)
class MailLogAdmin(admin.ModelAdmin): class MailLogAdmin(admin.ModelAdmin):
list_display = ["__str__", "to_recipients", "created", "status"] list_display = ["__str__", "to_recipients", "created", "status", "type"]
list_filter = ["status"] list_filter = ["status", "type"]
readonly_fields = ["created", "latest_activity"] readonly_fields = ["created", "latest_activity"]
search_fields = [ search_fields = [
"to_recipients", "to_recipients",
......
from typing import Iterable
from django.core.mail import get_connection
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings from django.conf import settings
from ...core import MailEngine from ...core import MailEngine
from ...models import MailLog from ...models import MailLog
BULK_EMAIL_THROTTLE = getattr(settings, "BULK_EMAIL_THROTTLE", 5)
if hasattr(settings, "EMAIL_BACKEND_ORIGINAL"):
backend = settings.EMAIL_BACKEND_ORIGINAL
else:
# Fallback to Django's default
backend = "django.core.mail.backends.smtp.EmailBackend"
if backend == "mails.backends.filebased.ModelEmailBackend":
raise AssertionError("The `EMAIL_BACKEND_ORIGINAL` cannot be the ModelEmailBackend")
connection = get_connection(backend=backend, fail_silently=False)
class Command(BaseCommand): class Command(BaseCommand):
""" """
...@@ -18,7 +34,7 @@ class Command(BaseCommand): ...@@ -18,7 +34,7 @@ class Command(BaseCommand):
help="The id in the `MailLog` table for a specific mail, Leave blank to send all", help="The id in the `MailLog` table for a specific mail, Leave blank to send all",
) )
def _process_mail(self, mail): def _render_mail(self, mail):
""" """
Render the templates for the mail if not done yet. Render the templates for the mail if not done yet.
""" """
...@@ -29,51 +45,90 @@ class Command(BaseCommand): ...@@ -29,51 +45,90 @@ class Command(BaseCommand):
body=message, body_html=html_message, status="rendered" body=message, body_html=html_message, status="rendered"
) )
def send_mails(self, mails): def _send_mail(self, connection, mail_log: "MailLog", recipients, **kwargs):
from django.core.mail import get_connection, EmailMultiAlternatives """
Build and send the mail, returning the response.
if hasattr(settings, "EMAIL_BACKEND_ORIGINAL"): """
backend = settings.EMAIL_BACKEND_ORIGINAL from django.core.mail import EmailMultiAlternatives
else:
# Fallback to Django's default
backend = "django.core.mail.backends.smtp.EmailBackend"
if backend == "mails.backends.filebased.ModelEmailBackend": mail = EmailMultiAlternatives(
raise AssertionError( mail_log.subject,
"The `EMAIL_BACKEND_ORIGINAL` cannot be the ModelEmailBackend" mail_log.body,
) mail_log.from_email,
recipients,
connection=connection,
cc=mail_log.cc_recipients,
bcc=mail_log.bcc_recipients,
reply_to=(mail_log.from_email,),
**kwargs,
)
mail.attach_alternative(mail_log.body_html, "text/html")
response = mail.send()
return response
connection = get_connection(backend=backend, fail_silently=False) def process_mail_logs(self, mail_logs: "Iterable[MailLog]"):
"""
Process the MailLogs according to their type and send the mails.
"""
count = 0 count = 0
for db_mail in mails: for mail_log in mail_logs:
if db_mail.status == "not_rendered": if mail_log.status == "not_rendered":
self._process_mail(db_mail) self._render_mail(mail_log)
db_mail.refresh_from_db() mail_log.refresh_from_db()
mail = EmailMultiAlternatives( # If single entry per mail, send regularly
db_mail.subject, if mail_log.type == mail_log.TYPE_SINGLE:
db_mail.body, response = self._send_mail(
db_mail.from_email, connection,
db_mail.to_recipients, mail_log,
cc=db_mail.cc_recipients, mail_log.to_recipients,
bcc=db_mail.bcc_recipients, )
reply_to=(db_mail.from_email,),
connection=connection, if response:
) count += 1
if db_mail.body_html: mail_log.status = "sent"
mail.attach_alternative(db_mail.body_html, "text/html") mail_log.processed = True
response = mail.send()
if response: # If bulk, build separate instances of the mail
count += 1 # for each recipient up to the throttle limit per run
db_mail.processed = True elif mail_log.type == mail_log.TYPE_BULK:
db_mail.status = "sent" if mail_log.sent_to is None:
db_mail.save() mail_log.sent_to = []
if mail_log.to_recipients is None:
self.stdout.write(
"MailLog {} has no recipients. Skipping.".format(mail_log.id)
)
continue
remaining_recipients = [
recipient
for recipient in mail_log.to_recipients
if recipient not in mail_log.sent_to
]
# Guard against empty recipients, updating the status
if not remaining_recipients:
mail_log.status = "sent"
mail_log.processed = True
mail_log.save()
continue
# For each recipient not yet sent to and up to the throttle limit
for recipient in remaining_recipients[:BULK_EMAIL_THROTTLE]:
response = self._send_mail(connection, mail_log, [recipient])
if response:
count += 1
mail_log.sent_to.append(recipient)
mail_log.save()
return count return count
def handle(self, *args, **options): def handle(self, *args, **options):
if options.get("id"): if options.get("id"):
mails = MailLog.objects.filter(id=options["id"]) mail_logs = MailLog.objects.filter(id=options["id"])
else: else:
mails = MailLog.objects.not_sent().order_by("created")[:10] mail_logs = MailLog.objects.not_sent().order_by("created")[:10]
nr_mails = self.send_mails(mails) nr_mails = self.process_mail_logs(mail_logs)
self.stdout.write("Sent {} mails.".format(nr_mails)) self.stdout.write("Sent {} mails.".format(nr_mails))
# Generated by Django 4.2.10 on 2024-05-21 12:39
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("mails", "0008_maillog_cc_recipients"),
]
operations = [
migrations.AddField(
model_name="maillog",
name="sent_to",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.EmailField(max_length=254),
blank=True,
null=True,
size=None,
),
),
migrations.AddField(
model_name="maillog",
name="type",
field=models.CharField(
choices=[("single", "Single"), ("bulk", "Bulk")],
default="single",
max_length=64,
),
),
]
...@@ -24,9 +24,22 @@ class MailLog(models.Model): ...@@ -24,9 +24,22 @@ class MailLog(models.Model):
Mails are not directly sent, but added to this table first. Mails are not directly sent, but added to this table first.
Using a cronjob, the unsent messages are eventually sent using Using a cronjob, the unsent messages are eventually sent using
the chosen MailBackend. the chosen MailBackend.
A mail can be of two types:
- Single: This log entry represents a single mail (optionally with multiple recipients)
- Bulk: This log entry represents an array of (identical) mails,
each with a single recipient, e.g. announcements or newsletters.
""" """
TYPE_SINGLE = "single"
TYPE_BULK = "bulk"
TYPE_CHOICES = (
(TYPE_SINGLE, "Single"),
(TYPE_BULK, "Bulk"),
)
processed = models.BooleanField(default=False) processed = models.BooleanField(default=False)
type = models.CharField(max_length=64, choices=TYPE_CHOICES, default=TYPE_SINGLE)
status = models.CharField( status = models.CharField(
max_length=16, choices=MAIL_STATUSES, default=MAIL_RENDERED max_length=16, choices=MAIL_STATUSES, default=MAIL_RENDERED
) )
...@@ -40,6 +53,8 @@ class MailLog(models.Model): ...@@ -40,6 +53,8 @@ class MailLog(models.Model):
cc_recipients = ArrayField(models.EmailField(), blank=True, null=True) cc_recipients = ArrayField(models.EmailField(), blank=True, null=True)
bcc_recipients = ArrayField(models.EmailField(), blank=True, null=True) bcc_recipients = ArrayField(models.EmailField(), blank=True, null=True)
sent_to = ArrayField(models.EmailField(), blank=True, null=True)
from_email = models.CharField(max_length=254, blank=True) from_email = models.CharField(max_length=254, blank=True)
subject = models.CharField(max_length=254, blank=True) subject = models.CharField(max_length=254, blank=True)
...@@ -49,16 +64,31 @@ class MailLog(models.Model): ...@@ -49,16 +64,31 @@ class MailLog(models.Model):
objects = MailLogQuerySet.as_manager() objects = MailLogQuerySet.as_manager()
def __str__(self): def __str__(self):
return "{id}. {subject} ({count} recipients)".format( nr_recipients = self.get_number_of_recipients()
if self.type == self.TYPE_SINGLE:
recipients_str = f"{nr_recipients} recipients"
elif self.type == self.TYPE_BULK:
nr_sent = len(self.sent_to) if self.sent_to else 0
recipients_str = f"{nr_sent}/{nr_recipients} recipients"
return "{id}. {subject} ({recipients_str})".format(
id=self.id, id=self.id,
subject=self.subject[:30], subject=self.subject[:30],
count=( recipients_str=recipients_str,
len(self.to_recipients)
+ len(self.bcc_recipients)
+ (len(self.cc_recipients) if self.cc_recipients else 0)
),
) )
def get_number_of_recipients(self):
def sum_optional(*args):
return sum([len(arg) for arg in args if arg])
if self.type == self.TYPE_SINGLE:
return sum_optional(
self.to_recipients, self.cc_recipients, self.bcc_recipients
)
elif self.type == self.TYPE_BULK:
return sum_optional(self.to_recipients)
return 0
def get_full_context(self): def get_full_context(self):
"""Get the full template context needed to render the template.""" """Get the full template context needed to render the template."""
if hasattr(self, "_context"): if hasattr(self, "_context"):
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment