Code

Fix handling of non-ascii in realname in the nosy mailer, this used to
[roundup.git] / roundup / mailer.py
1 """Sending Roundup-specific mail over SMTP.
2 """
3 __docformat__ = 'restructuredtext'
5 import time, quopri, os, socket, smtplib, re, sys, traceback, email
7 from cStringIO import StringIO
9 from roundup import __version__
10 from roundup.date import get_timezone
12 from email.Utils import formatdate, formataddr
13 from email.Message import Message
14 from email.Header import Header
15 from email.MIMEText import MIMEText
16 from email.MIMEMultipart import MIMEMultipart
18 class MessageSendError(RuntimeError):
19     pass
21 def encode_quopri(msg):
22     orig = msg.get_payload()
23     encdata = quopri.encodestring(orig)
24     msg.set_payload(encdata)
25     del msg['Content-Transfer-Encoding']
26     msg['Content-Transfer-Encoding'] = 'quoted-printable'
28 class Mailer:
29     """Roundup-specific mail sending."""
30     def __init__(self, config):
31         self.config = config
33         # set to indicate to roundup not to actually _send_ email
34         # this var must contain a file to write the mail to
35         self.debug = os.environ.get('SENDMAILDEBUG', '') \
36             or config["MAIL_DEBUG"]
38         # set timezone so that things like formatdate(localtime=True)
39         # use the configured timezone
40         # apparently tzset doesn't exist in python under Windows, my bad.
41         # my pathetic attempts at googling a Windows-solution failed
42         # so if you're on Windows your mail won't use your configured
43         # timezone.
44         if hasattr(time, 'tzset'):
45             os.environ['TZ'] = get_timezone(self.config.TIMEZONE).tzname(None)
46             time.tzset()
48     def get_standard_message(self, to, subject, author=None, multipart=False):
49         '''Form a standard email message from Roundup.
51         "to"      - recipients list
52         "subject" - Subject
53         "author"  - (name, address) tuple or None for admin email
55         Subject and author are encoded using the EMAIL_CHARSET from the
56         config (default UTF-8).
58         Returns a Message object.
59         '''
60         # encode header values if they need to be
61         charset = getattr(self.config, 'EMAIL_CHARSET', 'utf-8')
62         tracker_name = unicode(self.config.TRACKER_NAME, 'utf-8')
63         if not author:
64             author = (tracker_name, self.config.ADMIN_EMAIL)
65             name = author[0]
66         else:
67             name = unicode(author[0], 'utf-8')
68         try:
69             name = name.encode('ascii')
70         except UnicodeError:
71             name = Header(name, charset).encode()
72         author = formataddr((name, author[1]))
74         if multipart:
75             message = MIMEMultipart()
76         else:
77             message = MIMEText("")
78             message.set_charset(charset)
80         try:
81             message['Subject'] = subject.encode('ascii')
82         except UnicodeError:
83             message['Subject'] = Header(subject, charset)
84         message['To'] = ', '.join(to)
85         message['From'] = author
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         return message
104     def standard_message(self, to, subject, content, author=None):
105         """Send a standard message.
107         Arguments:
108         - to: a list of addresses usable by rfc822.parseaddr().
109         - subject: the subject as a string.
110         - content: the body of the message as a string.
111         - author: the sender as a (name, address) tuple
113         All strings are assumed to be UTF-8 encoded.
114         """
115         message = self.get_standard_message(to, subject, author)
116         message.set_payload(content)
117         encode_quopri(message)
118         self.smtp_send(to, message.as_string())
120     def bounce_message(self, bounced_message, to, error,
121                        subject='Failed issue tracker submission'):
122         """Bounce a message, attaching the failed submission.
124         Arguments:
125         - bounced_message: an RFC822 Message object.
126         - to: a list of addresses usable by rfc822.parseaddr(). Might be
127           extended or overridden according to the config
128           ERROR_MESSAGES_TO setting.
129         - error: the reason of failure as a string.
130         - subject: the subject as a string.
132         """
133         # see whether we should send to the dispatcher or not
134         dispatcher_email = getattr(self.config, "DISPATCHER_EMAIL",
135             getattr(self.config, "ADMIN_EMAIL"))
136         error_messages_to = getattr(self.config, "ERROR_MESSAGES_TO", "user")
137         if error_messages_to == "dispatcher":
138             to = [dispatcher_email]
139         elif error_messages_to == "both":
140             to.append(dispatcher_email)
142         message = self.get_standard_message(to, subject, multipart=True)
144         # add the error text
145         part = MIMEText('\n'.join(error))
146         message.attach(part)
148         # attach the original message to the returned message
149         body = []
150         for header in bounced_message.headers:
151             body.append(header)
152         try:
153             bounced_message.rewindbody()
154         except IOError, errmessage:
155             body.append("*** couldn't include message body: %s ***" %
156                 errmessage)
157         else:
158             body.append('\n')
159             body.append(bounced_message.fp.read())
160         part = MIMEText(''.join(body))
161         message.attach(part)
163         # send
164         try:
165             self.smtp_send(to, message.as_string())
166         except MessageSendError:
167             # squash mail sending errors when bouncing mail
168             # TODO this *could* be better, as we could notify admin of the
169             # problem (even though the vast majority of bounce errors are
170             # because of spam)
171             pass
173     def exception_message(self):
174         '''Send a message to the admins with information about the latest
175         traceback.
176         '''
177         subject = '%s: %s'%(self.config.TRACKER_NAME, sys.exc_info()[1])
178         to = [self.config.ADMIN_EMAIL]
179         content = '\n'.join(traceback.format_exception(*sys.exc_info()))
180         self.standard_message(to, subject, content)
182     def smtp_send(self, to, message, sender=None):
183         """Send a message over SMTP, using roundup's config.
185         Arguments:
186         - to: a list of addresses usable by rfc822.parseaddr().
187         - message: a StringIO instance with a full message.
188         - sender: if not 'None', the email address to use as the
189         envelope sender.  If 'None', the admin email is used.
190         """
192         if not sender:
193             sender = self.config.ADMIN_EMAIL
194         if self.debug:
195             # don't send - just write to a file
196             open(self.debug, 'a').write('FROM: %s\nTO: %s\n%s\n' %
197                                         (sender,
198                                          ', '.join(to), message))
199         else:
200             # now try to send the message
201             try:
202                 # send the message as admin so bounces are sent there
203                 # instead of to roundup
204                 smtp = SMTPConnection(self.config)
205                 smtp.sendmail(sender, to, message)
206             except socket.error, value:
207                 raise MessageSendError("Error: couldn't send email: "
208                                        "mailhost %s"%value)
209             except smtplib.SMTPException, msg:
210                 raise MessageSendError("Error: couldn't send email: %s"%msg)
212 class SMTPConnection(smtplib.SMTP):
213     ''' Open an SMTP connection to the mailhost specified in the config
214     '''
215     def __init__(self, config):
216         smtplib.SMTP.__init__(self, config.MAILHOST, port=config['MAIL_PORT'],
217                               local_hostname=config['MAIL_LOCAL_HOSTNAME'])
219         # start the TLS if requested
220         if config["MAIL_TLS"]:
221             self.ehlo()
222             self.starttls(config["MAIL_TLS_KEYFILE"],
223                 config["MAIL_TLS_CERTFILE"])
225         # ok, now do we also need to log in?
226         mailuser = config["MAIL_USERNAME"]
227         if mailuser:
228             self.login(mailuser, config["MAIL_PASSWORD"])
230 # vim: set et sts=4 sw=4 :