Code

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