├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README ├── django_mailer ├── __init__.py ├── admin.py ├── constants.py ├── engine.py ├── lockfile.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── retry_deferred.py │ │ └── send_mail.py ├── managers.py ├── models.py ├── settings.py ├── smtp_queue.py └── tests │ ├── __init__.py │ ├── backend.py │ ├── base.py │ ├── commands.py │ └── engine.py ├── docs ├── index.txt ├── install.txt ├── settings.txt └── usage.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Chris Beaven and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | recursive-include docs * 3 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | django-mailer-2 by Chris Beaven (a fork of James Tauber's django-mailer) 2 | 3 | A reusable Django app for queuing the sending of email -------------------------------------------------------------------------------- /django_mailer/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | VERSION = (1, 2, 0) 4 | 5 | logger = logging.getLogger('django_mailer') 6 | logger.setLevel(logging.DEBUG) 7 | 8 | 9 | def get_version(): 10 | bits = [str(bit) for bit in VERSION] 11 | version = bits[0] 12 | for bit in bits[1:]: 13 | version += (bit.isdigit() and '.' or '') + bit 14 | return version 15 | 16 | 17 | def send_mail(subject, message, from_email, recipient_list, 18 | fail_silently=False, auth_user=None, auth_password=None, 19 | priority=None): 20 | """ 21 | Add a new message to the mail queue. 22 | 23 | This is a replacement for Django's ``send_mail`` core email method. 24 | 25 | The `fail_silently``, ``auth_user`` and ``auth_password`` arguments are 26 | only provided to match the signature of the emulated function. These 27 | arguments are not used. 28 | 29 | """ 30 | from django.core.mail import EmailMessage 31 | from django.utils.encoding import force_unicode 32 | 33 | subject = force_unicode(subject) 34 | email_message = EmailMessage(subject, message, from_email, 35 | recipient_list) 36 | queue_email_message(email_message, priority=priority) 37 | 38 | 39 | def mail_admins(subject, message, fail_silently=False, priority=None): 40 | """ 41 | Add one or more new messages to the mail queue addressed to the site 42 | administrators (defined in ``settings.ADMINS``). 43 | 44 | This is a replacement for Django's ``mail_admins`` core email method. 45 | 46 | The ``fail_silently`` argument is only provided to match the signature of 47 | the emulated function. This argument is not used. 48 | 49 | """ 50 | from django.conf import settings as django_settings 51 | from django.utils.encoding import force_unicode 52 | from django_mailer import constants, settings 53 | 54 | if priority is None: 55 | settings.MAIL_ADMINS_PRIORITY 56 | 57 | subject = django_settings.EMAIL_SUBJECT_PREFIX + force_unicode(subject) 58 | from_email = django_settings.SERVER_EMAIL 59 | recipient_list = [recipient[1] for recipient in django_settings.ADMINS] 60 | send_mail(subject, message, from_email, recipient_list, priority=priority) 61 | 62 | 63 | def mail_managers(subject, message, fail_silently=False, priority=None): 64 | """ 65 | Add one or more new messages to the mail queue addressed to the site 66 | managers (defined in ``settings.MANAGERS``). 67 | 68 | This is a replacement for Django's ``mail_managers`` core email method. 69 | 70 | The ``fail_silently`` argument is only provided to match the signature of 71 | the emulated function. This argument is not used. 72 | 73 | """ 74 | from django.conf import settings as django_settings 75 | from django.utils.encoding import force_unicode 76 | from django_mailer import settings 77 | 78 | if priority is None: 79 | priority = settings.MAIL_MANAGERS_PRIORITY 80 | 81 | subject = django_settings.EMAIL_SUBJECT_PREFIX + force_unicode(subject) 82 | from_email = django_settings.SERVER_EMAIL 83 | recipient_list = [recipient[1] for recipient in django_settings.MANAGERS] 84 | send_mail(subject, message, from_email, recipient_list, priority=priority) 85 | 86 | 87 | def queue_email_message(email_message, fail_silently=False, priority=None): 88 | """ 89 | Add new messages to the email queue. 90 | 91 | The ``email_message`` argument should be an instance of Django's core mail 92 | ``EmailMessage`` class. 93 | 94 | The messages can be assigned a priority in the queue by using the 95 | ``priority`` argument. 96 | 97 | The ``fail_silently`` argument is not used and is only provided to match 98 | the signature of the ``EmailMessage.send`` function which it may emulate 99 | (see ``queue_django_mail``). 100 | 101 | """ 102 | from django_mailer import constants, models, settings 103 | 104 | if constants.PRIORITY_HEADER in email_message.extra_headers: 105 | priority = email_message.extra_headers.pop(constants.PRIORITY_HEADER) 106 | priority = constants.PRIORITIES.get(priority.lower()) 107 | 108 | if priority == constants.PRIORITY_EMAIL_NOW: 109 | if constants.EMAIL_BACKEND_SUPPORT: 110 | from django.core.mail import get_connection 111 | from django_mailer.engine import send_message 112 | connection = get_connection(backend=settings.USE_BACKEND) 113 | result = send_message(email_message, smtp_connection=connection) 114 | return (result == constants.RESULT_SENT) 115 | else: 116 | return email_message.send() 117 | count = 0 118 | for to_email in email_message.recipients(): 119 | message = models.Message.objects.create( 120 | to_address=to_email, from_address=email_message.from_email, 121 | subject=email_message.subject, 122 | encoded_message=email_message.message().as_string()) 123 | queued_message = models.QueuedMessage(message=message) 124 | if priority: 125 | queued_message.priority = priority 126 | queued_message.save() 127 | count += 1 128 | return count 129 | 130 | 131 | def queue_django_mail(): 132 | """ 133 | Monkey-patch the ``send`` method of Django's ``EmailMessage`` to just queue 134 | the message rather than actually send it. 135 | 136 | This method is only useful for Django versions < 1.2. 137 | 138 | """ 139 | from django.core.mail import EmailMessage 140 | 141 | if EmailMessage.send == queue_email_message: 142 | return False 143 | EmailMessage._actual_send = EmailMessage.send 144 | EmailMessage.send = queue_email_message 145 | EmailMessage.send 146 | return True 147 | 148 | 149 | def restore_django_mail(): 150 | """ 151 | Restore the original ``send`` method of Django's ``EmailMessage`` if it has 152 | been monkey-patched (otherwise, no action is taken). 153 | 154 | This method is only useful for Django versions < 1.2. 155 | 156 | """ 157 | from django.core.mail import EmailMessage 158 | 159 | actual_send = getattr(EmailMessage, '_actual_send', None) 160 | if not actual_send: 161 | return False 162 | EmailMessage.send = actual_send 163 | del EmailMessage._actual_send 164 | return True 165 | -------------------------------------------------------------------------------- /django_mailer/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django_mailer import models 3 | 4 | 5 | class Message(admin.ModelAdmin): 6 | list_display = ('to_address', 'subject', 'date_created') 7 | list_filter = ('date_created',) 8 | search_fields = ('to_address', 'subject', 'from_address', 'encoded_message',) 9 | date_hierarchy = 'date_created' 10 | ordering = ('-date_created',) 11 | 12 | 13 | class MessageRelatedModelAdmin(admin.ModelAdmin): 14 | list_select_related = True 15 | 16 | def message__to_address(self, obj): 17 | return obj.message.to_address 18 | message__to_address.admin_order_field = 'message__to_address' 19 | 20 | def message__subject(self, obj): 21 | return obj.message.subject 22 | message__subject.admin_order_field = 'message__subject' 23 | 24 | def message__date_created(self, obj): 25 | return obj.message.to_address 26 | message__date_created.admin_order_field = 'message__date_created' 27 | 28 | 29 | class QueuedMessage(MessageRelatedModelAdmin): 30 | def not_deferred(self, obj): 31 | return not obj.deferred 32 | not_deferred.boolean = True 33 | not_deferred.admin_order_field = 'deferred' 34 | 35 | list_display = ('id', 'message__to_address', 'message__subject', 36 | 'message__date_created', 'priority', 'not_deferred') 37 | 38 | 39 | class Blacklist(admin.ModelAdmin): 40 | list_display = ('email', 'date_added') 41 | 42 | 43 | class Log(MessageRelatedModelAdmin): 44 | list_display = ('id', 'result', 'message__to_address', 'message__subject', 45 | 'date') 46 | list_filter = ('result',) 47 | list_display_links = ('id', 'result') 48 | 49 | 50 | admin.site.register(models.Message, Message) 51 | admin.site.register(models.QueuedMessage, QueuedMessage) 52 | admin.site.register(models.Blacklist, Blacklist) 53 | admin.site.register(models.Log, Log) 54 | -------------------------------------------------------------------------------- /django_mailer/constants.py: -------------------------------------------------------------------------------- 1 | PRIORITY_EMAIL_NOW = 0 2 | PRIORITY_HIGH = 1 3 | PRIORITY_NORMAL = 3 4 | PRIORITY_LOW = 5 5 | 6 | RESULT_SENT = 0 7 | RESULT_SKIPPED = 1 8 | RESULT_FAILED = 2 9 | 10 | PRIORITIES = { 11 | 'now': PRIORITY_EMAIL_NOW, 12 | 'high': PRIORITY_HIGH, 13 | 'normal': PRIORITY_NORMAL, 14 | 'low': PRIORITY_LOW, 15 | } 16 | 17 | PRIORITY_HEADER = 'X-Mail-Queue-Priority' 18 | 19 | try: 20 | from django.core.mail import get_connection 21 | EMAIL_BACKEND_SUPPORT = True 22 | except ImportError: 23 | # Django version < 1.2 24 | EMAIL_BACKEND_SUPPORT = False 25 | -------------------------------------------------------------------------------- /django_mailer/engine.py: -------------------------------------------------------------------------------- 1 | """ 2 | The "engine room" of django mailer. 3 | 4 | Methods here actually handle the sending of queued messages. 5 | 6 | """ 7 | from django.utils.encoding import smart_str 8 | from django_mailer import constants, models, settings 9 | from lockfile import FileLock, AlreadyLocked, LockTimeout 10 | from socket import error as SocketError 11 | import logging 12 | import os 13 | import smtplib 14 | import tempfile 15 | import time 16 | 17 | if constants.EMAIL_BACKEND_SUPPORT: 18 | from django.core.mail import get_connection 19 | else: 20 | from django.core.mail import SMTPConnection as get_connection 21 | 22 | LOCK_PATH = settings.LOCK_PATH or os.path.join(tempfile.gettempdir(), 23 | 'send_mail') 24 | 25 | logger = logging.getLogger('django_mailer.engine') 26 | 27 | 28 | def _message_queue(block_size): 29 | """ 30 | A generator which iterates queued messages in blocks so that new 31 | prioritised messages can be inserted during iteration of a large number of 32 | queued messages. 33 | 34 | To avoid an infinite loop, yielded messages *must* be deleted or deferred. 35 | 36 | """ 37 | def get_block(): 38 | queue = models.QueuedMessage.objects.non_deferred().select_related() 39 | if block_size: 40 | queue = queue[:block_size] 41 | return queue 42 | queue = get_block() 43 | while queue: 44 | for message in queue: 45 | yield message 46 | queue = get_block() 47 | 48 | 49 | def send_all(block_size=500, backend=None): 50 | """ 51 | Send all non-deferred messages in the queue. 52 | 53 | A lock file is used to ensure that this process can not be started again 54 | while it is already running. 55 | 56 | The ``block_size`` argument allows for queued messages to be iterated in 57 | blocks, allowing new prioritised messages to be inserted during iteration 58 | of a large number of queued messages. 59 | 60 | """ 61 | lock = FileLock(LOCK_PATH) 62 | 63 | logger.debug("Acquiring lock...") 64 | try: 65 | # lockfile has a bug dealing with a negative LOCK_WAIT_TIMEOUT (which 66 | # is the default if it's not provided) systems which use a LinkFileLock 67 | # so ensure that it is never a negative number. 68 | lock.acquire(settings.LOCK_WAIT_TIMEOUT or 0) 69 | #lock.acquire(settings.LOCK_WAIT_TIMEOUT) 70 | except AlreadyLocked: 71 | logger.debug("Lock already in place. Exiting.") 72 | return 73 | except LockTimeout: 74 | logger.debug("Waiting for the lock timed out. Exiting.") 75 | return 76 | logger.debug("Lock acquired.") 77 | 78 | start_time = time.time() 79 | 80 | sent = deferred = skipped = 0 81 | 82 | try: 83 | if constants.EMAIL_BACKEND_SUPPORT: 84 | connection = get_connection(backend=backend) 85 | else: 86 | connection = get_connection() 87 | blacklist = models.Blacklist.objects.values_list('email', flat=True) 88 | connection.open() 89 | for message in _message_queue(block_size): 90 | result = send_queued_message(message, smtp_connection=connection, 91 | blacklist=blacklist) 92 | if result == constants.RESULT_SENT: 93 | sent += 1 94 | elif result == constants.RESULT_FAILED: 95 | deferred += 1 96 | elif result == constants.RESULT_SKIPPED: 97 | skipped += 1 98 | connection.close() 99 | finally: 100 | logger.debug("Releasing lock...") 101 | lock.release() 102 | logger.debug("Lock released.") 103 | 104 | logger.debug("") 105 | if sent or deferred or skipped: 106 | log = logger.warning 107 | else: 108 | log = logger.info 109 | log("%s sent, %s deferred, %s skipped." % (sent, deferred, skipped)) 110 | logger.debug("Completed in %.2f seconds." % (time.time() - start_time)) 111 | 112 | 113 | def send_loop(empty_queue_sleep=None): 114 | """ 115 | Loop indefinitely, checking queue at intervals and sending and queued 116 | messages. 117 | 118 | The interval (in seconds) can be provided as the ``empty_queue_sleep`` 119 | argument. The default is attempted to be retrieved from the 120 | ``MAILER_EMPTY_QUEUE_SLEEP`` setting (or if not set, 30s is used). 121 | 122 | """ 123 | empty_queue_sleep = empty_queue_sleep or settings.EMPTY_QUEUE_SLEEP 124 | while True: 125 | while not models.QueuedMessage.objects.all(): 126 | logger.debug("Sleeping for %s seconds before checking queue " 127 | "again." % empty_queue_sleep) 128 | time.sleep(empty_queue_sleep) 129 | send_all() 130 | 131 | 132 | def send_queued_message(queued_message, smtp_connection=None, blacklist=None, 133 | log=True): 134 | """ 135 | Send a queued message, returning a response code as to the action taken. 136 | 137 | The response codes can be found in ``django_mailer.constants``. The 138 | response will be either ``RESULT_SKIPPED`` for a blacklisted email, 139 | ``RESULT_FAILED`` for a deferred message or ``RESULT_SENT`` for a 140 | successful sent message. 141 | 142 | To allow optimizations if multiple messages are to be sent, an SMTP 143 | connection can be provided and a list of blacklisted email addresses. 144 | Otherwise an SMTP connection will be opened to send this message and the 145 | email recipient address checked against the ``Blacklist`` table. 146 | 147 | If the message recipient is blacklisted, the message will be removed from 148 | the queue without being sent. Otherwise, the message is attempted to be 149 | sent with an SMTP failure resulting in the message being flagged as 150 | deferred so it can be tried again later. 151 | 152 | By default, a log is created as to the action. Either way, the original 153 | message is not deleted. 154 | 155 | """ 156 | message = queued_message.message 157 | if smtp_connection is None: 158 | smtp_connection = get_connection() 159 | opened_connection = False 160 | 161 | if blacklist is None: 162 | blacklisted = models.Blacklist.objects.filter(email=message.to_address) 163 | else: 164 | blacklisted = message.to_address in blacklist 165 | 166 | log_message = '' 167 | if blacklisted: 168 | logger.info("Not sending to blacklisted email: %s" % 169 | message.to_address.encode("utf-8")) 170 | queued_message.delete() 171 | result = constants.RESULT_SKIPPED 172 | else: 173 | try: 174 | logger.info("Sending message to %s: %s" % 175 | (message.to_address.encode("utf-8"), 176 | message.subject.encode("utf-8"))) 177 | opened_connection = smtp_connection.open() 178 | smtp_connection.connection.sendmail(message.from_address, 179 | [message.to_address], smart_str(message.encoded_message)) 180 | queued_message.delete() 181 | result = constants.RESULT_SENT 182 | except (SocketError, smtplib.SMTPSenderRefused, 183 | smtplib.SMTPRecipientsRefused, 184 | smtplib.SMTPAuthenticationError), err: 185 | queued_message.defer() 186 | logger.warning("Message to %s deferred due to failure: %s" % 187 | (message.to_address.encode("utf-8"), err)) 188 | log_message = unicode(err) 189 | result = constants.RESULT_FAILED 190 | if log: 191 | models.Log.objects.create(message=message, result=result, 192 | log_message=log_message) 193 | 194 | if opened_connection: 195 | smtp_connection.close() 196 | return result 197 | 198 | 199 | def send_message(email_message, smtp_connection=None): 200 | """ 201 | Send an EmailMessage, returning a response code as to the action taken. 202 | 203 | The response codes can be found in ``django_mailer.constants``. The 204 | response will be either ``RESULT_FAILED`` for a failed send or 205 | ``RESULT_SENT`` for a successfully sent message. 206 | 207 | To allow optimizations if multiple messages are to be sent, an SMTP 208 | connection can be provided. Otherwise an SMTP connection will be opened 209 | to send this message. 210 | 211 | This function does not perform any logging or queueing. 212 | 213 | """ 214 | if smtp_connection is None: 215 | smtp_connection = get_connection() 216 | opened_connection = False 217 | 218 | try: 219 | opened_connection = smtp_connection.open() 220 | smtp_connection.connection.sendmail(email_message.from_email, 221 | email_message.recipients(), 222 | email_message.message().as_string()) 223 | result = constants.RESULT_SENT 224 | except (SocketError, smtplib.SMTPSenderRefused, 225 | smtplib.SMTPRecipientsRefused, 226 | smtplib.SMTPAuthenticationError): 227 | result = constants.RESULT_FAILED 228 | 229 | if opened_connection: 230 | smtp_connection.close() 231 | return result 232 | -------------------------------------------------------------------------------- /django_mailer/lockfile.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | lockfile.py - Platform-independent advisory file locks. 4 | 5 | Requires Python 2.5 unless you apply 2.4.diff 6 | Locking is done on a per-thread basis instead of a per-process basis. 7 | 8 | Usage: 9 | 10 | >>> lock = FileLock('somefile') 11 | >>> try: 12 | ... lock.acquire() 13 | ... except AlreadyLocked: 14 | ... print 'somefile', 'is locked already.' 15 | ... except LockFailed: 16 | ... print 'somefile', 'can\\'t be locked.' 17 | ... else: 18 | ... print 'got lock' 19 | got lock 20 | >>> print lock.is_locked() 21 | True 22 | >>> lock.release() 23 | 24 | >>> lock = FileLock('somefile') 25 | >>> print lock.is_locked() 26 | False 27 | >>> with lock: 28 | ... print lock.is_locked() 29 | True 30 | >>> print lock.is_locked() 31 | False 32 | >>> # It is okay to lock twice from the same thread... 33 | >>> with lock: 34 | ... lock.acquire() 35 | ... 36 | >>> # Though no counter is kept, so you can't unlock multiple times... 37 | >>> print lock.is_locked() 38 | False 39 | 40 | Exceptions: 41 | 42 | Error - base class for other exceptions 43 | LockError - base class for all locking exceptions 44 | AlreadyLocked - Another thread or process already holds the lock 45 | LockFailed - Lock failed for some other reason 46 | UnlockError - base class for all unlocking exceptions 47 | AlreadyUnlocked - File was not locked. 48 | NotMyLock - File was locked but not by the current thread/process 49 | """ 50 | 51 | from __future__ import division 52 | 53 | import sys 54 | import socket 55 | import os 56 | import thread 57 | import threading 58 | import time 59 | import errno 60 | import urllib 61 | 62 | # Work with PEP8 and non-PEP8 versions of threading module. 63 | if not hasattr(threading, "current_thread"): 64 | threading.current_thread = threading.currentThread 65 | if not hasattr(threading.Thread, "get_name"): 66 | threading.Thread.get_name = threading.Thread.getName 67 | 68 | __all__ = ['Error', 'LockError', 'LockTimeout', 'AlreadyLocked', 69 | 'LockFailed', 'UnlockError', 'NotLocked', 'NotMyLock', 70 | 'LinkFileLock', 'MkdirFileLock', 'SQLiteFileLock'] 71 | 72 | class Error(Exception): 73 | """ 74 | Base class for other exceptions. 75 | 76 | >>> try: 77 | ... raise Error 78 | ... except Exception: 79 | ... pass 80 | """ 81 | pass 82 | 83 | class LockError(Error): 84 | """ 85 | Base class for error arising from attempts to acquire the lock. 86 | 87 | >>> try: 88 | ... raise LockError 89 | ... except Error: 90 | ... pass 91 | """ 92 | pass 93 | 94 | class LockTimeout(LockError): 95 | """Raised when lock creation fails within a user-defined period of time. 96 | 97 | >>> try: 98 | ... raise LockTimeout 99 | ... except LockError: 100 | ... pass 101 | """ 102 | pass 103 | 104 | class AlreadyLocked(LockError): 105 | """Some other thread/process is locking the file. 106 | 107 | >>> try: 108 | ... raise AlreadyLocked 109 | ... except LockError: 110 | ... pass 111 | """ 112 | pass 113 | 114 | class LockFailed(LockError): 115 | """Lock file creation failed for some other reason. 116 | 117 | >>> try: 118 | ... raise LockFailed 119 | ... except LockError: 120 | ... pass 121 | """ 122 | pass 123 | 124 | class UnlockError(Error): 125 | """ 126 | Base class for errors arising from attempts to release the lock. 127 | 128 | >>> try: 129 | ... raise UnlockError 130 | ... except Error: 131 | ... pass 132 | """ 133 | pass 134 | 135 | class NotLocked(UnlockError): 136 | """Raised when an attempt is made to unlock an unlocked file. 137 | 138 | >>> try: 139 | ... raise NotLocked 140 | ... except UnlockError: 141 | ... pass 142 | """ 143 | pass 144 | 145 | class NotMyLock(UnlockError): 146 | """Raised when an attempt is made to unlock a file someone else locked. 147 | 148 | >>> try: 149 | ... raise NotMyLock 150 | ... except UnlockError: 151 | ... pass 152 | """ 153 | pass 154 | 155 | class LockBase: 156 | """Base class for platform-specific lock classes.""" 157 | def __init__(self, path, threaded=True): 158 | """ 159 | >>> lock = LockBase('somefile') 160 | >>> lock = LockBase('somefile', threaded=False) 161 | """ 162 | self.path = path 163 | self.lock_file = os.path.abspath(path) + ".lock" 164 | self.hostname = socket.gethostname() 165 | self.pid = os.getpid() 166 | if threaded: 167 | name = threading.current_thread().get_name() 168 | tname = "%s-" % urllib.quote(name, safe="") 169 | else: 170 | tname = "" 171 | dirname = os.path.dirname(self.lock_file) 172 | self.unique_name = os.path.join(dirname, 173 | "%s.%s%s" % (self.hostname, 174 | tname, 175 | self.pid)) 176 | 177 | def acquire(self, timeout=None): 178 | """ 179 | Acquire the lock. 180 | 181 | * If timeout is omitted (or None), wait forever trying to lock the 182 | file. 183 | 184 | * If timeout > 0, try to acquire the lock for that many seconds. If 185 | the lock period expires and the file is still locked, raise 186 | LockTimeout. 187 | 188 | * If timeout <= 0, raise AlreadyLocked immediately if the file is 189 | already locked. 190 | """ 191 | raise NotImplemented("implement in subclass") 192 | 193 | def release(self): 194 | """ 195 | Release the lock. 196 | 197 | If the file is not locked, raise NotLocked. 198 | """ 199 | raise NotImplemented("implement in subclass") 200 | 201 | def is_locked(self): 202 | """ 203 | Tell whether or not the file is locked. 204 | """ 205 | raise NotImplemented("implement in subclass") 206 | 207 | def i_am_locking(self): 208 | """ 209 | Return True if this object is locking the file. 210 | """ 211 | raise NotImplemented("implement in subclass") 212 | 213 | def break_lock(self): 214 | """ 215 | Remove a lock. Useful if a locking thread failed to unlock. 216 | """ 217 | raise NotImplemented("implement in subclass") 218 | 219 | def __enter__(self): 220 | """ 221 | Context manager support. 222 | """ 223 | self.acquire() 224 | return self 225 | 226 | def __exit__(self, *_exc): 227 | """ 228 | Context manager support. 229 | """ 230 | self.release() 231 | 232 | class LinkFileLock(LockBase): 233 | """Lock access to a file using atomic property of link(2).""" 234 | 235 | def acquire(self, timeout=None): 236 | try: 237 | open(self.unique_name, "wb").close() 238 | except IOError: 239 | raise LockFailed("failed to create %s" % self.unique_name) 240 | 241 | end_time = time.time() 242 | if timeout is not None and timeout > 0: 243 | end_time += timeout 244 | 245 | while True: 246 | # Try and create a hard link to it. 247 | try: 248 | os.link(self.unique_name, self.lock_file) 249 | except OSError: 250 | # Link creation failed. Maybe we've double-locked? 251 | nlinks = os.stat(self.unique_name).st_nlink 252 | if nlinks == 2: 253 | # The original link plus the one I created == 2. We're 254 | # good to go. 255 | return 256 | else: 257 | # Otherwise the lock creation failed. 258 | if timeout is not None and time.time() > end_time: 259 | os.unlink(self.unique_name) 260 | if timeout > 0: 261 | raise LockTimeout 262 | else: 263 | raise AlreadyLocked 264 | time.sleep(timeout is not None and timeout/10 or 0.1) 265 | else: 266 | # Link creation succeeded. We're good to go. 267 | return 268 | 269 | def release(self): 270 | if not self.is_locked(): 271 | raise NotLocked 272 | elif not os.path.exists(self.unique_name): 273 | raise NotMyLock 274 | os.unlink(self.unique_name) 275 | os.unlink(self.lock_file) 276 | 277 | def is_locked(self): 278 | return os.path.exists(self.lock_file) 279 | 280 | def i_am_locking(self): 281 | return (self.is_locked() and 282 | os.path.exists(self.unique_name) and 283 | os.stat(self.unique_name).st_nlink == 2) 284 | 285 | def break_lock(self): 286 | if os.path.exists(self.lock_file): 287 | os.unlink(self.lock_file) 288 | 289 | class MkdirFileLock(LockBase): 290 | """Lock file by creating a directory.""" 291 | def __init__(self, path, threaded=True): 292 | """ 293 | >>> lock = MkdirFileLock('somefile') 294 | >>> lock = MkdirFileLock('somefile', threaded=False) 295 | """ 296 | LockBase.__init__(self, path, threaded) 297 | if threaded: 298 | tname = "%x-" % thread.get_ident() 299 | else: 300 | tname = "" 301 | # Lock file itself is a directory. Place the unique file name into 302 | # it. 303 | self.unique_name = os.path.join(self.lock_file, 304 | "%s.%s%s" % (self.hostname, 305 | tname, 306 | self.pid)) 307 | 308 | def acquire(self, timeout=None): 309 | end_time = time.time() 310 | if timeout is not None and timeout > 0: 311 | end_time += timeout 312 | 313 | if timeout is None: 314 | wait = 0.1 315 | else: 316 | wait = max(0, timeout / 10) 317 | 318 | while True: 319 | try: 320 | os.mkdir(self.lock_file) 321 | except OSError: 322 | err = sys.exc_info()[1] 323 | if err.errno == errno.EEXIST: 324 | # Already locked. 325 | if os.path.exists(self.unique_name): 326 | # Already locked by me. 327 | return 328 | if timeout is not None and time.time() > end_time: 329 | if timeout > 0: 330 | raise LockTimeout 331 | else: 332 | # Someone else has the lock. 333 | raise AlreadyLocked 334 | time.sleep(wait) 335 | else: 336 | # Couldn't create the lock for some other reason 337 | raise LockFailed("failed to create %s" % self.lock_file) 338 | else: 339 | open(self.unique_name, "wb").close() 340 | return 341 | 342 | def release(self): 343 | if not self.is_locked(): 344 | raise NotLocked 345 | elif not os.path.exists(self.unique_name): 346 | raise NotMyLock 347 | os.unlink(self.unique_name) 348 | os.rmdir(self.lock_file) 349 | 350 | def is_locked(self): 351 | return os.path.exists(self.lock_file) 352 | 353 | def i_am_locking(self): 354 | return (self.is_locked() and 355 | os.path.exists(self.unique_name)) 356 | 357 | def break_lock(self): 358 | if os.path.exists(self.lock_file): 359 | for name in os.listdir(self.lock_file): 360 | os.unlink(os.path.join(self.lock_file, name)) 361 | os.rmdir(self.lock_file) 362 | 363 | class SQLiteFileLock(LockBase): 364 | "Demonstration of using same SQL-based locking." 365 | 366 | import tempfile 367 | _fd, testdb = tempfile.mkstemp() 368 | os.close(_fd) 369 | os.unlink(testdb) 370 | del _fd, tempfile 371 | 372 | def __init__(self, path, threaded=True): 373 | LockBase.__init__(self, path, threaded) 374 | self.lock_file = unicode(self.lock_file) 375 | self.unique_name = unicode(self.unique_name) 376 | 377 | import sqlite3 378 | self.connection = sqlite3.connect(SQLiteFileLock.testdb) 379 | 380 | c = self.connection.cursor() 381 | try: 382 | c.execute("create table locks" 383 | "(" 384 | " lock_file varchar(32)," 385 | " unique_name varchar(32)" 386 | ")") 387 | except sqlite3.OperationalError: 388 | pass 389 | else: 390 | self.connection.commit() 391 | import atexit 392 | atexit.register(os.unlink, SQLiteFileLock.testdb) 393 | 394 | def acquire(self, timeout=None): 395 | end_time = time.time() 396 | if timeout is not None and timeout > 0: 397 | end_time += timeout 398 | 399 | if timeout is None: 400 | wait = 0.1 401 | elif timeout <= 0: 402 | wait = 0 403 | else: 404 | wait = timeout / 10 405 | 406 | cursor = self.connection.cursor() 407 | 408 | while True: 409 | if not self.is_locked(): 410 | # Not locked. Try to lock it. 411 | cursor.execute("insert into locks" 412 | " (lock_file, unique_name)" 413 | " values" 414 | " (?, ?)", 415 | (self.lock_file, self.unique_name)) 416 | self.connection.commit() 417 | 418 | # Check to see if we are the only lock holder. 419 | cursor.execute("select * from locks" 420 | " where unique_name = ?", 421 | (self.unique_name,)) 422 | rows = cursor.fetchall() 423 | if len(rows) > 1: 424 | # Nope. Someone else got there. Remove our lock. 425 | cursor.execute("delete from locks" 426 | " where unique_name = ?", 427 | (self.unique_name,)) 428 | self.connection.commit() 429 | else: 430 | # Yup. We're done, so go home. 431 | return 432 | else: 433 | # Check to see if we are the only lock holder. 434 | cursor.execute("select * from locks" 435 | " where unique_name = ?", 436 | (self.unique_name,)) 437 | rows = cursor.fetchall() 438 | if len(rows) == 1: 439 | # We're the locker, so go home. 440 | return 441 | 442 | # Maybe we should wait a bit longer. 443 | if timeout is not None and time.time() > end_time: 444 | if timeout > 0: 445 | # No more waiting. 446 | raise LockTimeout 447 | else: 448 | # Someone else has the lock and we are impatient.. 449 | raise AlreadyLocked 450 | 451 | # Well, okay. We'll give it a bit longer. 452 | time.sleep(wait) 453 | 454 | def release(self): 455 | if not self.is_locked(): 456 | raise NotLocked 457 | if not self.i_am_locking(): 458 | raise NotMyLock((self._who_is_locking(), self.unique_name)) 459 | cursor = self.connection.cursor() 460 | cursor.execute("delete from locks" 461 | " where unique_name = ?", 462 | (self.unique_name,)) 463 | self.connection.commit() 464 | 465 | def _who_is_locking(self): 466 | cursor = self.connection.cursor() 467 | cursor.execute("select unique_name from locks" 468 | " where lock_file = ?", 469 | (self.lock_file,)) 470 | return cursor.fetchone()[0] 471 | 472 | def is_locked(self): 473 | cursor = self.connection.cursor() 474 | cursor.execute("select * from locks" 475 | " where lock_file = ?", 476 | (self.lock_file,)) 477 | rows = cursor.fetchall() 478 | return not not rows 479 | 480 | def i_am_locking(self): 481 | cursor = self.connection.cursor() 482 | cursor.execute("select * from locks" 483 | " where lock_file = ?" 484 | " and unique_name = ?", 485 | (self.lock_file, self.unique_name)) 486 | return not not cursor.fetchall() 487 | 488 | def break_lock(self): 489 | cursor = self.connection.cursor() 490 | cursor.execute("delete from locks" 491 | " where lock_file = ?", 492 | (self.lock_file,)) 493 | self.connection.commit() 494 | 495 | if hasattr(os, "link"): 496 | FileLock = LinkFileLock 497 | else: 498 | FileLock = MkdirFileLock 499 | -------------------------------------------------------------------------------- /django_mailer/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmileyChris/django-mailer-2/1bd21d43206bf00a1474c5c28ecfabfae79e16a0/django_mailer/management/__init__.py -------------------------------------------------------------------------------- /django_mailer/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | LOGGING_LEVEL = {'0': logging.ERROR, '1': logging.WARNING, '2': logging.DEBUG} 5 | 6 | 7 | def create_handler(verbosity, message='%(message)s'): 8 | """ 9 | Create a handler which can output logged messages to the console (the log 10 | level output depends on the verbosity level). 11 | """ 12 | handler = logging.StreamHandler() 13 | handler.setLevel(LOGGING_LEVEL[verbosity]) 14 | formatter = logging.Formatter(message) 15 | handler.setFormatter(formatter) 16 | return handler 17 | -------------------------------------------------------------------------------- /django_mailer/management/commands/retry_deferred.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import NoArgsCommand 2 | from django_mailer import models 3 | from django_mailer.management.commands import create_handler 4 | from optparse import make_option 5 | import logging 6 | 7 | 8 | class Command(NoArgsCommand): 9 | help = 'Place deferred messages back in the queue.' 10 | option_list = NoArgsCommand.option_list + ( 11 | make_option('-m', '--max-retries', type='int', 12 | help="Don't reset deferred messages with more than this many " 13 | "retries."), 14 | ) 15 | 16 | def handle_noargs(self, verbosity, max_retries=None, **options): 17 | # Send logged messages to the console. 18 | logger = logging.getLogger('django_mailer') 19 | handler = create_handler(verbosity) 20 | logger.addHandler(handler) 21 | 22 | count = models.QueuedMessage.objects.retry_deferred( 23 | max_retries=max_retries) 24 | logger = logging.getLogger('django_mailer.commands.retry_deferred') 25 | logger.warning("%s deferred message%s placed back in the queue" % 26 | (count, count != 1 and 's' or '')) 27 | 28 | logger.removeHandler(handler) 29 | -------------------------------------------------------------------------------- /django_mailer/management/commands/send_mail.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import NoArgsCommand 2 | from django.db import connection 3 | from django_mailer import models, settings 4 | from django_mailer.engine import send_all 5 | from django_mailer.management.commands import create_handler 6 | from optparse import make_option 7 | import logging 8 | import sys 9 | try: 10 | from django.core.mail import get_connection 11 | EMAIL_BACKEND_SUPPORT = True 12 | except ImportError: 13 | # Django version < 1.2 14 | EMAIL_BACKEND_SUPPORT = False 15 | 16 | 17 | class Command(NoArgsCommand): 18 | help = 'Iterate the mail queue, attempting to send all mail.' 19 | option_list = NoArgsCommand.option_list + ( 20 | make_option('-b', '--block-size', default=500, type='int', 21 | help='The number of messages to iterate before checking the queue ' 22 | 'again (in case new messages have been added while the queue ' 23 | 'is being cleared).'), 24 | make_option('-c', '--count', action='store_true', default=False, 25 | help='Return the number of messages in the queue (without ' 26 | 'actually sending any)'), 27 | ) 28 | 29 | def handle_noargs(self, verbosity, block_size, count, **options): 30 | # If this is just a count request the just calculate, report and exit. 31 | if count: 32 | queued = models.QueuedMessage.objects.non_deferred().count() 33 | deferred = models.QueuedMessage.objects.non_deferred().count() 34 | sys.stdout.write('%s queued message%s (and %s deferred message%s).' 35 | '\n' % (queued, queued != 1 and 's' or '', 36 | deferred, deferred != 1 and 's' or '')) 37 | sys.exit() 38 | 39 | # Send logged messages to the console. 40 | logger = logging.getLogger('django_mailer') 41 | handler = create_handler(verbosity) 42 | logger.addHandler(handler) 43 | 44 | # if PAUSE_SEND is turned on don't do anything. 45 | if not settings.PAUSE_SEND: 46 | if EMAIL_BACKEND_SUPPORT: 47 | send_all(block_size, backend=settings.USE_BACKEND) 48 | else: 49 | send_all(block_size) 50 | else: 51 | logger = logging.getLogger('django_mailer.commands.send_mail') 52 | logger.warning("Sending is paused, exiting without sending " 53 | "queued mail.") 54 | 55 | logger.removeHandler(handler) 56 | 57 | # Stop superfluous "unexpected EOF on client connection" errors in 58 | # Postgres log files caused by the database connection not being 59 | # explicitly closed. 60 | connection.close() 61 | -------------------------------------------------------------------------------- /django_mailer/managers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from django.db import models 3 | from django_mailer import constants 4 | 5 | 6 | class QueueMethods(object): 7 | """ 8 | A mixin which provides extra methods to a QuerySet/Manager subclass. 9 | 10 | """ 11 | 12 | def exclude_future(self): 13 | """ 14 | Exclude future time-delayed messages. 15 | 16 | """ 17 | return self.exclude(date_queued__gt=datetime.datetime.now) 18 | 19 | def high_priority(self): 20 | """ 21 | Return a QuerySet of high priority queued messages. 22 | 23 | """ 24 | return self.filter(priority=constants.PRIORITY_HIGH) 25 | 26 | def normal_priority(self): 27 | """ 28 | Return a QuerySet of normal priority queued messages. 29 | 30 | """ 31 | return self.filter(priority=constants.PRIORITY_NORMAL) 32 | 33 | def low_priority(self): 34 | """ 35 | Return a QuerySet of low priority queued messages. 36 | 37 | """ 38 | return self.filter(priority=constants.PRIORITY_LOW) 39 | 40 | def non_deferred(self): 41 | """ 42 | Return a QuerySet containing all non-deferred queued messages, 43 | excluding "future" messages. 44 | 45 | """ 46 | return self.exclude_future().filter(deferred=None) 47 | 48 | def deferred(self): 49 | """ 50 | Return a QuerySet of all deferred messages in the queue, excluding 51 | "future" messages. 52 | 53 | """ 54 | return self.exclude_future().exclude(deferred=None) 55 | 56 | 57 | class QueueQuerySet(QueueMethods, models.query.QuerySet): 58 | pass 59 | 60 | 61 | class QueueManager(QueueMethods, models.Manager): 62 | use_for_related_fields = True 63 | 64 | def get_query_set(self): 65 | return QueueQuerySet(self.model, using=self._db) 66 | 67 | def retry_deferred(self, max_retries=None, new_priority=None): 68 | """ 69 | Reset the deferred flag for all deferred messages so they will be 70 | retried. 71 | 72 | If ``max_retries`` is set, deferred messages which have been retried 73 | more than this many times will *not* have their deferred flag reset. 74 | 75 | If ``new_priority`` is ``None`` (default), deferred messages retain 76 | their original priority level. Otherwise all reset deferred messages 77 | will be set to this priority level. 78 | 79 | """ 80 | queryset = self.deferred() 81 | if max_retries: 82 | queryset = queryset.filter(retries__lte=max_retries) 83 | count = queryset.count() 84 | update_kwargs = dict(deferred=None, retries=models.F('retries')+1) 85 | if new_priority is not None: 86 | update_kwargs['priority'] = new_priority 87 | queryset.update(**update_kwargs) 88 | return count 89 | -------------------------------------------------------------------------------- /django_mailer/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django_mailer import constants, managers 3 | import datetime 4 | 5 | 6 | PRIORITIES = ( 7 | (constants.PRIORITY_HIGH, 'high'), 8 | (constants.PRIORITY_NORMAL, 'normal'), 9 | (constants.PRIORITY_LOW, 'low'), 10 | ) 11 | 12 | RESULT_CODES = ( 13 | (constants.RESULT_SENT, 'success'), 14 | (constants.RESULT_SKIPPED, 'not sent (blacklisted)'), 15 | (constants.RESULT_FAILED, 'failure'), 16 | ) 17 | 18 | 19 | class Message(models.Model): 20 | """ 21 | An email message. 22 | 23 | The ``to_address``, ``from_address`` and ``subject`` fields are merely for 24 | easy of access for these common values. The ``encoded_message`` field 25 | contains the entire encoded email message ready to be sent to an SMTP 26 | connection. 27 | 28 | """ 29 | to_address = models.CharField(max_length=200) 30 | from_address = models.CharField(max_length=200) 31 | subject = models.CharField(max_length=255) 32 | 33 | encoded_message = models.TextField() 34 | date_created = models.DateTimeField(default=datetime.datetime.now) 35 | 36 | class Meta: 37 | ordering = ('date_created',) 38 | 39 | def __unicode__(self): 40 | return '%s: %s' % (self.to_address, self.subject) 41 | 42 | 43 | class QueuedMessage(models.Model): 44 | """ 45 | A queued message. 46 | 47 | Messages in the queue can be prioritised so that the higher priority 48 | messages are sent first (secondarily sorted by the oldest message). 49 | 50 | """ 51 | message = models.OneToOneField(Message, editable=False) 52 | priority = models.PositiveSmallIntegerField(choices=PRIORITIES, 53 | default=constants.PRIORITY_NORMAL) 54 | deferred = models.DateTimeField(null=True, blank=True) 55 | retries = models.PositiveIntegerField(default=0) 56 | date_queued = models.DateTimeField(default=datetime.datetime.now) 57 | 58 | objects = managers.QueueManager() 59 | 60 | class Meta: 61 | ordering = ('priority', 'date_queued') 62 | 63 | def defer(self): 64 | self.deferred = datetime.datetime.now() 65 | self.save() 66 | 67 | 68 | class Blacklist(models.Model): 69 | """ 70 | A blacklisted email address. 71 | 72 | Messages attempted to be sent to e-mail addresses which appear on this 73 | blacklist will be skipped entirely. 74 | 75 | """ 76 | email = models.EmailField(max_length=200) 77 | date_added = models.DateTimeField(default=datetime.datetime.now) 78 | 79 | class Meta: 80 | ordering = ('-date_added',) 81 | verbose_name = 'blacklisted e-mail address' 82 | verbose_name_plural = 'blacklisted e-mail addresses' 83 | 84 | 85 | class Log(models.Model): 86 | """ 87 | A log used to record the activity of a queued message. 88 | 89 | """ 90 | message = models.ForeignKey(Message, editable=False) 91 | result = models.PositiveSmallIntegerField(choices=RESULT_CODES) 92 | date = models.DateTimeField(default=datetime.datetime.now) 93 | log_message = models.TextField() 94 | 95 | class Meta: 96 | ordering = ('-date',) 97 | -------------------------------------------------------------------------------- /django_mailer/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django_mailer import constants 3 | 4 | # Provide a way of temporarily pausing the sending of mail. 5 | PAUSE_SEND = getattr(settings, "MAILER_PAUSE_SEND", False) 6 | 7 | USE_BACKEND = getattr(settings, 'MAILER_USE_BACKEND', 8 | 'django.core.mail.backends.smtp.EmailBackend') 9 | 10 | # Default priorities for the mail_admins and mail_managers methods. 11 | MAIL_ADMINS_PRIORITY = getattr(settings, 'MAILER_MAIL_ADMINS_PRIORITY', 12 | constants.PRIORITY_HIGH) 13 | MAIL_MANAGERS_PRIORITY = getattr(settings, 'MAILER_MAIL_MANAGERS_PRIORITY', 14 | None) 15 | 16 | # When queue is empty, how long to wait (in seconds) before checking again. 17 | EMPTY_QUEUE_SLEEP = getattr(settings, "MAILER_EMPTY_QUEUE_SLEEP", 30) 18 | 19 | # Lock timeout value. how long to wait for the lock to become available. 20 | # default behavior is to never wait for the lock to be available. 21 | LOCK_WAIT_TIMEOUT = max(getattr(settings, "MAILER_LOCK_WAIT_TIMEOUT", 0), 0) 22 | 23 | # An optional alternate lock path, potentially useful if you have multiple 24 | # projects running on the same server. 25 | LOCK_PATH = getattr(settings, "MAILER_LOCK_PATH", None) 26 | -------------------------------------------------------------------------------- /django_mailer/smtp_queue.py: -------------------------------------------------------------------------------- 1 | """Queued SMTP email backend class.""" 2 | 3 | from django.core.mail.backends.base import BaseEmailBackend 4 | 5 | 6 | class EmailBackend(BaseEmailBackend): 7 | ''' 8 | A wrapper that manages a queued SMTP system. 9 | 10 | ''' 11 | 12 | def send_messages(self, email_messages): 13 | """ 14 | Add new messages to the email queue. 15 | 16 | The ``email_messages`` argument should be one or more instances 17 | of Django's core mail ``EmailMessage`` class. 18 | 19 | The messages can be assigned a priority in the queue by including 20 | an 'X-Mail-Queue-Priority' header set to one of the option strings 21 | in models.PRIORITIES. 22 | 23 | """ 24 | if not email_messages: 25 | return 26 | 27 | from django_mailer import queue_email_message 28 | 29 | num_sent = 0 30 | for email_message in email_messages: 31 | queue_email_message(email_message) 32 | num_sent += 1 33 | return num_sent 34 | -------------------------------------------------------------------------------- /django_mailer/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from django_mailer.tests.commands import TestCommands 2 | from django_mailer.tests.engine import LockTest #COULD DROP THIS TEST 3 | from django_mailer.tests.backend import TestBackend -------------------------------------------------------------------------------- /django_mailer/tests/backend.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings as django_settings 2 | from django.core import mail 3 | from django_mailer import models, constants, queue_email_message 4 | from django_mailer.tests.base import MailerTestCase 5 | 6 | 7 | class TestBackend(MailerTestCase): 8 | """ 9 | Backend tests for the django_mailer app. 10 | 11 | For Django versions less than 1.2, these tests are still run but they just 12 | use the queue_email_message funciton rather than directly sending messages. 13 | 14 | """ 15 | 16 | def setUp(self): 17 | super(TestBackend, self).setUp() 18 | if constants.EMAIL_BACKEND_SUPPORT: 19 | if hasattr(django_settings, 'EMAIL_BACKEND'): 20 | self.old_email_backend = django_settings.EMAIL_BACKEND 21 | else: 22 | self.old_email_backend = None 23 | django_settings.EMAIL_BACKEND = 'django_mailer.smtp_queue.'\ 24 | 'EmailBackend' 25 | 26 | def tearDown(self): 27 | super(TestBackend, self).tearDown() 28 | if constants.EMAIL_BACKEND_SUPPORT: 29 | if self.old_email_backend: 30 | django_settings.EMAIL_BACKEND = self.old_email_backend 31 | else: 32 | delattr(django_settings, 'EMAIL_BACKEND') 33 | 34 | def send_message(self, msg): 35 | if constants.EMAIL_BACKEND_SUPPORT: 36 | msg.send() 37 | else: 38 | queue_email_message(msg) 39 | 40 | def testQueuedMessagePriorities(self): 41 | # high priority message 42 | msg = mail.EmailMessage(subject='subject', body='body', 43 | from_email='mail_from@abc.com', to=['mail_to@abc.com'], 44 | headers={'X-Mail-Queue-Priority': 'high'}) 45 | self.send_message(msg) 46 | 47 | # low priority message 48 | msg = mail.EmailMessage(subject='subject', body='body', 49 | from_email='mail_from@abc.com', to=['mail_to@abc.com'], 50 | headers={'X-Mail-Queue-Priority': 'low'}) 51 | self.send_message(msg) 52 | 53 | # normal priority message 54 | msg = mail.EmailMessage(subject='subject', body='body', 55 | from_email='mail_from@abc.com', to=['mail_to@abc.com'], 56 | headers={'X-Mail-Queue-Priority': 'normal'}) 57 | self.send_message(msg) 58 | 59 | # normal priority message (no explicit priority header) 60 | msg = mail.EmailMessage(subject='subject', body='body', 61 | from_email='mail_from@abc.com', to=['mail_to@abc.com']) 62 | self.send_message(msg) 63 | 64 | qs = models.QueuedMessage.objects.high_priority() 65 | self.assertEqual(qs.count(), 1) 66 | queued_message = qs[0] 67 | self.assertEqual(queued_message.priority, constants.PRIORITY_HIGH) 68 | 69 | qs = models.QueuedMessage.objects.low_priority() 70 | self.assertEqual(qs.count(), 1) 71 | queued_message = qs[0] 72 | self.assertEqual(queued_message.priority, constants.PRIORITY_LOW) 73 | 74 | qs = models.QueuedMessage.objects.normal_priority() 75 | self.assertEqual(qs.count(), 2) 76 | for queued_message in qs: 77 | self.assertEqual(queued_message.priority, 78 | constants.PRIORITY_NORMAL) 79 | 80 | def testSendMessageNowPriority(self): 81 | # NOW priority message 82 | msg = mail.EmailMessage(subject='subject', body='body', 83 | from_email='mail_from@abc.com', to=['mail_to@abc.com'], 84 | headers={'X-Mail-Queue-Priority': 'now'}) 85 | self.send_message(msg) 86 | 87 | queued_messages = models.QueuedMessage.objects.all() 88 | self.assertEqual(queued_messages.count(), 0) 89 | self.assertEqual(len(mail.outbox), 1) 90 | -------------------------------------------------------------------------------- /django_mailer/tests/base.py: -------------------------------------------------------------------------------- 1 | from django.core import mail 2 | from django.test import TestCase 3 | from django_mailer import queue_email_message 4 | try: 5 | from django.core.mail import backends 6 | EMAIL_BACKEND_SUPPORT = True 7 | except ImportError: 8 | # Django version < 1.2 9 | EMAIL_BACKEND_SUPPORT = False 10 | 11 | class FakeConnection(object): 12 | """ 13 | A fake SMTP connection which diverts emails to the test buffer rather than 14 | sending. 15 | 16 | """ 17 | def sendmail(self, *args, **kwargs): 18 | """ 19 | Divert an email to the test buffer. 20 | 21 | """ 22 | #FUTURE: the EmailMessage attributes could be found by introspecting 23 | # the encoded message. 24 | message = mail.EmailMessage('SUBJECT', 'BODY', 'FROM', ['TO']) 25 | mail.outbox.append(message) 26 | 27 | 28 | if EMAIL_BACKEND_SUPPORT: 29 | class TestEmailBackend(backends.base.BaseEmailBackend): 30 | ''' 31 | An EmailBackend used in place of the default 32 | django.core.mail.backends.smtp.EmailBackend. 33 | 34 | ''' 35 | def __init__(self, fail_silently=False, **kwargs): 36 | super(TestEmailBackend, self).__init__(fail_silently=fail_silently) 37 | self.connection = FakeConnection() 38 | 39 | def send_messages(self, email_messages): 40 | pass 41 | 42 | 43 | class MailerTestCase(TestCase): 44 | """ 45 | A base class for Django Mailer test cases which diverts emails to the test 46 | buffer and provides some helper methods. 47 | 48 | """ 49 | def setUp(self): 50 | if EMAIL_BACKEND_SUPPORT: 51 | self.saved_email_backend = backends.smtp.EmailBackend 52 | backends.smtp.EmailBackend = TestEmailBackend 53 | else: 54 | connection = mail.SMTPConnection 55 | if hasattr(connection, 'connection'): 56 | connection.pretest_connection = connection.connection 57 | connection.connection = FakeConnection() 58 | 59 | def tearDown(self): 60 | if EMAIL_BACKEND_SUPPORT: 61 | backends.smtp.EmailBackend = self.saved_email_backend 62 | else: 63 | connection = mail.SMTPConnection 64 | if hasattr(connection, 'pretest_connection'): 65 | connection.connection = connection.pretest_connection 66 | 67 | def queue_message(self, subject='test', message='a test message', 68 | from_email='sender@djangomailer', 69 | recipient_list=['recipient@djangomailer'], 70 | priority=None): 71 | email_message = mail.EmailMessage(subject, message, from_email, 72 | recipient_list) 73 | return queue_email_message(email_message, priority=priority) 74 | -------------------------------------------------------------------------------- /django_mailer/tests/commands.py: -------------------------------------------------------------------------------- 1 | from django.core import mail 2 | from django.core.management import call_command 3 | from django_mailer import models 4 | from django_mailer.tests.base import MailerTestCase 5 | import datetime 6 | 7 | 8 | class TestCommands(MailerTestCase): 9 | """ 10 | A test case for management commands provided by django-mailer. 11 | 12 | """ 13 | def test_send_mail(self): 14 | """ 15 | The ``send_mail`` command initiates the sending of messages in the 16 | queue. 17 | 18 | """ 19 | # No action is taken if there are no messages. 20 | call_command('send_mail', verbosity='0') 21 | # Any (non-deferred) queued messages will be sent. 22 | self.queue_message() 23 | self.queue_message() 24 | self.queue_message(subject='deferred') 25 | models.QueuedMessage.objects\ 26 | .filter(message__subject__startswith='deferred')\ 27 | .update(deferred=datetime.datetime.now()) 28 | queued_messages = models.QueuedMessage.objects.all() 29 | self.assertEqual(queued_messages.count(), 3) 30 | self.assertEqual(len(mail.outbox), 0) 31 | call_command('send_mail', verbosity='0') 32 | self.assertEqual(queued_messages.count(), 1) 33 | self.assertEqual(len(mail.outbox), 2) 34 | 35 | def test_retry_deferred(self): 36 | """ 37 | The ``retry_deferred`` command places deferred messages back in the 38 | queue. 39 | 40 | """ 41 | self.queue_message() 42 | self.queue_message(subject='deferred') 43 | self.queue_message(subject='deferred 2') 44 | self.queue_message(subject='deferred 3') 45 | models.QueuedMessage.objects\ 46 | .filter(message__subject__startswith='deferred')\ 47 | .update(deferred=datetime.datetime.now()) 48 | non_deferred_messages = models.QueuedMessage.objects.non_deferred() 49 | # Deferred messages are returned to the queue (nothing is sent). 50 | self.assertEqual(non_deferred_messages.count(), 1) 51 | call_command('retry_deferred', verbosity='0') 52 | self.assertEqual(non_deferred_messages.count(), 4) 53 | self.assertEqual(len(mail.outbox), 0) 54 | # Check the --max-retries logic. 55 | models.QueuedMessage.objects\ 56 | .filter(message__subject='deferred')\ 57 | .update(deferred=datetime.datetime.now(), retries=2) 58 | models.QueuedMessage.objects\ 59 | .filter(message__subject='deferred 2')\ 60 | .update(deferred=datetime.datetime.now(), retries=3) 61 | models.QueuedMessage.objects\ 62 | .filter(message__subject='deferred 3')\ 63 | .update(deferred=datetime.datetime.now(), retries=4) 64 | self.assertEqual(non_deferred_messages.count(), 1) 65 | call_command('retry_deferred', verbosity='0', max_retries=3) 66 | self.assertEqual(non_deferred_messages.count(), 3) 67 | -------------------------------------------------------------------------------- /django_mailer/tests/engine.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django_mailer import engine, settings 3 | from django_mailer.lockfile import FileLock 4 | from StringIO import StringIO 5 | import logging 6 | import time 7 | 8 | 9 | class LockTest(TestCase): 10 | """ 11 | Tests for Django Mailer trying to send mail when the lock is already in 12 | place. 13 | """ 14 | 15 | def setUp(self): 16 | # Create somewhere to store the log debug output. 17 | self.output = StringIO() 18 | # Create a log handler which can capture the log debug output. 19 | self.handler = logging.StreamHandler(self.output) 20 | self.handler.setLevel(logging.DEBUG) 21 | formatter = logging.Formatter('%(message)s') 22 | self.handler.setFormatter(formatter) 23 | # Add the log handler. 24 | logger = logging.getLogger('django_mailer') 25 | logger.addHandler(self.handler) 26 | 27 | # Set the LOCK_WAIT_TIMEOUT to the default value. 28 | self.original_timeout = settings.LOCK_WAIT_TIMEOUT 29 | settings.LOCK_WAIT_TIMEOUT = 0 30 | 31 | # Use a test lock-file name in case something goes wrong, then emulate 32 | # that the lock file has already been acquired by another process. 33 | self.original_lock_path = engine.LOCK_PATH 34 | engine.LOCK_PATH += '.mailer-test' 35 | self.lock = FileLock(engine.LOCK_PATH) 36 | self.lock.unique_name += '.mailer_test' 37 | self.lock.acquire(0) 38 | 39 | def tearDown(self): 40 | # Remove the log handler. 41 | logger = logging.getLogger('django_mailer') 42 | logger.removeHandler(self.handler) 43 | 44 | # Revert the LOCK_WAIT_TIMEOUT to it's original value. 45 | settings.LOCK_WAIT_TIMEOUT = self.original_timeout 46 | 47 | # Revert the lock file unique name 48 | engine.LOCK_PATH = self.original_lock_path 49 | self.lock.release() 50 | 51 | def test_locked(self): 52 | # Acquire the lock so that send_all will fail. 53 | engine.send_all() 54 | self.output.seek(0) 55 | self.assertEqual(self.output.readlines()[-1].strip(), 56 | 'Lock already in place. Exiting.') 57 | # Try with a timeout. 58 | settings.LOCK_WAIT_TIMEOUT = .1 59 | engine.send_all() 60 | self.output.seek(0) 61 | self.assertEqual(self.output.readlines()[-1].strip(), 62 | 'Waiting for the lock timed out. Exiting.') 63 | 64 | def test_locked_timeoutbug(self): 65 | # We want to emulate the lock acquiring taking no time, so the next 66 | # three calls to time.time() always return 0 (then set it back to the 67 | # real function). 68 | original_time = time.time 69 | global time_call_count 70 | time_call_count = 0 71 | def fake_time(): 72 | global time_call_count 73 | time_call_count = time_call_count + 1 74 | if time_call_count >= 3: 75 | time.time = original_time 76 | return 0 77 | time.time = fake_time 78 | try: 79 | engine.send_all() 80 | self.output.seek(0) 81 | self.assertEqual(self.output.readlines()[-1].strip(), 82 | 'Lock already in place. Exiting.') 83 | finally: 84 | time.time = original_time 85 | -------------------------------------------------------------------------------- /docs/index.txt: -------------------------------------------------------------------------------- 1 | =============== 2 | django-mailer-2 3 | =============== 4 | 5 | Django mailer is used to queue e-mails. This allows the emails to be sent 6 | asynchronously (by the use of a command extension) rather than blocking the 7 | response. 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | 13 | install 14 | usage 15 | settings 16 | 17 | Django Mailer fork 18 | ================== 19 | 20 | django-mailer-2 is a fork of James Tauber's `django-mailer`__. 21 | 22 | .. __: http://github.com/jtauber/django-mailer 23 | 24 | History 25 | ------- 26 | 27 | Chris Beaven started a fork of django-mailer and it got to the point when it 28 | would be rather difficult to merge back. The fork was then renamed to the 29 | completely unimaginative "django mailer 2". 30 | 31 | In hindsight, this was a bad naming choice as it wasn't supposed to reflect 32 | that this is a "2.0" version or the like, simply an alternative. 33 | 34 | The application namespace was changed (django-mailer-2 uses ``django_mailer`` 35 | whereas django-mailer uses ``mailer``), allowing the two products to 36 | technically live side by side in harmony. One of the motivations in doing this 37 | was to make the transition simpler for projects which are using django-mailer 38 | (or to transition back, if someone doesn't like this one). 39 | 40 | Differences 41 | ----------- 42 | 43 | Some of the larger differences in django-mailer-2: 44 | 45 | * It saves a rendered version of the email instead - so HTML and other 46 | attachments are handled fine 47 | 48 | * The models were completely refactored for a better logical separation of 49 | data. 50 | 51 | * It provides a hook to override (aka "monkey patch") the Django ``send_mail``, 52 | ``mail_admins`` and ``mail_manager`` functions. 53 | 54 | Credit 55 | ------ 56 | 57 | At the time of the fork, the primary authors of django-mailer were James Tauber 58 | and Brian Rosner. The additional contributors included Michael Trier, Doug 59 | Napoleone and Jannis Leidel. -------------------------------------------------------------------------------- /docs/install.txt: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | An obvious prerequisite of Django Mailer 2 is Django - 1.1 is the 6 | minimum supported version. 7 | 8 | 9 | Installing django-mailer-2 10 | ========================== 11 | 12 | Download and install from http://github.com/SmileyChris/django-mailer-2.git 13 | 14 | If you're using pip__ and a virtual environment, this usually looks like:: 15 | 16 | pip install -e git+http://github.com/SmileyChris/django-mailer-2.git#egg=django-mailer-2 17 | 18 | .. __: http://pip.openplans.org/ 19 | 20 | Or for a manual installation, once you've downloaded the package, unpack it 21 | and run the ``setup.py`` installation script:: 22 | 23 | python setup.py install 24 | 25 | 26 | Configuring your project 27 | ======================== 28 | 29 | In your Django project's settings module, add django_mailer to your 30 | ``INSTALLED_APPS`` setting:: 31 | 32 | INSTALLED_APPS = ( 33 | ... 34 | 'django_mailer', 35 | ) 36 | 37 | Note that django mailer doesn't implicitly queue all django mail (unless you 38 | tell it to). More details can be found in the usage documentation. 39 | -------------------------------------------------------------------------------- /docs/settings.txt: -------------------------------------------------------------------------------- 1 | ======== 2 | Settings 3 | ======== 4 | 5 | Following is a list of settings which can be added to your Django settings 6 | configuration. All settings are optional and the default value is listed for 7 | each. 8 | 9 | 10 | MAILER_PAUSE_SEND 11 | ----------------- 12 | Provides a way of temporarily pausing the sending of mail. Defaults to 13 | ``False``. 14 | 15 | If this setting is ``True``, mail will not be sent when the ``send_mail`` 16 | command is called. 17 | 18 | 19 | MAILER_USE_BACKEND 20 | ------------------ 21 | *Django 1.2 setting* 22 | 23 | The mail backend to use when actually sending e-mail. 24 | Defaults to ``'django.core.mail.backends.smtp.EmailBackend'`` 25 | 26 | 27 | MAILER_MAIL_ADMINS_PRIORITY 28 | --------------------------- 29 | The default priority for messages sent via the ``mail_admins`` function of 30 | Django Mailer 2. 31 | 32 | The default value is ``constants.PRIORITY_HIGH``. Valid values are ``None`` 33 | or any of the priority from ``django_mailer.constants``: 34 | ``PRIORITY_EMAIL_NOW``, ``PRIORITY_HIGH``, ``PRIORITY_NORMAL`` or 35 | ``PRIORITY_LOW``. 36 | 37 | 38 | MAILER_MAIL_MANAGERS_PRIORITY 39 | ----------------------------- 40 | The default priority for messages sent via the ``mail_managers`` function of 41 | Django Mailer 2. 42 | 43 | The default value is ``None``. Valid values are the same as for 44 | `MAILER_MAIL_ADMINS_PRIORITY`_. 45 | 46 | 47 | MAILER_EMPTY_QUEUE_SLEEP 48 | ------------------------ 49 | For use with the ``django_mailer.engine.send_loop`` helper function. 50 | 51 | When queue is empty, this setting controls how long to wait (in seconds) 52 | before checking again. Defaults to ``30``. 53 | 54 | 55 | MAILER_LOCK_WAIT_TIMEOUT 56 | ------------------------ 57 | A lock is set while the ``send_mail`` command is being run. This controls the 58 | maximum number of seconds the command should wait if a lock is already in 59 | place. 60 | 61 | The default value is ``-1`` which means to never wait for the lock to be 62 | available. 63 | -------------------------------------------------------------------------------- /docs/usage.txt: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | django-mailer-2 is asynchronous so in addition to putting mail on the queue you 6 | need to periodically tell it to clear the queue and actually send the mail. 7 | 8 | The latter is done via a command extension. 9 | 10 | 11 | Putting Mail On The Queue (Django 1.2 or higher) 12 | ================================================= 13 | 14 | In settings.py, configure Django's EMAIL_BACKEND setting like so: 15 | 16 | EMAIL_BACKEND = 'django_mailer.smtp_queue.EmailBackend' 17 | 18 | If you don't need message priority support you can call send_mail like 19 | you normally would in Django:: 20 | 21 | send_mail(subject, message_body, settings.DEFAULT_FROM_EMAIL, recipients) 22 | 23 | If you need prioritized messages, create an instance of EmailMessage 24 | and specify {'X-Mail-Queue-Priority': ''} in the ``headers`` parameter, 25 | where is one of: 26 | 27 | 'now' - do not queue, send immediately 28 | 'high' - high priority 29 | 'normal' - standard priority - this is the default. 30 | 'low' - low priority 31 | 32 | If you don't specify a priority, the message is sent at 'normal' priority. 33 | 34 | 35 | Putting Mail On The Queue (Django 1.1 or earlier) 36 | ================================================= 37 | 38 | Because django-mailer currently uses the same function signature as Django's 39 | core mail support you can do the following in your code:: 40 | 41 | # favour django-mailer-2 but fall back to django.core.mail 42 | from django.conf import settings 43 | 44 | if "django_mailer" in settings.INSTALLED_APPS: 45 | from django_mailer import send_mail 46 | else: 47 | from django.core.mail import send_mail 48 | 49 | and then just call send_mail like you normally would in Django:: 50 | 51 | send_mail(subject, message_body, settings.DEFAULT_FROM_EMAIL, recipients) 52 | 53 | Additionally you can send all the admins as specified in the ``ADMIN`` 54 | setting by calling:: 55 | 56 | mail_admins(subject, message_body) 57 | 58 | or all managers as defined in the ``MANAGERS`` setting by calling:: 59 | 60 | mail_managers(subject, message_body) 61 | 62 | 63 | Clear Queue With Command Extensions 64 | =================================== 65 | 66 | With mailer in your INSTALLED_APPS, there will be two new manage.py commands 67 | you can run: 68 | 69 | * ``send_mail`` will clear the current message queue. If there are any 70 | failures, they will be marked deferred and will not be attempted again by 71 | ``send_mail``. 72 | 73 | * ``retry_deferred`` will move any deferred mail back into the normal queue 74 | (so it will be attempted again on the next ``send_mail``). 75 | 76 | You may want to set these up via cron to run regularly:: 77 | 78 | * * * * * (cd $PROJECT; python manage.py send_mail >> $PROJECT/cron_mail.log 2>&1) 79 | 0,20,40 * * * * (cd $PROJECT; python manage.py retry_deferred >> $PROJECT/cron_mail_deferred.log 2>&1) 80 | 81 | This attempts to send mail every minute with a retry on failure every 20 minutes. 82 | 83 | ``manage.py send_mail`` uses a lock file in case clearing the queue takes 84 | longer than the interval between calling ``manage.py send_mail``. 85 | 86 | Note that if your project lives inside a virtualenv, you also have to execute 87 | this command from the virtualenv. The same, naturally, applies also if you're 88 | executing it with cron. -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | from django_mailer import get_version 3 | 4 | 5 | setup( 6 | name='django-mailer-2', 7 | version=get_version(), 8 | description=("A reusable Django app for queueing the sending of email " 9 | "(forked from James Tauber's django-mailer)"), 10 | long_description=open('docs/usage.txt').read(), 11 | author='Chris Beaven', 12 | author_email='smileychris@gmail.com', 13 | url='http://github.com/SmileyChris/django-mailer-2', 14 | packages=[ 15 | 'django_mailer', 16 | 'django_mailer.management', 17 | 'django_mailer.management.commands', 18 | 'django_mailer.tests', 19 | ], 20 | classifiers=[ 21 | 'Development Status :: 4 - Beta', 22 | 'Environment :: Web Environment', 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: MIT License', 25 | 'Operating System :: OS Independent', 26 | 'Programming Language :: Python', 27 | 'Framework :: Django', 28 | ] 29 | ) 30 | --------------------------------------------------------------------------------