Skip to content

Commit

Permalink
Refactor sendmail commands into a unified namespace.
Browse files Browse the repository at this point in the history
Reorganized sendmail management commands under a single `sendmail` namespace for better structure and usability. Updated documentation, tests, and examples to reflect the new command structure, replacing `send_queued_mail` with `sendmail all` and introducing new subcommands like `batch` and `cleanup_mail`.
  • Loading branch information
michaelpoi committed Dec 27, 2024
1 parent 8ffd844 commit 0b3eb53
Show file tree
Hide file tree
Showing 10 changed files with 223 additions and 37 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,12 +221,12 @@ mail.send(
The above command will put your email on the queue so you can use the command in your webapp without slowing down the request/response cycle too much.
To actually send them out, run

`python manage.py send_queued_mail`
`python manage.py sendmail all`

You can schedule this management command to run regularly via cron:

```shell
* * * * * (/usr/bin/python manage.py send_queued_mail >> send_mail.log 2>&1)
* * * * * (/usr/bin/python manage.py sendmail all >> send_mail.log 2>&1)
```

Full documentation can be found [here](https://django-sendmail.readthedocs.io/en/latest/)
41 changes: 26 additions & 15 deletions demoapp/tests/test_commands.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import datetime
import os
import timeit
from unittest import mock

import pytest
from django.core.files.base import ContentFile
from django.core.management import call_command
from django.utils.timezone import now

from sendmail.mail import send
from sendmail.config import settings
from sendmail.models.attachment import Attachment
from sendmail.models.emailaddress import EmailAddress
from sendmail.models.emailmodel import STATUS, EmailModel
from sendmail.utils import set_recipients

def call_sendmail(*args, **kwargs):
return call_command('sendmail', *args, **kwargs)


@pytest.mark.django_db
def test_cleanup_mail_with_orphaned_attachments():
Expand All @@ -28,11 +30,11 @@ def test_cleanup_mail_with_orphaned_attachments():
email.attachments.add(attachment)
attachment_path = attachment.file.name

call_command('cleanup_mail', days=30)
call_sendmail('cleanup_mail', days=30)
assert EmailModel.objects.count() == 0
assert Attachment.objects.count() == 1

call_command('cleanup_mail', '-da', days=30)
call_sendmail('cleanup_mail', '-da', days=30)
assert EmailModel.objects.count() == 0
assert Attachment.objects.count() == 0

Expand All @@ -51,7 +53,7 @@ def test_cleanup_mail_with_orphaned_attachments():
# Simulate that the files have been deleted by accidents
os.remove(attachment_path)

call_command('cleanup_mail', '-da', days=30)
call_sendmail('cleanup_mail', '-da', days=30)
assert EmailModel.objects.count() == 0
assert Attachment.objects.count() == 0

Expand All @@ -66,41 +68,50 @@ def test_cleanup_mail():

# The command shouldn't delete today's email
email = EmailModel.objects.create(from_email='[email protected]', language='en')
call_command('cleanup_mail', days=30)
call_sendmail('cleanup_mail', days=30)
assert EmailModel.objects.count() == 1

# Email older than 30 days should be deleted
email.created = now() - datetime.timedelta(days=31)
email.save()
call_command('cleanup_mail', days=30)
call_sendmail('cleanup_mail', days=30)
assert EmailModel.objects.count() == 0


@pytest.mark.django_db
def test_send_queued_mail():
with mock.patch('django.db.connection.close', return_value=None):
call_command('send_queued_mail', processes=1)
call_sendmail('all', processes=1)

EmailModel.objects.create(from_email='[email protected]', status=STATUS.queued, language='en')
EmailModel.objects.create(from_email='[email protected]', status=STATUS.queued, language='en')
call_command('send_queued_mail', processes=1)
call_sendmail('all', processes=1)
assert EmailModel.objects.filter(status=STATUS.sent).count() == 2
assert EmailModel.objects.filter(status=STATUS.queued).count() == 0

@pytest.mark.django_db
def test_send_batch():
with mock.patch('django.db.connection.close', return_value=None):
queue = [EmailModel.objects.create(from_email='[email protected]', status=STATUS.queued, language='en') for _ in range(200)]

call_sendmail('batch', processes=1)
assert EmailModel.objects.filter(status=STATUS.sent).count() == 100
assert EmailModel.objects.filter(status=STATUS.queued).count() == 100


@pytest.mark.django_db
def test_successful_deliveries_log():
with mock.patch('django.db.connection.close', return_value=None):
email = EmailModel.objects.create(from_email='[email protected]', status=STATUS.queued, language='en')
call_command('send_queued_mail', log_level=0)
call_sendmail('all', log_level=0)
assert email.logs.count() == 0

email = EmailModel.objects.create(from_email='[email protected]', status=STATUS.queued, language='en')
call_command('send_queued_mail', log_level=1)
call_sendmail('all', log_level=1)
assert email.logs.count() == 0

email = EmailModel.objects.create(from_email='[email protected]', status=STATUS.queued, language='en')
call_command('send_queued_mail', log_level=2)
call_sendmail('all', log_level=2)
assert email.logs.count() == 1


Expand All @@ -117,22 +128,22 @@ def test_failed_deliveries_logging():
)
set_recipients(email, [recipient])

call_command('send_queued_mail', log_level=0)
call_sendmail('all', log_level=0)
assert email.logs.count() == 0

email = EmailModel.objects.create(
from_email='[email protected]', status=STATUS.queued, backend_alias='error', language='en'
)
set_recipients(email, [recipient])

call_command('send_queued_mail', log_level=1)
call_sendmail('all', log_level=1)
assert email.logs.count() == 1

email = EmailModel.objects.create(
from_email='[email protected]', status=STATUS.queued, backend_alias='error', language='en'
)
set_recipients(email, [recipient])
call_command('send_queued_mail', log_level=2)
call_sendmail('all', log_level=2)
assert email.logs.count() == 1


Expand Down
4 changes: 2 additions & 2 deletions docs/source/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,11 @@ The above command will put your email on the queue so you can use the command in
your webapp without slowing down the request/response cycle too much.
To actually send them out, run:

``python manage.py send_queued_mail``
``python manage.py sendmail all``


You can schedule this management command to run regularly via cron:

.. code-block::
* * * * * (/usr/bin/python manage.py send_queued_mail >> send_mail.log 2>&1)
* * * * * (/usr/bin/python manage.py sendmail all >> send_mail.log 2>&1)
18 changes: 15 additions & 3 deletions docs/source/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -564,9 +564,21 @@ Resulting 2 emails will be sent using ``django-ses`` backend.
Management commands
------------------------

- send_queued_mail - send queued emails, those are not successfully sent are marked as failed or requeued depending on :ref:`settings`.
Sendmail commands are available under ``sendmail`` namespace and can be triggered as following:

.. list-table:: send_queued_mail arguments
``python manage.py sendmail <subcommand> [arguments]``

example: ``python manage.py all -p 4``

- python manage.py sendmail -h - Show help message.

- python manage.py sendmail --version - Show installed version of sendmail.

- all - send all queued emails, those are not successfully sent are marked as failed or requeued depending on :ref:`settings`.

- batch - send one batch of queued emails. Batch size is defined in settings(:ref:`Batch Size`).

.. list-table:: all and batch arguments
:widths: 50 100
:header-rows: 1

Expand All @@ -593,7 +605,7 @@ Management commands
* - --batch-size or -b
- Limits number of emails being deleted in a batch. Defaults to ``1000``.

- dblocks - when ``sendmail`` is sending emails using ``send_queued_mail`` management command it blocks the entire database.
- dblocks - when ``sendmail`` is sending emails using ``all`` or ``batch`` management command it blocks the entire database.
You can use this command to manage these DB locks.

.. list-table:: dblocks
Expand Down
25 changes: 25 additions & 0 deletions sendmail/management/commands/sendmail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from collections import OrderedDict
import sendmail
from sendmail.management.commands.subcommands.base import SubcommandsCommand
from sendmail.management.commands.subcommands.cleanup_mail import Command as CleanupMailCommand
from sendmail.management.commands.subcommands.send_queued_mail import SendBatch, SendQueuedMail
from sendmail.management.commands.subcommands.dblocks import Command as DBLocksCommand

class Command(SubcommandsCommand):
command_name = "sendmail"
subcommands = OrderedDict((
('cleanup_mail', CleanupMailCommand),
('all', SendQueuedMail),
('batch', SendBatch),
('dblocks', DBLocksCommand),
))
missing_args_message = 'one of the available sub commands must be provided'

subcommand_dest = 'cmd'

def get_version(self):
return '.'.join(map(str,sendmail.VERSION))

def add_arguments(self, parser):
parser.add_argument('--version', action='version', version=self.get_version())
super().add_arguments(parser)
Empty file.
107 changes: 107 additions & 0 deletions sendmail/management/commands/subcommands/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Django-CMS: https://github.com/django-cms/django-cms/blob/develop-4/cms/management/commands/subcommands/base.py

import os
from collections import OrderedDict

from django.core.management.base import BaseCommand, CommandParser
from django.core.management.color import color_style, no_style


def add_builtin_arguments(parser):
parser.add_argument(
'--noinput',
action='store_false',
dest='interactive',
default=True,
help='Tells Django CMS to NOT prompt the user for input of any kind.'
)

# These are taking "as-is" from Django's management base
# management command.
parser.add_argument(
'-v', '--verbosity', action='store', dest='verbosity', default='1',
type=int, choices=[0, 1, 2, 3],
help='Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output'
)
parser.add_argument(
'--settings',
help=(
'The Python path to a settings module, e.g. '
'"myproject.settings.main". If this isn\'t provided, the '
'DJANGO_SETTINGS_MODULE environment variable will be used.'
),
)
parser.add_argument(
'--pythonpath', help='A directory to add to the Python path, e.g. "/home/djangoprojects/myproject".'
)
parser.add_argument(
'--traceback', action='store_true', help='Raise on CommandError exceptions'
)
parser.add_argument(
'--no-color', action='store_true', dest='no_color', default=False, help="Don't colorize the command output."
)
parser.add_argument(
'--force-color', action='store_true', dest='force_color', default=False, help="Colorize the command output."
)
parser.add_argument(
'--skip-checks', action='store_true', dest='skip_checks', default=False, help="Skip the checks."
)


class SubcommandsCommand(BaseCommand):
subcommands = OrderedDict()
instances = {}
help_string = ''
command_name = ''
stealth_options = ('interactive',)

subcommand_dest = 'subcmd'

def create_parser(self, prog_name, subcommand):
kwargs = {}
parser = CommandParser(
prog=f"{os.path.basename(prog_name)} {subcommand}",
description=self.help or None,
missing_args_message=getattr(self, "missing_args_message", None),
called_from_command_line=getattr(self, "_called_from_command_line", None),
**kwargs
)
self.add_arguments(parser)
return parser

def add_arguments(self, parser):
self.instances = {}

if self.subcommands:
stealth_options = set(self.stealth_options)
subparsers = parser.add_subparsers(dest=self.subcommand_dest)
for command, cls in self.subcommands.items():
instance = cls(self.stdout._out, self.stderr._out)
instance.style = self.style
kwargs = {}
parser_sub = subparsers.add_parser(
name=instance.command_name, help=instance.help_string,
description=instance.help_string, **kwargs
)

add_builtin_arguments(parser=parser_sub)
instance.add_arguments(parser_sub)
stealth_options.update({action.dest for action in parser_sub._actions})
self.instances[command] = instance
self.stealth_options = tuple(stealth_options)

def handle(self, *args, **options):
if options[self.subcommand_dest] in self.instances:
command = self.instances[options[self.subcommand_dest]]
if options.get('no_color'):
command.style = no_style()
command.stderr.style_func = None
if options.get('force_color'):
command.style = color_style(force_color=True)
if options.get('stdout'):
command.stdout._out = options.get('stdout')
if options.get('stderr'):
command.stderr._out = options.get('stderr')
command.handle(*args, **options)
else:
self.print_help('manage.py', 'cms')
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
from django.utils.timezone import now

from sendmail.utils import cleanup_expired_mails
from sendmail.management.commands.subcommands.base import SubcommandsCommand


class Command(BaseCommand):
help = 'Place deferred messages back in the queue.'
class Command(SubcommandsCommand):
help_string = 'Place deferred messages back in the queue.'
command_name = 'cleanup_mail'

def add_arguments(self, parser):
parser.add_argument(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
from django.utils.timezone import localtime, now

from sendmail.models.dbmutex import DBMutex
from sendmail.management.commands.subcommands.base import SubcommandsCommand


class Command(BaseCommand):
help = "Manage DB locks."
class Command(SubcommandsCommand):
help_string = "Manage DB locks."
command_name = "dblocks"

def add_arguments(self, parser):
parser.add_argument('-d', '--delete', dest='delete_expired', action='store_true',
Expand Down
Loading

0 comments on commit 0b3eb53

Please sign in to comment.