Code

fix TLS handling with some SMTP servers (issues 2484879 and 1912923)
[roundup.git] / roundup / mailer.py
1 """Sending Roundup-specific mail over SMTP.
2 """
3 __docformat__ = 'restructuredtext'
4 # $Id: mailer.py,v 1.22 2008-07-21 01:44:58 richard Exp $
6 import time, quopri, os, socket, smtplib, re, sys, traceback, email
8 from cStringIO import StringIO
10 from roundup import __version__
11 from roundup.date import get_timezone
13 from email.Utils import formatdate, formataddr
14 from email.Message import Message
15 from email.Header import Header
16 from email.MIMEText import MIMEText
17 from email.MIMEMultipart import MIMEMultipart
19 class MessageSendError(RuntimeError):
20     pass
22 def encode_quopri(msg):
23     orig = msg.get_payload()
24     encdata = quopri.encodestring(orig)
25     msg.set_payload(encdata)
26     del msg['Content-Transfer-Encoding']
27     msg['Content-Transfer-Encoding'] = 'quoted-printable'
29 class Mailer:
30     """Roundup-specific mail sending."""
31     def __init__(self, config):
32         self.config = config
34         # set to indicate to roundup not to actually _send_ email
35         # this var must contain a file to write the mail to
36         self.debug = os.environ.get('SENDMAILDEBUG', '') \
37             or config["MAIL_DEBUG"]
39         # set timezone so that things like formatdate(localtime=True)
40         # use the configured timezone
41         # apparently tzset doesn't exist in python under Windows, my bad.
42         # my pathetic attempts at googling a Windows-solution failed
43         # so if you're on Windows your mail won't use your configured
44         # timezone.
45         if hasattr(time, 'tzset'):
46             os.environ['TZ'] = get_timezone(self.config.TIMEZONE).tzname(None)
47             time.tzset()
49     def get_standard_message(self, to, subject, author=None, multipart=False):
50         '''Form a standard email message from Roundup.
52         "to"      - recipients list
53         "subject" - Subject
54         "author"  - (name, address) tuple or None for admin email
56         Subject and author are encoded using the EMAIL_CHARSET from the
57         config (default UTF-8).
59         Returns a Message object.
60         '''
61         # encode header values if they need to be
62         charset = getattr(self.config, 'EMAIL_CHARSET', 'utf-8')
63         tracker_name = unicode(self.config.TRACKER_NAME, 'utf-8')
64         if not author:
65             author = formataddr((tracker_name, self.config.ADMIN_EMAIL))
66         else:
67             name = unicode(author[0], 'utf-8')
68             author = formataddr((name, author[1]))
70         if multipart:
71             message = MIMEMultipart()
72         else:
73             message = Message()
74             message.set_type('text/plain')
75             message.set_charset(charset)
77         try:
78             message['Subject'] = subject.encode('ascii')
79         except UnicodeError:
80             message['Subject'] = Header(subject, charset)
81         message['To'] = ', '.join(to)
82         try:
83             message['From'] = author.encode('ascii')
84         except UnicodeError:
85             message['From'] = Header(author, charset)
86         message['Date'] = formatdate(localtime=True)
88         # add a Precedence header so autoresponders ignore us
89         message['Precedence'] = 'bulk'
91         # Add a unique Roundup header to help filtering
92         try:
93             message['X-Roundup-Name'] = tracker_name.encode('ascii')
94         except UnicodeError:
95             message['X-Roundup-Name'] = Header(tracker_name, charset)
97         # and another one to avoid loops
98         message['X-Roundup-Loop'] = 'hello'
99         # finally, an aid to debugging problems
100         message['X-Roundup-Version'] = __version__
102         message['MIME-Version'] = '1.0'
104         return message
106     def standard_message(self, to, subject, content, author=None):
107         """Send a standard message.
109         Arguments:
110         - to: a list of addresses usable by rfc822.parseaddr().
111         - subject: the subject as a string.
112         - content: the body of the message as a string.
113         - author: the sender as a (name, address) tuple
115         All strings are assumed to be UTF-8 encoded.
116         """
117         message = self.get_standard_message(to, subject, author)
118         message.set_payload(content)
119         encode_quopri(message)
120         self.smtp_send(to, str(message))
122     def bounce_message(self, bounced_message, to, error,
123                        subject='Failed issue tracker submission'):
124         """Bounce a message, attaching the failed submission.
126         Arguments:
127         - bounced_message: an RFC822 Message object.
128         - to: a list of addresses usable by rfc822.parseaddr(). Might be
129           extended or overridden according to the config
130           ERROR_MESSAGES_TO setting.
131         - error: the reason of failure as a string.
132         - subject: the subject as a string.
134         """
135         # see whether we should send to the dispatcher or not
136         dispatcher_email = getattr(self.config, "DISPATCHER_EMAIL",
137             getattr(self.config, "ADMIN_EMAIL"))
138         error_messages_to = getattr(self.config, "ERROR_MESSAGES_TO", "user")
139         if error_messages_to == "dispatcher":
140             to = [dispatcher_email]
141         elif error_messages_to == "both":
142             to.append(dispatcher_email)
144         message = self.get_standard_message(to, subject)
146         # add the error text
147         part = MIMEText(error)
148         message.attach(part)
150         # attach the original message to the returned message
151         try:
152             bounced_message.rewindbody()
153         except IOError, message:
154             body.write("*** couldn't include message body: %s ***"
155                        % bounced_message)
156         else:
157             body.write(bounced_message.fp.read())
158         part = MIMEText(bounced_message.fp.read())
159         part['Content-Disposition'] = 'attachment'
160         for header in bounced_message.headers:
161             part.write(header)
162         message.attach(part)
164         # send
165         try:
166             self.smtp_send(to, str(message))
167         except MessageSendError:
168             # squash mail sending errors when bouncing mail
169             # TODO this *could* be better, as we could notify admin of the
170             # problem (even though the vast majority of bounce errors are
171             # because of spam)
172             pass
174     def exception_message(self):
175         '''Send a message to the admins with information about the latest
176         traceback.
177         '''
178         subject = '%s: %s'%(self.config.TRACKER_NAME, sys.exc_info()[1])
179         to = [self.config.ADMIN_EMAIL]
180         content = '\n'.join(traceback.format_exception(*sys.exc_info()))
181         self.standard_message(to, subject, content)
183     def smtp_send(self, to, message):
184         """Send a message over SMTP, using roundup's config.
186         Arguments:
187         - to: a list of addresses usable by rfc822.parseaddr().
188         - message: a StringIO instance with a full message.
189         """
190         if self.debug:
191             # don't send - just write to a file
192             open(self.debug, 'a').write('FROM: %s\nTO: %s\n%s\n' %
193                                         (self.config.ADMIN_EMAIL,
194                                          ', '.join(to), message))
195         else:
196             # now try to send the message
197             try:
198                 # send the message as admin so bounces are sent there
199                 # instead of to roundup
200                 smtp = SMTPConnection(self.config)
201                 smtp.sendmail(self.config.ADMIN_EMAIL, to, message)
202             except socket.error, value:
203                 raise MessageSendError("Error: couldn't send email: "
204                                        "mailhost %s"%value)
205             except smtplib.SMTPException, msg:
206                 raise MessageSendError("Error: couldn't send email: %s"%msg)
208 class SMTPConnection(smtplib.SMTP):
209     ''' Open an SMTP connection to the mailhost specified in the config
210     '''
211     def __init__(self, config):
212         smtplib.SMTP.__init__(self, config.MAILHOST, port=config['MAIL_PORT'],
213                               local_hostname=config['MAIL_LOCAL_HOSTNAME'])
215         # start the TLS if requested
216         if config["MAIL_TLS"]:
217             self.ehlo()
218             self.starttls(config["MAIL_TLS_KEYFILE"],
219                 config["MAIL_TLS_CERTFILE"])
221         # ok, now do we also need to log in?
222         mailuser = config["MAIL_USERNAME"]
223         if mailuser:
224             self.login(mailuser, config["MAIL_PASSWORD"])
226 # vim: set et sts=4 sw=4 :