Search code examples
djangodjango-signalsdjango-email

Problems sending emails in post save signal in Django


I'm trying to send a email after save some model in a post_save signal but when I save the model by first time the email is not sent, if I save the model a second time the email is sent.

Notes

The model has a ManyToMany field so I can't use pre_save signal because it throws a error: <mymodel: mymodel_name object (None)>" needs to have a value for field "id" before this many-to-many relationship can be used.

What I have

Model

class Message(TimeStampedModel, models.Model):
    """Representation of a Message."""

    recipients = models.ManyToManyField(
        to=settings.AUTH_USER_MODEL, verbose_name=_("Recipients")
    )
    subject = models.CharField(
        verbose_name=_("Subject"),
        help_text=_(
            "150 numbers / letters or fewer. Only letters and numbers are allowed."
        ),
        max_length=150,
        validators=[alphabets_accents_and_numbers],
    )
    content = models.TextField(verbose_name=_("Message"))

Signal

@receiver(signal=post_save, sender=Message)
def send_message(sender, instance, **kwargs):
    """Send a email or Whatsapp if a new message is created."""
    recipient_emails = [recipient.email for recipient in instance.recipients.all()]
    attachments = []
    if instance.messagefile_set:
        for message_file in instance.messagefile_set.all():
            attachments.append((message_file.file.name, message_file.file.read()))
    send_mails(
        subject=instance.subject,
        message=instance.content,
        recipient_list=recipient_emails,
        attachments=attachments,
    )

Send mail wrapper to send emails

This is the function used in the signal to send emails.

def send_mails(
    subject: str,
    message: str,
    recipient_list: List[str],
    from_email: Optional[str] = None,
    **kwargs,
) -> int:
    """Wrapper around Django's EmailMessage done in send_mail().
    Custom from_email handling and special Auto-Submitted header.
    """
    if not from_email:
        if hasattr(settings, "DEFAULT_FROM_EMAIL"):
            from_email = settings.DEFAULT_FROM_EMAIL
        else:
            from_email = "webmaster@localhost"
    connection = kwargs.get("connection", False) or get_connection(
        username=kwargs.get("auth_user", None),
        password=kwargs.get("auth_password", None),
        fail_silently=kwargs.get("fail_silently", None),
    )
    multi_alt_kwargs = {
        "connection": connection,
        "headers": {"Auto-Submitted": "auto-generated"},
    }
    mail = EmailMessage(
        subject=subject,
        body=message,
        from_email=from_email,
        to=recipient_list,
        **multi_alt_kwargs,
    )
    attachments = kwargs.get("attachments", None)
    if attachments:
        for attachment in attachments:
            if isinstance(attachment, MIMEBase):
                mail.attach(attachment)
            else:
                mail.attach(*attachment)
    return mail.send()

Solution

  • At the time you create the Message, and immediately after you create (not update) the Message (so when the post_save signal is called), all ManyToManyFields are empty, as well as the reverse of all ForeignKeys). This makes sense since before you can create a many-to-many relation between two records, these records need to be saved to the database, otherwise these records do not have a primary key, and thus these can not be linked.

    I would advise not to use signals for this. Strictly speaking, you could try to work with an m2m_changed signal [Django-doc], like:

    from django.contrib.auth import get_user_model
    from django.db.models.signals import m2m_changed
    
    def recipient_added(sender, instance, action, pk_set, **kwargs):
        if action == 'post_add':
            recipient_emails = list(get_user_model().objects.filter(pk__in=pk_set))
        
    
    'm2m_changed'.connect(recipient_added, sender=Message.recipients.through)
    

    But nevertheless, now it is possible that the messagefile_set is saved later, and we thus still have a problem.

    I would advise to encapsulate the logic of sending emails in a function, and then call that function in the view after the object was created.

    So if you for example have a view with a MessageForm:

    def send_message(request):
        if request.method == 'POST':
            form = MessageForm(request.POST, request.FILES)
            if form.is_valid():
                message = form.save()
                send_messages(message)
    

    Here the form will not only save the object, but also the many-to-many fields it handles. So after form.save() then saving of the object is completely finished.

    In the ModelAdmin you can make use of the save_related method [Django-doc]:

    from django.contrib import admin
    
    class MessageAdmin(admin.ModelAdmin):
        
        def save_related(self, request, form, formsets, change):
            super().save_related(request, form, formsets, change)
            send_mail(form.instance)
    

    You can of course alter it. It is important to first call the .save_related() super method, since that will call the form.save() that will again handle the many-to-many relation, etc.