source: products/vendor/Products.MailHost/current/src/Products/MailHost/MailHost.py @ 3356

Last change on this file since 3356 was 3356, checked in by fenix, 12 years ago

Load 2.13.1 into vendor/Products.MailHost?/current.

  • Property svn:eol-style set to native
File size: 18.1 KB
Line 
1##############################################################################
2#
3# Copyright (c) 2002 Zope Foundation and Contributors.
4#
5# This software is subject to the provisions of the Zope Public License,
6# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
7# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
8# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
9# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
10# FOR A PARTICULAR PURPOSE.
11#
12##############################################################################
13"""SMTP mail objects
14"""
15import logging
16from os.path import realpath
17import re
18from cStringIO import StringIO
19from copy import deepcopy
20from email.Header import Header
21from email.Charset import Charset
22from email import message_from_string
23from email.Message import Message
24from email import Encoders
25try:
26    import email.utils as emailutils
27except ImportError:
28    import email.Utils as emailutils
29import email.Charset
30# We import from a private module here because the email module
31# doesn't provide a good public address list parser
32import uu
33
34from threading import Lock
35import time
36
37from AccessControl.class_init import InitializeClass
38from AccessControl.SecurityInfo import ClassSecurityInfo
39from AccessControl.Permissions import change_configuration, view
40from AccessControl.Permissions import use_mailhost_services
41from Acquisition import Implicit
42from App.special_dtml import DTMLFile
43from DateTime.DateTime import DateTime
44from Persistence import Persistent
45from OFS.role import RoleManager
46from OFS.SimpleItem import Item
47
48from zope.interface import implements
49from zope.sendmail.mailer import SMTPMailer
50from zope.sendmail.maildir import Maildir
51from zope.sendmail.delivery import DirectMailDelivery, QueuedMailDelivery, \
52                            QueueProcessorThread
53
54from interfaces import IMailHost
55from decorator import synchronized
56
57queue_threads = {}  # maps MailHost path -> queue processor threada
58
59LOG = logging.getLogger('MailHost')
60
61# Encode utf-8 emails as Quoted Printable by default
62email.Charset.add_charset("utf-8", email.Charset.QP, email.Charset.QP, "utf-8")
63formataddr = emailutils.formataddr
64parseaddr = emailutils.parseaddr
65getaddresses = emailutils.getaddresses
66CHARSET_RE = re.compile('charset=[\'"]?([\w-]+)[\'"]?', re.IGNORECASE)
67
68
69class MailHostError(Exception):
70    pass
71
72manage_addMailHostForm = DTMLFile('dtml/addMailHost_form', globals())
73
74
75def manage_addMailHost(self,
76                       id,
77                       title='',
78                       smtp_host='localhost',
79                       localhost='localhost',
80                       smtp_port=25,
81                       timeout=1.0,
82                       REQUEST=None,
83                      ):
84    """ Add a MailHost into the system.
85    """
86    i = MailHost(id, title, smtp_host, smtp_port)
87    self._setObject(id, i)
88
89    if REQUEST is not None:
90        REQUEST['RESPONSE'].redirect(self.absolute_url()+'/manage_main')
91
92add = manage_addMailHost
93
94
95class MailBase(Implicit, Item, RoleManager):
96    """a mailhost...?"""
97
98    implements(IMailHost)
99
100    meta_type = 'Mail Host'
101    manage = manage_main = DTMLFile('dtml/manageMailHost', globals())
102    manage_main._setName('manage_main')
103    index_html = None
104    security = ClassSecurityInfo()
105    smtp_uid = '' # Class attributes for smooth upgrades
106    smtp_pwd = ''
107    smtp_queue = False
108    smtp_queue_directory = '/tmp'
109    force_tls = False
110    lock = Lock()
111
112    manage_options = (
113        (
114        {'icon': '', 'label': 'Edit',
115         'action': 'manage_main',
116         'help': ('MailHost', 'Mail-Host_Edit.stx')},
117        )
118        + RoleManager.manage_options
119        + Item.manage_options
120        )
121
122    def __init__(self,
123                 id='',
124                 title='',
125                 smtp_host='localhost',
126                 smtp_port=25,
127                 force_tls=False,
128                 smtp_uid='',
129                 smtp_pwd='',
130                 smtp_queue=False,
131                 smtp_queue_directory='/tmp',
132                ):
133        """Initialize a new MailHost instance.
134        """
135        self.id = id
136        self.title = title
137        self.smtp_host = str(smtp_host)
138        self.smtp_port = int(smtp_port)
139        self.smtp_uid = smtp_uid
140        self.smtp_pwd = smtp_pwd
141        self.force_tls = force_tls
142        self.smtp_queue = smtp_queue
143        self.smtp_queue_directory = smtp_queue_directory
144
145
146    # staying for now... (backwards compatibility)
147    def _init(self, smtp_host, smtp_port):
148        self.smtp_host = smtp_host
149        self.smtp_port = smtp_port
150
151    security.declareProtected(change_configuration, 'manage_makeChanges')
152    def manage_makeChanges(self,
153                           title,
154                           smtp_host,
155                           smtp_port,
156                           smtp_uid='',
157                           smtp_pwd='',
158                           smtp_queue=False,
159                           smtp_queue_directory='/tmp',
160                           force_tls=False,
161                           REQUEST=None,
162                          ):
163        """Make the changes.
164        """
165        title = str(title)
166        smtp_host = str(smtp_host)
167        smtp_port = int(smtp_port)
168
169        self.title = title
170        self.smtp_host = smtp_host
171        self.smtp_port = smtp_port
172        self.smtp_uid = smtp_uid
173        self.smtp_pwd = smtp_pwd
174        self.force_tls = force_tls
175        self.smtp_queue = smtp_queue
176        self.smtp_queue_directory = smtp_queue_directory
177
178        # restart queue processor thread
179        if self.smtp_queue:
180            self._stopQueueProcessorThread()
181            self._startQueueProcessorThread()
182        else:
183            self._stopQueueProcessorThread()
184
185
186        if REQUEST is not None:
187            msg = 'MailHost %s updated' % self.id
188            return self.manage_main(self, REQUEST, manage_tabs_message=msg)
189
190    security.declareProtected(use_mailhost_services, 'sendTemplate')
191    def sendTemplate(trueself,
192                     self,
193                     messageTemplate,
194                     statusTemplate=None,
195                     mto=None,
196                     mfrom=None,
197                     encode=None,
198                     REQUEST=None,
199                     immediate=False,
200                     charset=None,
201                     msg_type=None,
202                    ):
203        """Render a mail template, then send it...
204        """
205        mtemplate = getattr(self, messageTemplate)
206        messageText = mtemplate(self, trueself.REQUEST)
207        trueself.send(messageText, mto=mto, mfrom=mfrom,
208                      encode=encode, immediate=immediate,
209                      charset=charset, msg_type=msg_type)
210
211        if not statusTemplate:
212            return "SEND OK"
213        try:
214            stemplate = getattr(self, statusTemplate)
215            return stemplate(self, trueself.REQUEST)
216        except:
217            return "SEND OK"
218
219    security.declareProtected(use_mailhost_services, 'send')
220    def send(self,
221             messageText,
222             mto=None,
223             mfrom=None,
224             subject=None,
225             encode=None,
226             immediate=False,
227             charset=None,
228             msg_type=None,
229            ):
230        messageText, mto, mfrom = _mungeHeaders(messageText, mto, mfrom,
231                                                subject, charset, msg_type)
232        # This encode step is mainly for BBB, encoding should be
233        # automatic if charset is passed.  The automated charset-based
234        # encoding will be preferred if both encode and charset are
235        # provided.
236        messageText = _encode(messageText, encode)
237        self._send(mfrom, mto, messageText, immediate)
238
239    # This is here for backwards compatibility only. Possibly it could
240    # be used to send messages at a scheduled future time, or via a mail queue?
241    security.declareProtected(use_mailhost_services, 'scheduledSend')
242    scheduledSend = send
243
244    security.declareProtected(use_mailhost_services, 'simple_send')
245    def simple_send(self, mto, mfrom, subject, body, immediate=False):
246        body = "From: %s\nTo: %s\nSubject: %s\n\n%s" % (
247            mfrom, mto, subject, body)
248
249        self._send(mfrom, mto, body, immediate)
250
251
252    def _makeMailer(self):
253        """ Create a SMTPMailer """
254        return SMTPMailer(hostname=self.smtp_host,
255                          port=int(self.smtp_port),
256                          username=self.smtp_uid or None,
257                          password=self.smtp_pwd or None,
258                          force_tls=self.force_tls)
259
260    security.declarePrivate('_getThreadKey')
261    def _getThreadKey(self):
262        """ Return the key used to find our processor thread.
263        """
264        return realpath(self.smtp_queue_directory)
265
266    @synchronized(lock)
267    def _stopQueueProcessorThread(self):
268        """ Stop thread for processing the mail queue.
269        """
270        key = self._getThreadKey()
271        if key in queue_threads:
272            thread = queue_threads[key]
273            thread.stop()
274            while thread.isAlive():
275                # wait until thread is really dead
276                time.sleep(0.3)
277            del queue_threads[key]
278            LOG.info('Thread for %s stopped' % key)
279
280    @synchronized(lock)
281    def _startQueueProcessorThread(self):
282        """ Start thread for processing the mail queue.
283        """
284        key = self._getThreadKey()
285        if key not in queue_threads:
286            thread = QueueProcessorThread()
287            thread.setMailer(self._makeMailer())
288            thread.setQueuePath(self.smtp_queue_directory)
289            thread.start()
290            queue_threads[key] = thread
291            LOG.info('Thread for %s started' % key)
292
293    security.declareProtected(view, 'queueLength')
294    def queueLength(self):
295        """ return length of mail queue """
296
297        try:
298            maildir = Maildir(self.smtp_queue_directory)
299            return len([item for item in maildir])
300        except ValueError:
301            return 'n/a - %s is not a maildir - please verify your ' \
302                   'configuration' % self.smtp_queue_directory
303
304
305    security.declareProtected(view, 'queueThreadAlive')
306    def queueThreadAlive(self):
307        """ return True/False is queue thread is working
308        """
309        th = queue_threads.get(self._getThreadKey())
310        if th:
311            return th.isAlive()
312        return False
313
314    security.declareProtected(change_configuration,
315                              'manage_restartQueueThread')
316    def manage_restartQueueThread(self, action='start', REQUEST=None):
317        """ Restart the queue processor thread """
318
319        if action == 'stop':
320            self._stopQueueProcessorThread()
321        elif action == 'start':
322            self._startQueueProcessorThread()
323        else:
324            raise ValueError('Unsupported action %s' % action)
325
326        if REQUEST is not None:
327            msg = 'Queue processor thread %s' % \
328                  (action == 'stop' and 'stopped' or 'started')
329            return self.manage_main(self, REQUEST, manage_tabs_message=msg)
330
331
332    security.declarePrivate('_send')
333    def _send(self, mfrom, mto, messageText, immediate=False):
334        """ Send the message """
335
336        if immediate:
337            self._makeMailer().send(mfrom, mto, messageText)
338        else:
339            if self.smtp_queue:
340                # Start queue processor thread, if necessary
341                self._startQueueProcessorThread()
342                delivery = QueuedMailDelivery(self.smtp_queue_directory)
343            else:
344                delivery = DirectMailDelivery(self._makeMailer())
345
346            delivery.send(mfrom, mto, messageText)
347
348InitializeClass(MailBase)
349
350
351class MailHost(Persistent, MailBase):
352    """persistent version"""
353
354
355def uu_encoder(msg):
356    """For BBB only, don't send uuencoded emails"""
357    orig = StringIO(msg.get_payload())
358    encdata = StringIO()
359    uu.encode(orig, encdata)
360    msg.set_payload(encdata.getvalue())
361
362# All encodings supported by mimetools for BBB
363ENCODERS = {
364    'base64': Encoders.encode_base64,
365    'quoted-printable': Encoders.encode_quopri,
366    '7bit': Encoders.encode_7or8bit,
367    '8bit': Encoders.encode_7or8bit,
368    'x-uuencode': uu_encoder,
369    'uuencode': uu_encoder,
370    'x-uue': uu_encoder,
371    'uue': uu_encoder,
372    }
373
374
375def _encode(body, encode=None):
376    """Manually sets an encoding and encodes the message if not
377    already encoded."""
378    if encode is None:
379        return body
380    mo = message_from_string(body)
381    current_coding = mo['Content-Transfer-Encoding']
382    if current_coding == encode:
383        # already encoded correctly, may have been automated
384        return body
385    if mo['Content-Transfer-Encoding'] not in ['7bit', None]:
386        raise MailHostError('Message already encoded')
387    if encode in ENCODERS:
388        ENCODERS[encode](mo)
389        if not mo['Content-Transfer-Encoding']:
390            mo['Content-Transfer-Encoding'] = encode
391        if not mo['Mime-Version']:
392            mo['Mime-Version'] = '1.0'
393    return mo.as_string()
394
395
396def _mungeHeaders(messageText, mto=None, mfrom=None, subject=None,
397                  charset=None, msg_type=None):
398    """Sets missing message headers, and deletes Bcc.
399       returns fixed message, fixed mto and fixed mfrom"""
400    # If we have been given unicode fields, attempt to encode them
401    if isinstance(messageText, unicode):
402        messageText = _try_encode(messageText, charset)
403    if isinstance(mto, unicode):
404        mto = _try_encode(mto, charset)
405    if isinstance(mfrom, unicode):
406        mfrom = _try_encode(mfrom, charset)
407    if isinstance(subject, unicode):
408        subject = _try_encode(subject, charset)
409
410    if isinstance(messageText, Message):
411        # We already have a message, make a copy to operate on
412        mo = deepcopy(messageText)
413    else:
414        # Otherwise parse the input message
415        mo = message_from_string(messageText)
416
417    if msg_type and not mo.get('Content-Type'):
418        # we don't use get_content_type because that has a default
419        # value of 'text/plain'
420        mo.set_type(msg_type)
421
422    charset = _set_recursive_charset(mo, charset=charset)
423
424    # Parameters given will *always* override headers in the messageText.
425    # This is so that you can't override or add to subscribers by adding
426    # them to # the message text.
427    if subject:
428        # remove any existing header otherwise we get two
429        del mo['Subject']
430        # Perhaps we should ignore errors here and pass 8bit strings
431        # on encoding errors
432        mo['Subject'] = Header(subject, charset, errors='replace')
433    elif not mo.get('Subject'):
434        mo['Subject'] = '[No Subject]'
435
436    if mto:
437        if isinstance(mto, basestring):
438            mto = [formataddr(addr) for addr in getaddresses((mto, ))]
439        if not mo.get('To'):
440            mo['To'] = ', '.join(str(_encode_address_string(e, charset))
441                                 for e in mto)
442    else:
443        # If we don't have recipients, extract them from the message
444        mto = []
445        for header in ('To', 'Cc', 'Bcc'):
446            v = ','.join(mo.get_all(header) or [])
447            if v:
448                mto += [formataddr(addr) for addr in getaddresses((v, ))]
449        if not mto:
450            raise MailHostError("No message recipients designated")
451
452    if mfrom:
453        # XXX: do we really want to override an explicitly set From
454        # header in the messageText
455        del mo['From']
456        mo['From'] = _encode_address_string(mfrom, charset)
457    else:
458        if mo.get('From') is None:
459            raise MailHostError("Message missing SMTP Header 'From'")
460        mfrom = mo['From']
461
462    if mo.get('Bcc'):
463        del mo['Bcc']
464
465    if not mo.get('Date'):
466        mo['Date'] = DateTime().rfc822()
467
468    return mo.as_string(), mto, mfrom
469
470
471def _set_recursive_charset(payload, charset=None):
472    """Set charset for all parts of an multipart message."""
473    def _set_payload_charset(payload, charset=None, index=None):
474        payload_from_string = False
475        if not isinstance(payload, Message):
476            payload = message_from_string(payload)
477            payload_from_string = True
478        charset_match = CHARSET_RE.search(payload['Content-Type'] or '')
479        if charset and not charset_match:
480            # Don't change the charset if already set
481            # This encodes the payload automatically based on the default
482            # encoding for the charset
483            if payload_from_string:
484                payload.get_payload()[index] = payload
485            else:
486                payload.set_charset(charset)
487        elif charset_match and not charset:
488            # If a charset parameter was provided use it for header encoding
489            # below, otherwise, try to use the charset provided in the message.
490            charset = charset_match.groups()[0]
491        return charset
492    if payload.is_multipart():
493        for index, payload in enumerate(payload.get_payload()):
494            if payload.get_filename() is None:
495                if not payload.is_multipart():
496                    charset = _set_payload_charset(payload,
497                                                   charset=charset,
498                                                   index=index)
499                else:
500                    _set_recursive_charset(payload, charset=charset)
501    else:
502        charset = _set_payload_charset(payload, charset=charset)
503    return charset
504
505
506def _try_encode(text, charset):
507    """Attempt to encode using the default charset if none is
508    provided.  Should we permit encoding errors?"""
509    if charset:
510        return text.encode(charset)
511    else:
512        return text.encode()
513
514
515def _encode_address_string(text, charset):
516    """Split the email into parts and use header encoding on the name
517    part if needed. We do this because the actual addresses need to be
518    ASCII with no encoding for most SMTP servers, but the non-address
519    parts should be encoded appropriately."""
520    header = Header()
521    name, addr = parseaddr(text)
522    try:
523        name.decode('us-ascii')
524    except UnicodeDecodeError:
525        if charset:
526            charset = Charset(charset)
527            name = charset.header_encode(name)
528    # We again replace rather than raise an error or pass an 8bit string
529    header.append(formataddr((name, addr)), errors='replace')
530    return header
Note: See TracBrowser for help on using the repository browser.