X-Git-Url: https://git.tokkee.org/?a=blobdiff_plain;f=roundup%2Fmailer.py;h=a91baf2cee5ebfeaf705c09c6e8c6a3285d29f9a;hb=e26c0e6a8f9dca8e604bae291509feec1e492354;hp=6d9e6c8d63438210e5d15aa16c7a67e3c761f4b2;hpb=a9499a6d033556980ceed799ebd0313275ca63d2;p=roundup.git diff --git a/roundup/mailer.py b/roundup/mailer.py index 6d9e6c8..a91baf2 100644 --- a/roundup/mailer.py +++ b/roundup/mailer.py @@ -1,16 +1,49 @@ -"""Sending Roundup-specific mail over SMTP.""" -# $Id: mailer.py,v 1.3 2003-10-04 11:21:47 jlgijsbers Exp $ +"""Sending Roundup-specific mail over SMTP. +""" +__docformat__ = 'restructuredtext' -import time, quopri, os, socket, smtplib, re +import time, quopri, os, socket, smtplib, re, sys, traceback, email from cStringIO import StringIO -from MimeWriter import MimeWriter -from roundup.rfc2822 import encode_header +from roundup import __version__ +from roundup.date import get_timezone, Date + +from email.Utils import formatdate, formataddr, specialsre, escapesre +from email.Message import Message +from email.Header import Header +from email.MIMEText import MIMEText +from email.MIMEMultipart import MIMEMultipart class MessageSendError(RuntimeError): pass +def encode_quopri(msg): + orig = msg.get_payload() + encdata = quopri.encodestring(orig) + msg.set_payload(encdata) + del msg['Content-Transfer-Encoding'] + msg['Content-Transfer-Encoding'] = 'quoted-printable' + +def nice_sender_header(name, address, charset): + # construct an address header so it's as human-readable as possible + # even in the presence of a non-ASCII name part + if not name: + return address + try: + encname = name.encode('ASCII') + except UnicodeEncodeError: + # use Header to encode correctly. + encname = Header(name, charset=charset).encode() + + # the important bits of formataddr() + if specialsre.search(encname): + encname = '"%s"'%escapesre.sub(r'\\\g<0>', encname) + + # now format the header as a string - don't return a Header as anonymous + # headers play poorly with Messages (eg. won't get wrapped properly) + return '%s <%s>'%(encname, address) + class Mailer: """Roundup-specific mail sending.""" def __init__(self, config): @@ -18,28 +51,70 @@ class Mailer: # set to indicate to roundup not to actually _send_ email # this var must contain a file to write the mail to - self.debug = os.environ.get('SENDMAILDEBUG', '') - - def get_standard_message(self, to, subject, author=None): + self.debug = os.environ.get('SENDMAILDEBUG', '') \ + or config["MAIL_DEBUG"] + + # set timezone so that things like formatdate(localtime=True) + # use the configured timezone + # apparently tzset doesn't exist in python under Windows, my bad. + # my pathetic attempts at googling a Windows-solution failed + # so if you're on Windows your mail won't use your configured + # timezone. + if hasattr(time, 'tzset'): + os.environ['TZ'] = get_timezone(self.config.TIMEZONE).tzname(None) + time.tzset() + + def get_standard_message(self, to, subject, author=None, multipart=False): + '''Form a standard email message from Roundup. + + "to" - recipients list + "subject" - Subject + "author" - (name, address) tuple or None for admin email + + Subject and author are encoded using the EMAIL_CHARSET from the + config (default UTF-8). + + Returns a Message object. + ''' + # encode header values if they need to be + charset = getattr(self.config, 'EMAIL_CHARSET', 'utf-8') + tracker_name = unicode(self.config.TRACKER_NAME, 'utf-8') if not author: - author = straddr((self.config.TRACKER_NAME, - self.config.ADMIN_EMAIL)) - message = StringIO() - writer = MimeWriter(message) - writer.addheader('Subject', encode_header(subject)) - writer.addheader('To', ', '.join(to)) - writer.addheader('From', author) - writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000", - time.gmtime())) + author = (tracker_name, self.config.ADMIN_EMAIL) + name = author[0] + else: + name = unicode(author[0], 'utf-8') + author = nice_sender_header(name, author[1], charset) + + if multipart: + message = MIMEMultipart() + else: + message = MIMEText("") + message.set_charset(charset) + + try: + message['Subject'] = subject.encode('ascii') + except UnicodeError: + message['Subject'] = Header(subject, charset) + message['To'] = ', '.join(to) + message['From'] = author + message['Date'] = formatdate(localtime=True) + + # add a Precedence header so autoresponders ignore us + message['Precedence'] = 'bulk' # Add a unique Roundup header to help filtering - writer.addheader('X-Roundup-Name', self.config.TRACKER_NAME) + try: + message['X-Roundup-Name'] = tracker_name.encode('ascii') + except UnicodeError: + message['X-Roundup-Name'] = Header(tracker_name, charset) + # and another one to avoid loops - writer.addheader('X-Roundup-Loop', 'hello') + message['X-Roundup-Loop'] = 'hello' + # finally, an aid to debugging problems + message['X-Roundup-Version'] = __version__ - writer.addheader('MIME-Version', '1.0') - - return message, writer + return message def standard_message(self, to, subject, content, author=None): """Send a standard message. @@ -48,78 +123,104 @@ class Mailer: - to: a list of addresses usable by rfc822.parseaddr(). - subject: the subject as a string. - content: the body of the message as a string. - - author: the sender as a string, suitable for a 'From:' header. - """ - message, writer = self.get_standard_message(to, subject, author) + - author: the sender as a (name, address) tuple - writer.addheader('Content-Transfer-Encoding', 'quoted-printable') - body = writer.startbody('text/plain; charset=utf-8') - content = StringIO(content) - quopri.encode(content, body, 0) + All strings are assumed to be UTF-8 encoded. + """ + message = self.get_standard_message(to, subject, author) + message.set_payload(content) + encode_quopri(message) + self.smtp_send(to, message.as_string()) - self.smtp_send(to, message) - def bounce_message(self, bounced_message, to, error, subject='Failed issue tracker submission'): """Bounce a message, attaching the failed submission. Arguments: - bounced_message: an RFC822 Message object. - - to: a list of addresses usable by rfc822.parseaddr(). + - to: a list of addresses usable by rfc822.parseaddr(). Might be + extended or overridden according to the config + ERROR_MESSAGES_TO setting. - error: the reason of failure as a string. - subject: the subject as a string. - + """ - message, writer = self.get_standard_message(to, subject) + # see whether we should send to the dispatcher or not + dispatcher_email = getattr(self.config, "DISPATCHER_EMAIL", + getattr(self.config, "ADMIN_EMAIL")) + error_messages_to = getattr(self.config, "ERROR_MESSAGES_TO", "user") + if error_messages_to == "dispatcher": + to = [dispatcher_email] + elif error_messages_to == "both": + to.append(dispatcher_email) - part = writer.startmultipartbody('mixed') - part = writer.nextpart() - part.addheader('Content-Transfer-Encoding', 'quoted-printable') - body = part.startbody('text/plain; charset=utf-8') - body.write('\n'.join(error)) + message = self.get_standard_message(to, subject, multipart=True) - # attach the original message to the returned message - part = writer.nextpart() - part.addheader('Content-Disposition', 'attachment') - part.addheader('Content-Description', 'Message you sent') - body = part.startbody('text/plain') + # add the error text + part = MIMEText('\n'.join(error)) + message.attach(part) + # attach the original message to the returned message + body = [] for header in bounced_message.headers: - body.write(header) - body.write('\n') + body.append(header) try: bounced_message.rewindbody() - except IOError, message: - body.write("*** couldn't include message body: %s ***" - % bounced_message) + except IOError, errmessage: + body.append("*** couldn't include message body: %s ***" % + errmessage) else: - body.write(bounced_message.fp.read()) + body.append('\n') + body.append(bounced_message.fp.read()) + part = MIMEText(''.join(body)) + message.attach(part) - writer.lastpart() - - self.smtp_send(to, message) - - def smtp_send(self, to, message): + # send + try: + self.smtp_send(to, message.as_string()) + except MessageSendError: + # squash mail sending errors when bouncing mail + # TODO this *could* be better, as we could notify admin of the + # problem (even though the vast majority of bounce errors are + # because of spam) + pass + + def exception_message(self): + '''Send a message to the admins with information about the latest + traceback. + ''' + subject = '%s: %s'%(self.config.TRACKER_NAME, sys.exc_info()[1]) + to = [self.config.ADMIN_EMAIL] + content = '\n'.join(traceback.format_exception(*sys.exc_info())) + self.standard_message(to, subject, content) + + def smtp_send(self, to, message, sender=None): """Send a message over SMTP, using roundup's config. Arguments: - to: a list of addresses usable by rfc822.parseaddr(). - message: a StringIO instance with a full message. + - sender: if not 'None', the email address to use as the + envelope sender. If 'None', the admin email is used. """ + + if not sender: + sender = self.config.ADMIN_EMAIL if self.debug: - # don't send - just write to a file - open(self.debug, 'a').write('FROM: %s\nTO: %s\n%s\n' % - (self.config.ADMIN_EMAIL, - ', '.join(to), - message.getvalue())) + # don't send - just write to a file, use unix from line so + # that resulting file can be openened in a mailer + fmt = '%a %b %m %H:%M:%S %Y' + unixfrm = 'From %s %s' % (sender, Date ('.').pretty (fmt)) + open(self.debug, 'a').write('%s\nFROM: %s\nTO: %s\n%s\n\n' % + (unixfrm, sender, + ', '.join(to), message)) else: # now try to send the message try: # send the message as admin so bounces are sent there # instead of to roundup smtp = SMTPConnection(self.config) - smtp.sendmail(self.config.ADMIN_EMAIL, to, - message.getvalue()) + smtp.sendmail(sender, to, message) except socket.error, value: raise MessageSendError("Error: couldn't send email: " "mailhost %s"%value) @@ -130,42 +231,18 @@ class SMTPConnection(smtplib.SMTP): ''' Open an SMTP connection to the mailhost specified in the config ''' def __init__(self, config): - - smtplib.SMTP.__init__(self, config.MAILHOST) - - # use TLS? - use_tls = getattr(config, 'MAILHOST_TLS', 'no') - if use_tls == 'yes': - # do we have key files too? - keyfile = getattr(config, 'MAILHOST_TLS_KEYFILE', '') - if keyfile: - certfile = getattr(config, 'MAILHOST_TLS_CERTFILE', '') - if certfile: - args = (keyfile, certfile) - else: - args = (keyfile, ) - else: - args = () - # start the TLS - self.starttls(*args) + smtplib.SMTP.__init__(self, config.MAILHOST, port=config['MAIL_PORT'], + local_hostname=config['MAIL_LOCAL_HOSTNAME']) + + # start the TLS if requested + if config["MAIL_TLS"]: + self.ehlo() + self.starttls(config["MAIL_TLS_KEYFILE"], + config["MAIL_TLS_CERTFILE"]) # ok, now do we also need to log in? - mailuser = getattr(config, 'MAILUSER', None) + mailuser = config["MAIL_USERNAME"] if mailuser: - self.login(*config.MAILUSER) - -# use the 'email' module, either imported, or our copied version -try : - from email.Utils import formataddr as straddr -except ImportError : - # code taken from the email package 2.4.3 - def straddr(pair, specialsre = re.compile(r'[][\()<>@,:;".]'), - escapesre = re.compile(r'[][\()"]')): - name, address = pair - if name: - quotes = '' - if specialsre.search(name): - quotes = '"' - name = escapesre.sub(r'\\\g<0>', name) - return '%s%s%s <%s>' % (quotes, name, quotes, address) - return address + self.login(mailuser, config["MAIL_PASSWORD"]) + +# vim: set et sts=4 sw=4 :