Code

handle quoting/escaping specials after encoding; fix tests to handle new, more consis...
[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, specialsre, escapesre
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 def nice_sender_header(name, address, charset):
29     # construct an address header so it's as human-readable as possible
30     # even in the presence of a non-ASCII name part
31     if not name:
32         return address
33     try:
34         encname = name.encode('ASCII')
35     except UnicodeEncodeError:
36         # use Header to encode correctly.
37         encname = Header(name, charset=charset).encode()
39     # the important bits of formataddr()
40     if specialsre.search(encname):
41         encname = '"%s"'%escapesre.sub(r'\\\g<0>', encname)
43     # now use Header again to wrap the line if necessary
44     return '%s <%s>'%(encname, address)
46 class Mailer:
47     """Roundup-specific mail sending."""
48     def __init__(self, config):
49         self.config = config
51         # set to indicate to roundup not to actually _send_ email
52         # this var must contain a file to write the mail to
53         self.debug = os.environ.get('SENDMAILDEBUG', '') \
54             or config["MAIL_DEBUG"]
56         # set timezone so that things like formatdate(localtime=True)
57         # use the configured timezone
58         # apparently tzset doesn't exist in python under Windows, my bad.
59         # my pathetic attempts at googling a Windows-solution failed
60         # so if you're on Windows your mail won't use your configured
61         # timezone.
62         if hasattr(time, 'tzset'):
63             os.environ['TZ'] = get_timezone(self.config.TIMEZONE).tzname(None)
64             time.tzset()
66     def get_standard_message(self, to, subject, author=None, multipart=False):
67         '''Form a standard email message from Roundup.
69         "to"      - recipients list
70         "subject" - Subject
71         "author"  - (name, address) tuple or None for admin email
73         Subject and author are encoded using the EMAIL_CHARSET from the
74         config (default UTF-8).
76         Returns a Message object.
77         '''
78         # encode header values if they need to be
79         charset = getattr(self.config, 'EMAIL_CHARSET', 'utf-8')
80         tracker_name = unicode(self.config.TRACKER_NAME, 'utf-8')
81         if not author:
82             author = (tracker_name, self.config.ADMIN_EMAIL)
83             name = author[0]
84         else:
85             name = unicode(author[0], 'utf-8')
86         author = nice_sender_header(name, author[1], charset)
88         if multipart:
89             message = MIMEMultipart()
90         else:
91             message = MIMEText("")
92             message.set_charset(charset)
94         try:
95             message['Subject'] = subject.encode('ascii')
96         except UnicodeError:
97             message['Subject'] = Header(subject, charset)
98         message['To'] = ', '.join(to)
99         message['From'] = author
100         message['Date'] = formatdate(localtime=True)
102         # add a Precedence header so autoresponders ignore us
103         message['Precedence'] = 'bulk'
105         # Add a unique Roundup header to help filtering
106         try:
107             message['X-Roundup-Name'] = tracker_name.encode('ascii')
108         except UnicodeError:
109             message['X-Roundup-Name'] = Header(tracker_name, charset)
111         # and another one to avoid loops
112         message['X-Roundup-Loop'] = 'hello'
113         # finally, an aid to debugging problems
114         message['X-Roundup-Version'] = __version__
116         return message
118     def standard_message(self, to, subject, content, author=None):
119         """Send a standard message.
121         Arguments:
122         - to: a list of addresses usable by rfc822.parseaddr().
123         - subject: the subject as a string.
124         - content: the body of the message as a string.
125         - author: the sender as a (name, address) tuple
127         All strings are assumed to be UTF-8 encoded.
128         """
129         message = self.get_standard_message(to, subject, author)
130         message.set_payload(content)
131         encode_quopri(message)
132         self.smtp_send(to, message.as_string())
134     def bounce_message(self, bounced_message, to, error,
135                        subject='Failed issue tracker submission'):
136         """Bounce a message, attaching the failed submission.
138         Arguments:
139         - bounced_message: an RFC822 Message object.
140         - to: a list of addresses usable by rfc822.parseaddr(). Might be
141           extended or overridden according to the config
142           ERROR_MESSAGES_TO setting.
143         - error: the reason of failure as a string.
144         - subject: the subject as a string.
146         """
147         # see whether we should send to the dispatcher or not
148         dispatcher_email = getattr(self.config, "DISPATCHER_EMAIL",
149             getattr(self.config, "ADMIN_EMAIL"))
150         error_messages_to = getattr(self.config, "ERROR_MESSAGES_TO", "user")
151         if error_messages_to == "dispatcher":
152             to = [dispatcher_email]
153         elif error_messages_to == "both":
154             to.append(dispatcher_email)
156         message = self.get_standard_message(to, subject, multipart=True)
158         # add the error text
159         part = MIMEText('\n'.join(error))
160         message.attach(part)
162         # attach the original message to the returned message
163         body = []
164         for header in bounced_message.headers:
165             body.append(header)
166         try:
167             bounced_message.rewindbody()
168         except IOError, errmessage:
169             body.append("*** couldn't include message body: %s ***" %
170                 errmessage)
171         else:
172             body.append('\n')
173             body.append(bounced_message.fp.read())
174         part = MIMEText(''.join(body))
175         message.attach(part)
177         # send
178         try:
179             self.smtp_send(to, message.as_string())
180         except MessageSendError:
181             # squash mail sending errors when bouncing mail
182             # TODO this *could* be better, as we could notify admin of the
183             # problem (even though the vast majority of bounce errors are
184             # because of spam)
185             pass
187     def exception_message(self):
188         '''Send a message to the admins with information about the latest
189         traceback.
190         '''
191         subject = '%s: %s'%(self.config.TRACKER_NAME, sys.exc_info()[1])
192         to = [self.config.ADMIN_EMAIL]
193         content = '\n'.join(traceback.format_exception(*sys.exc_info()))
194         self.standard_message(to, subject, content)
196     def smtp_send(self, to, message, sender=None):
197         """Send a message over SMTP, using roundup's config.
199         Arguments:
200         - to: a list of addresses usable by rfc822.parseaddr().
201         - message: a StringIO instance with a full message.
202         - sender: if not 'None', the email address to use as the
203         envelope sender.  If 'None', the admin email is used.
204         """
206         if not sender:
207             sender = self.config.ADMIN_EMAIL
208         if self.debug:
209             # don't send - just write to a file
210             open(self.debug, 'a').write('FROM: %s\nTO: %s\n%s\n' %
211                                         (sender,
212                                          ', '.join(to), message))
213         else:
214             # now try to send the message
215             try:
216                 # send the message as admin so bounces are sent there
217                 # instead of to roundup
218                 smtp = SMTPConnection(self.config)
219                 smtp.sendmail(sender, to, message)
220             except socket.error, value:
221                 raise MessageSendError("Error: couldn't send email: "
222                                        "mailhost %s"%value)
223             except smtplib.SMTPException, msg:
224                 raise MessageSendError("Error: couldn't send email: %s"%msg)
226 class SMTPConnection(smtplib.SMTP):
227     ''' Open an SMTP connection to the mailhost specified in the config
228     '''
229     def __init__(self, config):
230         smtplib.SMTP.__init__(self, config.MAILHOST, port=config['MAIL_PORT'],
231                               local_hostname=config['MAIL_LOCAL_HOSTNAME'])
233         # start the TLS if requested
234         if config["MAIL_TLS"]:
235             self.ehlo()
236             self.starttls(config["MAIL_TLS_KEYFILE"],
237                 config["MAIL_TLS_CERTFILE"])
239         # ok, now do we also need to log in?
240         mailuser = config["MAIL_USERNAME"]
241         if mailuser:
242             self.login(mailuser, config["MAIL_PASSWORD"])
244 # vim: set et sts=4 sw=4 :