banniere.png

Service de mailing asynchrone pour Django, Plone, etc..

Django ne déroge pas à la règle, et fourni dans comme pour la plupart des frameworks web Python, un module pour l'envoi de mail totalement... inutilisable dans des conditions de production. En effet, send_mail et send_mass_mail, les deux API de django.core.mail, envoient tous les deux les mails de manière synchrone.

L'effet est relativement désastreux sur les performances du site puisque chaque envoi de mail provoque un blocage du thread en cours, le temps de l'échange avec le serveur SMTP via telnet. Sur un site chargé, ou qui utilise de façon massive les envois de mails, l'emploi de django.core.mail est donc fortement déconseillé.

Plus globalement, tout code qui n'entre pas en ligne de compte pour calculer la page à afficher, ne doit pas s'exécuter de manière synchrone (autres exemples de calculs asynchrones: calculs des voisins, indexation, etc).

Zope 3, un peu plus mature et sophistiqué que les autres frameworks sur ce point précis, propose un module d'envoi de mails asynchrone, qui recopie les mails dans un répertoire au format Maildir, et lance un thread en charge de dépiler les mails du répertoire. L'interêt, outre l'aspect asynchrone qui permet d'accélerer les envois et de libérer le thread qui sert la page immédiatement, est la robustesse: si le serveur tombe, le thread peut reprendre son travail d'envoi lorsqu'il est relancé.

Mais cette solution reste liée au serveur d'application car le thread est lié au processus. L'autre défaut est qu'il est nécessaire, si l'on veut ajouter des informations supplémentaires aux mails à traiter, d'ajouter des en-têtes pour respecter le format RFC-2822 des mails qui sont recopiés dans la maildir (et de les retirer avant l'envoi réel des mails). Enfin, le code devient dépendant du système de fichiers, ce qui peut poser des problèmes d'infrastructure si l'on déploie ce service d'envoi de mails sur une machine tierce au serveur d'application.

Une autre solution, beaucoup plus robuste, consiste à déposer ces mails dans une table de base de donnée relationnelle (celle employée par le site dans Django, ou une dédiée pour Zope) qui est lue régulièrement par un service d'envois de mails, totalement indépendant du serveur web.

Deux tables pour le prix d'une


Pour mettre en place ce service, deux tables sont créées dans la base de données, grâce à SQLAlchemy:
mail_data = Table('mailer_mail_data', metadata,
Column('id', Integer, primary_key=True, autoincrement=True),
Column('subject', String(300)),
Column('sender', String(300)),
Column('recipients', String(300)),
Column('date', DateTime()),
Column('data', TEXT()))

mailed_data = Table('mailer_mailed_data', metadata,
Column('id', Integer, primary_key=True, autoincrement=True),
Column('subject', String(300)),
Column('sender', String(300)),
Column('original_id', Integer),
Column('recipients', String(300)),
Column('error', String(300)),
Column('data', TEXT()),
Column('date', DateTime()),
Column('status', String(10)),)
La table mail_data sert à stocker les informations sur les mails à envoyer, et la table mailed_data permet de stocker les mails envoyés, avec pour chaque un statut, si jamais l'envoi a échoué. Cette deuxième table permet aux applications de mettre en place du feedback en cas de problème d'envoi.

Travailleuse, travailleur


Le programme en charge d'envoyer les mails est un thread qui ouvre régulièrement la base pour:
  • Lire la table mail_data et envoyer les mails qu'elle contient
  • Archiver les mails envoyés, avec ou sans erreurs, dans mailed_data
  • Supprimer les mails envoyés de la table mail_data

Il est lancé comme programme indépendant vi un script run.py, qui peut être dameonisé sur le serveur grâce aux dameontools par exemple (il y a surement plus simple de nos jours avis aux experts Linux...)
class MailWorker(Thread):
"""reads the SQLDB to do the jobs"""

def __init__(self):
Thread.__init__(self)
self.is_working = False
self.running = False

def _get_mails(self):
"""returns lines of mail_data """
return mail_data.select().execute().fetchall()

def _get_message(self, mail):
"""returns a Mime"""
msg = MIMEText(b64decode(mail.data))
msg['From'] = mail.sender
msg['To'] = mail.recipients
msg['Subject'] = mail.subject

msg['Date'] = mail.date.isoformat()
return msg

def _send_mail(self, mail):
"""sends the mail"""
server = smtplib.SMTP(settings.SMTP_SERVER)
msg = self._get_message(mail)
try:
server.sendmail(msg['From'], msg['To'], msg.as_string())
finally:
server.quit()
logging.debug('mailer:message sent to %s' % msg['To'])

def _store_mail(self, mail, error=None):
"""stores the mail"""
if error is not None:
error = str(error)
ins = mailed_data.insert()
ins.execute(subject=mail.subject, sender=mail.sender,
original_id=mail.id, recipients=mail.recipients,
error=error, data=mail.data, date=datetime.now(),
status='processed')

# removes from original table
mail_data.delete().execute(id=mail.id)

def run(self):
"""called threaded"""
self.running = True
logging.debug('mailer:launched')

while self.running:
# index
self.is_working = True
try:
# get mails to send
mails = self._get_mails()
for mail in mails:
try:
# send then
self._send_mail(mail)
except Exception, e:
logging.debug('mailer:failed to send mail')
self._store_mail(mail, e)
else:
self._store_mail(mail)
finally:
self.is_working = False
time.sleep(.1)

logging.debug('mailer:stopped')

worker = None

def start_server():
"""starts the worker"""
global worker
worker = MailWorker()
worker.start()

def stop_server():
"""stops the worker"""
global worker
if worker is not None:
worker.running = False
worker.join()
worker = None

def is_working():
"""tells if the worker works"""
return worker.is_working

# will make sure the thread stops when the process quits
from atexit import register
register(stop_server)

API d'envoi de mail


Enfin, les applications peuvent se servir du module sender pour envoyer des mails. Ce dernier injecte dans la table mail_data le mail et rend la main immédiatement
def send_mail(sender, recipients, subject, msg):
"""sends the mail by storing it into the DB"""
inserter = mail_data.insert()
res = inserter.execute(subject=subject, sender=sender,
recipients=','.join(recipients),
data=b64encode(msg), date=datetime.now())

return res.last_inserted_ids()[0]

J'utilise cette API dans mes applications pour tous les envois de mail. Dans Django, elle remplace avantageusement django.core.mail.

Exemple complet


Voici la docstring du paquet que j'ai conçu
mailer
=====

Mailer provides:

- a simple method to send mails (eg: store them)
- a worker that actually sends them

Let's work on a sql data file for the tests:

>>> import settings
>>> settings.DATABASE = test_db

send mails
----------

To send a mail, the package provides the `sender` module::

>>> from sender import send_mail
>>> mail_id = send_mail(sender='tarek@ziade.org',
... recipients=['ziade.tarek@gmail.com'],
... subject='hello',
... msg='héllo')


The mail is then stored in the database, and the id returned is the
mail id in the DB.

Another API will give the status of the mail in process::

>>> from sender import mail_status
>>> mail_status(mail_id)
u'processing'

When the id is not given, the whole mailed table is returned::

>>> mail_status()
[]

We can ask for the queue size as well::

>>> from sender import mail_queue_size
>>> mail_queue_size()
1

sending mails, for real
-----------------------

A worker is in charge of sending mails::

>>> from mailer import start_server, stop_server
>>> start_server()

The mail is then processed::

>>> import time
>>> while mail_status(mail_id) == u'processing':
... time.sleep(0.2)
>>> mail_status(mail_id)
u'processed'

Let's stop the server::

>>> stop_server()

And see the status::

>>> status = mail_status()
>>> status[0]['subject']
u'hello'
>>> status[0]['status']
u'processed'


Et, Ô joie, le code est disponible, comme d'habitude, sur http://hg.programmation-python.org, dans le paquet mailer.

Vous aimez ce post ? partagez-le :


Trackback URL | Categories: coding, django, zope 0 comments - add

Tarek Ziadé. Copyright 2006. Tous droits réservés. Licence contenu site
BuzTrucs
Add to Technorati Favorites