Code

When debugging mail (debug = <filename> setting in [mail] section of
[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, Date
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, use unix from line so
211             # that resulting file can be openened in a mailer
212             fmt = '%a %b %m %H:%M:%S %Y'
213             unixfrm = 'From %s %s' % (sender, Date ('.').pretty (fmt))
214             open(self.debug, 'a').write('%s\nFROM: %s\nTO: %s\n%s\n\n' %
215                                         (unixfrm, sender,
216                                          ', '.join(to), message))
217         else:
218             # now try to send the message
219             try:
220                 # send the message as admin so bounces are sent there
221                 # instead of to roundup
222                 smtp = SMTPConnection(self.config)
223                 smtp.sendmail(sender, to, message)
224             except socket.error, value:
225                 raise MessageSendError("Error: couldn't send email: "
226                                        "mailhost %s"%value)
227             except smtplib.SMTPException, msg:
228                 raise MessageSendError("Error: couldn't send email: %s"%msg)
230 class SMTPConnection(smtplib.SMTP):
231     ''' Open an SMTP connection to the mailhost specified in the config
232     '''
233     def __init__(self, config):
234         smtplib.SMTP.__init__(self, config.MAILHOST, port=config['MAIL_PORT'],
235                               local_hostname=config['MAIL_LOCAL_HOSTNAME'])
237         # start the TLS if requested
238         if config["MAIL_TLS"]:
239             self.ehlo()
240             self.starttls(config["MAIL_TLS_KEYFILE"],
241                 config["MAIL_TLS_CERTFILE"])
243         # ok, now do we also need to log in?
244         mailuser = config["MAIL_USERNAME"]
245         if mailuser:
246             self.login(mailuser, config["MAIL_PASSWORD"])
248 # vim: set et sts=4 sw=4 :