source: announcerplugin/trunk/announcer/distributors/mail.py @ 9227

Last change on this file since 9227 was 9227, checked in by Robert Corsaro, 14 years ago

Removes some more deprecated code

File size: 25.9 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (c) 2008, Stephen Hansen
4# Copyright (c) 2009, Robert Corsaro
5# Copyright (c) 2010, Steffen Hoffmann
6#
7# All rights reserved.
8#
9# Redistribution and use in source and binary forms, with or without
10# modification, are permitted provided that the following conditions are met:
11#
12#     * Redistributions of source code must retain the above copyright
13#       notice, this list of conditions and the following disclaimer.
14#     * Redistributions in binary form must reproduce the above copyright
15#       notice, this list of conditions and the following disclaimer in the
16#       documentation and/or other materials provided with the distribution.
17#     * Neither the name of the <ORGANIZATION> nor the names of its
18#       contributors may be used to endorse or promote products derived from
19#       this software without specific prior written permission.
20#
21# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
22# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
23# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
24# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
25# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
26# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
27# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
28# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
29# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
30# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
31# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
32# ----------------------------------------------------------------------------
33
34# TODO: pick format based on subscription.  For now users will use the same
35#       format for all announcements, but in the future we can make this more
36#       flexible, since it's in the subscription table.
37import Queue
38import random
39import re
40import smtplib
41import sys
42import threading
43import time
44
45from subprocess import Popen, PIPE
46
47from email.MIMEMultipart import MIMEMultipart
48from email.MIMEText import MIMEText
49from email.Utils import formatdate, formataddr
50from email.Charset import Charset, QP, BASE64
51try:
52    from email.header import Header
53except:
54    from email.Header import Header
55
56from trac.core import *
57from trac.util.compat import set, sorted
58from trac.config import Option, BoolOption, IntOption, \
59    OrderedExtensionsOption, ChoiceOption
60from trac.config import ExtensionOption
61from trac.util import get_pkginfo, md5
62from trac.util.datefmt import to_timestamp
63from trac.util.text import to_unicode, CRLF
64
65from announcer.api import AnnouncementSystem
66from announcer.api import IAnnouncementAddressResolver
67from announcer.api import IAnnouncementDistributor
68from announcer.api import IAnnouncementFormatter
69from announcer.api import IAnnouncementPreferenceProvider
70from announcer.api import IAnnouncementProducer
71from announcer.api import _
72
73from announcer.util.mail import set_header
74from announcer.util.mail_crypto import CryptoTxt
75
76
77class IEmailSender(Interface):
78    """Extension point interface for components that allow sending e-mail."""
79
80    def send(self, from_addr, recipients, message):
81        """Send message to recipients."""
82
83
84class IAnnouncementEmailDecorator(Interface):
85    def decorate_message(event, message, decorators):
86        """Manipulate the message before it is sent on it's way.  The callee
87        should call the next decorator by popping decorators and calling the
88        popped decorator.  If decorators is empty, don't worry about it.
89        """
90
91
92class EmailDistributor(Component):
93
94    implements(IAnnouncementDistributor)
95
96    formatters = ExtensionPoint(IAnnouncementFormatter)
97    # Make ordered
98    decorators = ExtensionPoint(IAnnouncementEmailDecorator)
99
100    resolvers = OrderedExtensionsOption('announcer', 'email_address_resolvers',
101        IAnnouncementAddressResolver, 'SpecifiedEmailResolver, '\
102        'SessionEmailResolver, DefaultDomainEmailResolver',
103        """Comma seperated list of email resolver components in the order
104        they will be called.  If an email address is resolved, the remaining
105        resolvers will not be called.
106        """)
107
108    email_sender = ExtensionOption('announcer', 'email_sender',
109        IEmailSender, 'SmtpEmailSender',
110        """Name of the component implementing `IEmailSender`.
111
112        This component is used by the announcer system to send emails.
113        Currently, `SmtpEmailSender` and `SendmailEmailSender` are provided.
114        """)
115
116    enabled = BoolOption('announcer', 'email_enabled', 'true',
117        """Enable email notification.""")
118
119    email_from = Option('announcer', 'email_from', 'trac@localhost',
120        """Sender address to use in notification emails.""")
121
122    from_name = Option('announcer', 'email_from_name', '',
123        """Sender name to use in notification emails.""")
124
125    replyto = Option('announcer', 'email_replyto', 'trac@localhost',
126        """Reply-To address to use in notification emails.""")
127
128    mime_encoding = ChoiceOption('announcer', 'mime_encoding',
129        ['base64', 'qp', 'none'],
130        """Specifies the MIME encoding scheme for emails.
131
132        Valid options are 'base64' for Base64 encoding, 'qp' for
133        Quoted-Printable, and 'none' for no encoding. Note that the no encoding
134        means that non-ASCII characters in text are going to cause problems
135        with notifications.
136        """)
137
138    use_public_cc = BoolOption('announcer', 'use_public_cc', 'false',
139        """Recipients can see email addresses of other CC'ed recipients.
140
141        If this option is disabled (the default), recipients are put on BCC
142        """)
143
144    # used in email decorators, but not here
145    subject_prefix = Option('announcer', 'email_subject_prefix',
146                                 '__default__',
147        """Text to prepend to subject line of notification emails.
148
149        If the setting is not defined, then the [$project_name] prefix.
150        If no prefix is desired, then specifying an empty option
151        will disable it.
152        """)
153
154    to = Option('announcer', 'email_to', 'undisclosed-recipients: ;',
155        'Default To: field')
156
157    use_threaded_delivery = BoolOption('announcer', 'use_threaded_delivery',
158        'false',
159        """Do message delivery in a separate thread.
160
161        Enabling this will improve responsiveness for requests that end up
162        with an announcement being sent over email. It requires building
163        Python with threading support enabled-- which is usually the case.
164        To test, start Python and type 'import threading' to see
165        if it raises an error.
166        """)
167
168    default_email_format = Option('announcer', 'default_email_format',
169        'text/plain',
170        """The default mime type of the email notifications.
171
172        This can be overridden on a per user basis through the announcer
173        preferences panel.
174        """)
175
176    rcpt_allow_regexp = Option('announcer', 'rcpt_allow_regexp', '',
177        """A whitelist pattern to match any address to before adding to
178        recipients list.
179        """)
180
181    rcpt_local_regexp = Option('announcer', 'rcpt_local_regexp', '',
182        """A whitelist pattern to match any address, that should be
183        considered local.
184
185        This will be evaluated only if msg encryption is set too.
186        Recipients with matching email addresses will continue to
187        receive unencrypted email messages.
188        """)
189
190    crypto = Option('announcer', 'email_crypto', '',
191        """Enable cryptographically operation on email msg body.
192
193        Empty string, the default for unset, disables all crypto operations.
194        Valid values are:
195            sign          sign msg body with given privkey
196            encrypt       encrypt msg body with pubkeys of all recipients
197            sign,encrypt  sign, than encrypt msg body
198        """)
199
200    # get GnuPG configuration options
201    gpg_binary = Option('announcer', 'gpg_binary', 'gpg',
202        """GnuPG binary name, allows for full path too.
203
204        Value 'gpg' is same default as in python-gnupg itself.
205        For usual installations location of the gpg binary is auto-detected.
206        """)
207
208    gpg_home = Option('announcer', 'gpg_home', '',
209        """Directory containing keyring files.
210
211        In case of wrong configuration missing keyring files without content
212        will be created in the configured location, provided necessary
213        write permssion is granted for the corresponding parent directory.
214        """)
215
216    private_key = Option('announcer', 'gpg_signing_key', None,
217        """Keyid of private key (last 8 chars or more) used for signing.
218
219        If unset, a private key will be selected from keyring automagicly.
220        The password must be available i.e. provided by running gpg-agent
221        or empty (bad security). On failing to unlock the private key,
222        msg body will get emptied.
223        """)
224
225
226    def __init__(self):
227        self.delivery_queue = None
228        self._init_pref_encoding()
229
230    def get_delivery_queue(self):
231        if not self.delivery_queue:
232            self.delivery_queue = Queue.Queue()
233            thread = DeliveryThread(self.delivery_queue, self.send)
234            thread.start()
235        return self.delivery_queue
236
237    # IAnnouncementDistributor
238    def transports(self):
239        yield "email"
240
241    def formats(self, transport, realm):
242        "Find valid formats for transport and realm"
243        formats = {}
244        for f in self.formatters:
245            for style in f.styles(transport, realm):
246                formats[style] = f
247        self.log.debug(
248            "EmailDistributor has found the following formats capable "
249            "of handling '%s' of '%s': %s"%(transport, realm,
250                ', '.join(formats.keys())))
251        if not formats:
252            self.log.error("EmailDistributor is unable to continue " \
253                    "without supporting formatters.")
254        return formats
255
256    def distribute(self, transport, recipients, event):
257        found = False
258        for supported_transport in self.transports():
259            if supported_transport == transport:
260                found = True
261        if not self.enabled or not found:
262            self.log.debug("EmailDistributer email_enabled set to false")
263            return
264        fmtdict = self.formats(transport, event.realm)
265        if not fmtdict:
266            self.log.error(
267                "EmailDistributer No formats found for %s %s"%(
268                    transport, event.realm))
269            return
270        msgdict = {}
271        msgdict_encrypt = {}
272        msg_pubkey_ids = []
273        # compile pattern before use for better performance
274        RCPT_ALLOW_RE = re.compile(self.rcpt_allow_regexp)
275        RCPT_LOCAL_RE = re.compile(self.rcpt_local_regexp)
276
277        if self.crypto != '':
278            self.log.debug("EmailDistributor attempts crypto operation.")
279            self.enigma = CryptoTxt(self.gpg_binary, self.gpg_home)
280
281        for name, authed, addr in recipients:
282            fmt = name and \
283                self._get_preferred_format(event.realm, name, authed) or \
284                self._get_default_format()
285            if fmt not in fmtdict:
286                self.log.debug(("EmailDistributer format %s not available " +
287                    "for %s %s, looking for an alternative")%(
288                        fmt, transport, event.realm))
289                # If the fmt is not available for this realm, then try to find
290                # an alternative
291                oldfmt = fmt
292                fmt = None
293                for f in fmtdict.values():
294                    fmt = f.alternative_style_for(
295                            transport, event.realm, oldfmt)
296                    if fmt: break
297            if not fmt:
298                self.log.error(
299                    "EmailDistributer was unable to find a formatter " +
300                    "for format %s"%k
301                )
302                continue
303            rslvr = None
304            if name and not addr:
305                # figure out what the addr should be if it's not defined
306                for rslvr in self.resolvers:
307                    addr = rslvr.get_address_for_name(name, authed)
308                    if addr: break
309            if addr:
310                self.log.debug("EmailDistributor found the " \
311                        "address '%s' for '%s (%s)' via: %s"%(
312                        addr, name, authed and \
313                        'authenticated' or 'not authenticated',
314                        rslvr.__class__.__name__))
315
316                # ok, we found an addr, add the message
317                # but wait, check for allowed rcpt first, if set
318                if RCPT_ALLOW_RE.search(addr) is not None:
319                    # check for local recipients now
320                    local_match = RCPT_LOCAL_RE.search(addr)
321                    if self.crypto in ['encrypt', 'sign,encrypt'] and \
322                            local_match is None:
323                        # search available public keys for matching UID
324                        pubkey_ids = self.enigma.get_pubkey_ids(addr)
325                        if len(pubkey_ids) > 0:
326                            msgdict_encrypt.setdefault(fmt, set()).add((name,
327                                                            authed, addr))
328                            msg_pubkey_ids[len(msg_pubkey_ids):] = pubkey_ids
329                            self.log.debug("EmailDistributor got pubkeys " \
330                                "for %s: %s" % (addr, pubkey_ids))
331                        else:
332                            self.log.debug("EmailDistributor dropped %s " \
333                                "after missing pubkey with corresponding " \
334                                "address %s in any UID" % (name, addr))
335                    else:
336                        msgdict.setdefault(fmt, set()).add((name, authed,
337                                                            addr))
338                        if local_match is not None:
339                            self.log.debug("EmailDistributor expected " \
340                                "local delivery for %s to: %s" % (name, addr))
341                else:
342                    self.log.debug("EmailDistributor dropped %s for " \
343                        "not matching allowed recipient pattern %s" % \
344                        (addr, self.rcpt_allow_regexp))
345            else:
346                self.log.debug("EmailDistributor was unable to find an " \
347                        "address for: %s (%s)"%(name, authed and \
348                        'authenticated' or 'not authenticated'))
349        for k, v in msgdict.items():
350            if not v or not fmtdict.get(k):
351                continue
352            self.log.debug(
353                "EmailDistributor is sending event as '%s' to: %s"%(
354                    fmt, ', '.join(x[2] for x in v)))
355            self._do_send(transport, event, k, v, fmtdict[k])
356        for k, v in msgdict_encrypt.items():
357            if not v or not fmtdict.get(k):
358                continue
359            self.log.debug(
360                "EmailDistributor is sending encrypted info on event " \
361                "as '%s' to: %s"%(fmt, ', '.join(x[2] for x in v)))
362            self._do_send(transport, event, k, v, fmtdict[k], msg_pubkey_ids)
363
364    def _get_default_format(self):
365        return self.default_email_format
366
367    def _get_preferred_format(self, realm, sid, authenticated):
368        if authenticated is None:
369            authenticated = 0
370        db = self.env.get_db_cnx()
371        cursor = db.cursor()
372        cursor.execute("""
373            SELECT value
374              FROM session_attribute
375             WHERE sid=%s
376               AND authenticated=%s
377               AND name=%s
378        """, (sid, int(authenticated), 'announcer_email_format_%s' % realm))
379        result = cursor.fetchone()
380        if result:
381            chosen = result[0]
382            self.log.debug("EmailDistributor determined the preferred format" \
383                    " for '%s (%s)' is: %s"%(sid, authenticated and \
384                    'authenticated' or 'not authenticated', chosen))
385            return chosen
386        else:
387            return self._get_default_format()
388
389    def _init_pref_encoding(self):
390        self._charset = Charset()
391        self._charset.input_charset = 'utf-8'
392        pref = self.mime_encoding.lower()
393        if pref == 'base64':
394            self._charset.header_encoding = BASE64
395            self._charset.body_encoding = BASE64
396            self._charset.output_charset = 'utf-8'
397            self._charset.input_codec = 'utf-8'
398            self._charset.output_codec = 'utf-8'
399        elif pref in ['qp', 'quoted-printable']:
400            self._charset.header_encoding = QP
401            self._charset.body_encoding = QP
402            self._charset.output_charset = 'utf-8'
403            self._charset.input_codec = 'utf-8'
404            self._charset.output_codec = 'utf-8'
405        elif pref == 'none':
406            self._charset.header_encoding = None
407            self._charset.body_encoding = None
408            self._charset.input_codec = None
409            self._charset.output_charset = 'ascii'
410        else:
411            raise TracError(_('Invalid email encoding setting: %s'%pref))
412
413    def _message_id(self, realm):
414        """Generate a predictable, but sufficiently unique message ID."""
415        modtime = time.time()
416        rand = random.randint(0,32000)
417        s = '%s.%d.%d.%s' % (self.env.project_url,
418                          modtime, rand,
419                          realm.encode('ascii', 'ignore'))
420        dig = md5(s).hexdigest()
421        host = self.email_from[self.email_from.find('@') + 1:]
422        msgid = '<%03d.%s@%s>' % (len(s), dig, host)
423        return msgid
424
425    def _filter_recipients(self, rcpt):
426        return rcpt
427
428    def _do_send(self, transport, event, format, recipients, formatter,
429                 pubkey_ids=[]):
430
431        output = formatter.format(transport, event.realm, format, event)
432
433        # DEVEL: force message body plaintext style for crypto operations
434        if self.crypto != '' and pubkey_ids != []:
435            if self.crypto == 'sign':
436                output = self.enigma.sign(output, self.private_key)
437            elif self.crypto == 'encrypt':
438                output = self.enigma.encrypt(output, pubkey_ids)
439            elif self.crypto == 'sign,encrypt':
440                output = self.enigma.sign_encrypt(output, pubkey_ids,
441                                                     self.private_key)
442
443            self.log.debug(output)
444            self.log.debug(_("EmailDistributor crypto operaton successful."))
445            alternate_output = None
446        else:
447            alternate_style = formatter.alternative_style_for(
448                transport,
449                event.realm,
450                format
451            )
452            if alternate_style:
453                alternate_output = formatter.format(
454                    transport,
455                    event.realm,
456                    alternate_style,
457                    event
458                )
459            else:
460                alternate_output = None
461
462        # sanity check
463        if not self._charset.body_encoding:
464            try:
465                dummy = output.encode('ascii')
466            except UnicodeDecodeError:
467                raise TracError(_("Ticket contains non-ASCII chars. " \
468                                  "Please change encoding setting"))
469
470        rootMessage = MIMEMultipart("related")
471        # TODO: is this good? (from jabber branch)
472        #rootMessage.set_charset(self._charset)
473
474        headers = dict()
475        headers['Message-ID'] = self._message_id(event.realm)
476        headers['Date'] = formatdate()
477        from_header = formataddr((
478            self.from_name or self.env.project_name,
479            self.email_from
480        ))
481        headers['From'] = from_header
482        headers['To'] = '"%s"'%(self.to)
483        if self.use_public_cc:
484            headers['Cc'] = ', '.join([x[2] for x in recipients if x])
485        headers['Reply-To'] = self.replyto
486        for k, v in headers.iteritems():
487            set_header(rootMessage, k, v)
488
489        rootMessage.preamble = 'This is a multi-part message in MIME format.'
490        if alternate_output:
491            parentMessage = MIMEMultipart('alternative')
492            rootMessage.attach(parentMessage)
493
494            alt_msg_format = 'html' in alternate_style and 'html' or 'plain'
495            msgText = MIMEText(alternate_output, alt_msg_format)
496            parentMessage.attach(msgText)
497        else:
498            parentMessage = rootMessage
499
500        msg_format = 'html' in format and 'html' or 'plain'
501        msgText = MIMEText(output, msg_format)
502        del msgText['Content-Transfer-Encoding']
503        msgText.set_charset(self._charset)
504        parentMessage.attach(msgText)
505        decorators = self._get_decorators()
506        if len(decorators) > 0:
507            decorator = decorators.pop()
508            decorator.decorate_message(event, rootMessage, decorators)
509
510        recip_adds = [x[2] for x in recipients if x]
511        # Append any to, cc or bccs added to the recipient list
512        for field in ('To', 'Cc', 'Bcc'):
513            if rootMessage[field] and \
514                    len(str(rootMessage[field]).split(',')) > 0:
515                for addy in str(rootMessage[field]).split(','):
516                    self._add_recipient(recip_adds, addy)
517        # replace with localized bcc hint
518        if headers['To'] == 'undisclosed-recipients: ;':
519            set_header(rootMessage, 'To', _('undisclosed-recipients: ;'))
520
521        self.log.debug("Content of recip_adds: %s" %(recip_adds))
522        package = (from_header, recip_adds, rootMessage.as_string())
523        start = time.time()
524        if self.use_threaded_delivery:
525            self.get_delivery_queue().put(package)
526        else:
527            self.send(*package)
528        stop = time.time()
529        self.log.debug("EmailDistributor took %s seconds to send."\
530                %(round(stop-start,2)))
531
532    def send(self, from_addr, recipients, message):
533        """Send message to recipients via e-mail."""
534        # Ensure the message complies with RFC2822: use CRLF line endings
535        message = CRLF.join(re.split("\r?\n", message))
536        self.email_sender.send(from_addr, recipients, message)
537
538    def _get_decorators(self):
539        return self.decorators[:]
540
541    def _add_recipient(self, recipients, addy):
542        if addy.strip() != '"undisclosed-recipients: ;"':
543            recipients.append(addy)
544
545
546
547class SmtpEmailSender(Component):
548    """E-mail sender connecting to an SMTP server."""
549
550    implements(IEmailSender)
551
552    server = Option('smtp', 'server', 'localhost',
553        """SMTP server hostname to use for email notifications.""")
554
555    timeout = IntOption('smtp', 'timeout', 10,
556        """SMTP server connection timeout. (requires python-2.6)""")
557
558    port = IntOption('smtp', 'port', 25,
559        """SMTP server port to use for email notification.""")
560
561    user = Option('smtp', 'user', '',
562        """Username for SMTP server.""")
563
564    password = Option('smtp', 'password', '',
565        """Password for SMTP server.""")
566
567    use_tls = BoolOption('smtp', 'use_tls', 'false',
568        """Use SSL/TLS to send notifications over SMTP.""")
569
570    use_ssl = BoolOption('smtp', 'use_ssl', 'false',
571        """Use ssl for smtp connection.""")
572
573    debuglevel = IntOption('smtp', 'debuglevel', 0,
574        """Set to 1 for useful smtp debugging on stdout.""")
575
576
577    def send(self, from_addr, recipients, message):
578        # use defaults to make sure connect() is called in the constructor
579        smtpclass = smtplib.SMTP
580        if self.use_ssl:
581            smtpclass = smtplib.SMTP_SSL
582
583        args = {
584            'host': self.server,
585            'port': self.port
586        }
587        # timeout isn't supported until python 2.6
588        vparts = sys.version_info[0:2]
589        if vparts[0] >= 2 and vparts[1] >= 6:
590            args['timeout'] = self.timeout
591
592        smtp = smtpclass(**args)
593        smtp.set_debuglevel(self.debuglevel)
594        if self.use_tls:
595            smtp.ehlo()
596            if not smtp.esmtp_features.has_key('starttls'):
597                raise TracError(_("TLS enabled but server does not support " \
598                        "TLS"))
599            smtp.starttls()
600            smtp.ehlo()
601        if self.user:
602            smtp.login(
603                self.user.encode('utf-8'),
604                self.password.encode('utf-8')
605            )
606        smtp.sendmail(from_addr, recipients, message)
607        if self.use_tls or self.use_ssl:
608            # avoid false failure detection when the server closes
609            # the SMTP connection with TLS/SSL enabled
610            import socket
611            try:
612                smtp.quit()
613            except socket.sslerror:
614                pass
615        else:
616            smtp.quit()
617
618
619class SendmailEmailSender(Component):
620    """E-mail sender using a locally-installed sendmail program."""
621
622    implements(IEmailSender)
623
624    sendmail_path = Option('sendmail', 'sendmail_path', 'sendmail',
625        """Path to the sendmail executable.
626
627        The sendmail program must accept the `-i` and `-f` options.
628        """)
629
630    def send(self, from_addr, recipients, message):
631        self.log.info("Sending notification through sendmail at %s to %s"
632                      % (self.sendmail_path, recipients))
633        cmdline = [self.sendmail_path, "-i", "-f", from_addr]
634        cmdline.extend(recipients)
635        self.log.debug("Sendmail command line: %s" % ' '.join(cmdline))
636        try:
637            child = Popen(cmdline, bufsize=-1, stdin=PIPE, stdout=PIPE,
638                          stderr=PIPE)
639            (out, err) = child.communicate(message)
640            if child.returncode or err:
641                raise Exception("Sendmail failed with (%s, %s), command: '%s'"
642                                % (child.returncode, err.strip(), cmdline))
643        except OSError, e:
644            self.log.error("Failed to run sendmail[%s] with error %s"%\
645                    (self.sendmail_path, e))
646
647
648class DeliveryThread(threading.Thread):
649    def __init__(self, queue, sender):
650        threading.Thread.__init__(self)
651        self._sender = sender
652        self._queue = queue
653        self.setDaemon(True)
654
655    def run(self):
656        while 1:
657            sendfrom, recipients, message = self._queue.get()
658            self._sender(sendfrom, recipients, message)
659
Note: See TracBrowser for help on using the repository browser.