Code

d44568e5fb190751b7ebc99e7acdeebb17e1c501
[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 format the header as a string - don't return a Header as anonymous
44     # headers play poorly with Messages (eg. won't get wrapped properly)
45     return '%s <%s>'%(encname, address)
47 class Mailer:
48     """Roundup-specific mail sending."""
49     def __init__(self, config):
50         self.config = config
52         # set to indicate to roundup not to actually _send_ email
53         # this var must contain a file to write the mail to
54         self.debug = os.environ.get('SENDMAILDEBUG', '') \
55             or config["MAIL_DEBUG"]
57         # set timezone so that things like formatdate(localtime=True)
58         # use the configured timezone
59         # apparently tzset doesn't exist in python under Windows, my bad.
60         # my pathetic attempts at googling a Windows-solution failed
61         # so if you're on Windows your mail won't use your configured
62         # timezone.
63         if hasattr(time, 'tzset'):
64             os.environ['TZ'] = get_timezone(self.config.TIMEZONE).tzname(None)
65             time.tzset()
67     def get_standard_message(self, to, subject, author=None, multipart=False):
68         '''Form a standard email message from Roundup.
70         "to"      - recipients list
71         "subject" - Subject
72         "author"  - (name, address) tuple or None for admin email
74         Subject and author are encoded using the EMAIL_CHARSET from the
75         config (default UTF-8).
77         Returns a Message object.
78         '''
79         # encode header values if they need to be
80         charset = getattr(self.config, 'EMAIL_CHARSET', 'utf-8')
81         tracker_name = unicode(self.config.TRACKER_NAME, 'utf-8')
82         if not author:
83             author = (tracker_name, self.config.ADMIN_EMAIL)
84             name = author[0]
85         else:
86             name = unicode(author[0], 'utf-8')
87         author = nice_sender_header(name, author[1], charset)
89         if multipart:
90             message = MIMEMultipart()
91         else:
92             message = MIMEText("")
93             message.set_charset(charset)
95         try:
96             message['Subject'] = subject.encode('ascii')
97         except UnicodeError:
98             message['Subject'] = Header(subject, charset)
99         message['To'] = ', '.join(to)
100         message['From'] = author
101         message['Date'] = formatdate(localtime=True)
103         # add a Precedence header so autoresponders ignore us
104         message['Precedence'] = 'bulk'
106         # Add a unique Roundup header to help filtering
107         try:
108             message['X-Roundup-Name'] = tracker_name.encode('ascii')
109         except UnicodeError:
110             message['X-Roundup-Name'] = Header(tracker_name, charset)
112         # and another one to avoid loops
113         message['X-Roundup-Loop'] = 'hello'
114         # finally, an aid to debugging problems
115         message['X-Roundup-Version'] = __version__
117         return message
119     def standard_message(self, to, subject, content, author=None):
120         """Send a standard message.
122         Arguments:
123         - to: a list of addresses usable by rfc822.parseaddr().
124         - subject: the subject as a string.
125         - content: the body of the message as a string.
126         - author: the sender as a (name, address) tuple
128         All strings are assumed to be UTF-8 encoded.
129         """
130         message = self.get_standard_message(to, subject, author)
131         message.set_payload(content)
132         encode_quopri(message)
133         self.smtp_send(to, message.as_string())
135     def bounce_message(self, bounced_message, to, error,
136                        subject='Failed issue tracker submission'):
137         """Bounce a message, attaching the failed submission.
139         Arguments:
140         - bounced_message: an RFC822 Message object.
141         - to: a list of addresses usable by rfc822.parseaddr(). Might be
142           extended or overridden according to the config
143           ERROR_MESSAGES_TO setting.
144         - error: the reason of failure as a string.
145         - subject: the subject as a string.
147         """
148         # see whether we should send to the dispatcher or not
149         dispatcher_email = getattr(self.config, "DISPATCHER_EMAIL",
150             getattr(self.config, "ADMIN_EMAIL"))
151         error_messages_to = getattr(self.config, "ERROR_MESSAGES_TO", "user")
152         if error_messages_to == "dispatcher":
153             to = [dispatcher_email]
154         elif error_messages_to == "both":
155             to.append(dispatcher_email)
157         message = self.get_standard_message(to, subject, multipart=True)
159         # add the error text
160         part = MIMEText('\n'.join(error))
161         message.attach(part)
163         # attach the original message to the returned message
164         body = []
165         for header in bounced_message.headers:
166             body.append(header)
167         try:
168             bounced_message.rewindbody()
169         except IOError, errmessage:
170             body.append("*** couldn't include message body: %s ***" %
171                 errmessage)
172         else:
173             body.append('\n')
174             body.append(bounced_message.fp.read())
175         part = MIMEText(''.join(body))
176         message.attach(part)
178         # send
179         try:
180             self.smtp_send(to, message.as_string())
181         except MessageSendError:
182             # squash mail sending errors when bouncing mail
183             # TODO this *could* be better, as we could notify admin of the
184             # problem (even though the vast majority of bounce errors are
185             # because of spam)
186             pass
188     def exception_message(self):
189         '''Send a message to the admins with information about the latest
190         traceback.
191         '''
192         subject = '%s: %s'%(self.config.TRACKER_NAME, sys.exc_info()[1])
193         to = [self.config.ADMIN_EMAIL]
194         content = '\n'.join(traceback.format_exception(*sys.exc_info()))
195         self.standard_message(to, subject, content)
197     def smtp_send(self, to, message, sender=None):
198         """Send a message over SMTP, using roundup's config.
200         Arguments:
201         - to: a list of addresses usable by rfc822.parseaddr().
202         - message: a StringIO instance with a full message.
203         - sender: if not 'None', the email address to use as the
204         envelope sender.  If 'None', the admin email is used.
205         """
207         if not sender:
208             sender = self.config.ADMIN_EMAIL
209         if self.debug:
210             # don't send - just write to a file
211             open(self.debug, 'a').write('FROM: %s\nTO: %s\n%s\n' %
212                                         (sender,
213                                          ', '.join(to), message))
214         else:
215             # now try to send the message
216             try:
217                 # send the message as admin so bounces are sent there
218                 # instead of to roundup
219                 smtp = SMTPConnection(self.config)
220                 smtp.sendmail(sender, to, message)
221             except socket.error, value:
222                 raise MessageSendError("Error: couldn't send email: "
223                                        "mailhost %s"%value)
224             except smtplib.SMTPException, msg:
225                 raise MessageSendError("Error: couldn't send email: %s"%msg)
227 class SMTPConnection(smtplib.SMTP):
228     ''' Open an SMTP connection to the mailhost specified in the config
229     '''
230     def __init__(self, config):
231         smtplib.SMTP.__init__(self, config.MAILHOST, port=config['MAIL_PORT'],
232                               local_hostname=config['MAIL_LOCAL_HOSTNAME'])
234         # start the TLS if requested
235         if config["MAIL_TLS"]:
236             self.ehlo()
237             self.starttls(config["MAIL_TLS_KEYFILE"],
238                 config["MAIL_TLS_CERTFILE"])
240         # ok, now do we also need to log in?
241         mailuser = config["MAIL_USERNAME"]
242         if mailuser:
243             self.login(mailuser, config["MAIL_PASSWORD"])
245 # vim: set et sts=4 sw=4 :