Code

PGP support is again working (pyme API has changed significantly) and we
[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.MIMEBase import MIMEBase
16 from email.MIMEText import MIMEText
17 from email.MIMEMultipart import MIMEMultipart
19 try:
20     import pyme, pyme.core
21 except ImportError:
22     pyme = None
25 class MessageSendError(RuntimeError):
26     pass
28 def encode_quopri(msg):
29     orig = msg.get_payload()
30     encdata = quopri.encodestring(orig)
31     msg.set_payload(encdata)
32     del msg['Content-Transfer-Encoding']
33     msg['Content-Transfer-Encoding'] = 'quoted-printable'
35 def nice_sender_header(name, address, charset):
36     # construct an address header so it's as human-readable as possible
37     # even in the presence of a non-ASCII name part
38     if not name:
39         return address
40     try:
41         encname = name.encode('ASCII')
42     except UnicodeEncodeError:
43         # use Header to encode correctly.
44         encname = Header(name, charset=charset).encode()
46     # the important bits of formataddr()
47     if specialsre.search(encname):
48         encname = '"%s"'%escapesre.sub(r'\\\g<0>', encname)
50     # now format the header as a string - don't return a Header as anonymous
51     # headers play poorly with Messages (eg. won't get wrapped properly)
52     return '%s <%s>'%(encname, address)
54 class Mailer:
55     """Roundup-specific mail sending."""
56     def __init__(self, config):
57         self.config = config
59         # set to indicate to roundup not to actually _send_ email
60         # this var must contain a file to write the mail to
61         self.debug = os.environ.get('SENDMAILDEBUG', '') \
62             or config["MAIL_DEBUG"]
64         # set timezone so that things like formatdate(localtime=True)
65         # use the configured timezone
66         # apparently tzset doesn't exist in python under Windows, my bad.
67         # my pathetic attempts at googling a Windows-solution failed
68         # so if you're on Windows your mail won't use your configured
69         # timezone.
70         if hasattr(time, 'tzset'):
71             os.environ['TZ'] = get_timezone(self.config.TIMEZONE).tzname(None)
72             time.tzset()
74     def set_message_attributes(self, message, to, subject, author=None):
75         ''' Add attributes to a standard output message
76         "to"      - recipients list
77         "subject" - Subject
78         "author"  - (name, address) tuple or None for admin email
80         Subject and author are encoded using the EMAIL_CHARSET from the
81         config (default UTF-8).
82         '''
83         # encode header values if they need to be
84         charset = getattr(self.config, 'EMAIL_CHARSET', 'utf-8')
85         tracker_name = unicode(self.config.TRACKER_NAME, 'utf-8')
86         if not author:
87             author = (tracker_name, self.config.ADMIN_EMAIL)
88             name = author[0]
89         else:
90             name = unicode(author[0], 'utf-8')
91         author = nice_sender_header(name, author[1], charset)
92         try:
93             message['Subject'] = subject.encode('ascii')
94         except UnicodeError:
95             message['Subject'] = Header(subject, charset)
96         message['To'] = ', '.join(to)
97         message['From'] = author
98         message['Date'] = formatdate(localtime=True)
100         # add a Precedence header so autoresponders ignore us
101         message['Precedence'] = 'bulk'
103         # Add a unique Roundup header to help filtering
104         try:
105             message['X-Roundup-Name'] = tracker_name.encode('ascii')
106         except UnicodeError:
107             message['X-Roundup-Name'] = Header(tracker_name, charset)
109         # and another one to avoid loops
110         message['X-Roundup-Loop'] = 'hello'
111         # finally, an aid to debugging problems
112         message['X-Roundup-Version'] = __version__
114     def get_standard_message(self, multipart=False):
115         '''Form a standard email message from Roundup.
116         Returns a Message object.
117         '''
118         charset = getattr(self.config, 'EMAIL_CHARSET', 'utf-8')
119         if multipart:
120             message = MIMEMultipart()
121         else:
122             message = MIMEText("")
123             message.set_charset(charset)
125         return message
127     def standard_message(self, to, subject, content, author=None):
128         """Send a standard message.
130         Arguments:
131         - to: a list of addresses usable by rfc822.parseaddr().
132         - subject: the subject as a string.
133         - content: the body of the message as a string.
134         - author: the sender as a (name, address) tuple
136         All strings are assumed to be UTF-8 encoded.
137         """
138         message = self.get_standard_message()
139         self.set_message_attributes(message, to, subject, author)
140         message.set_payload(content)
141         encode_quopri(message)
142         self.smtp_send(to, message.as_string())
144     def bounce_message(self, bounced_message, to, error,
145                        subject='Failed issue tracker submission', crypt=False):
146         """Bounce a message, attaching the failed submission.
148         Arguments:
149         - bounced_message: an RFC822 Message object.
150         - to: a list of addresses usable by rfc822.parseaddr(). Might be
151           extended or overridden according to the config
152           ERROR_MESSAGES_TO setting.
153         - error: the reason of failure as a string.
154         - subject: the subject as a string.
155         - crypt: require encryption with pgp for user -- applies only to
156           mail sent back to the user, not the dispatcher oder admin.
158         """
159         crypt_to = None
160         if crypt:
161             crypt_to = to
162             to = None
163         # see whether we should send to the dispatcher or not
164         dispatcher_email = getattr(self.config, "DISPATCHER_EMAIL",
165             getattr(self.config, "ADMIN_EMAIL"))
166         error_messages_to = getattr(self.config, "ERROR_MESSAGES_TO", "user")
167         if error_messages_to == "dispatcher":
168             to = [dispatcher_email]
169             crypt = False
170             crypt_to = None
171         elif error_messages_to == "both":
172             if crypt:
173                 to = [dispatcher_email]
174             else:
175                 to.append(dispatcher_email)
177         message = self.get_standard_message(multipart=True)
179         # add the error text
180         part = MIMEText('\n'.join(error))
181         message.attach(part)
183         # attach the original message to the returned message
184         body = []
185         for header in bounced_message.headers:
186             body.append(header)
187         try:
188             bounced_message.rewindbody()
189         except IOError, errmessage:
190             body.append("*** couldn't include message body: %s ***" %
191                 errmessage)
192         else:
193             body.append('\n')
194             body.append(bounced_message.fp.read())
195         part = MIMEText(''.join(body))
196         message.attach(part)
198         if to:
199             # send
200             self.set_message_attributes(message, to, subject)
201             try:
202                 self.smtp_send(to, message.as_string())
203             except MessageSendError:
204                 # squash mail sending errors when bouncing mail
205                 # TODO this *could* be better, as we could notify admin of the
206                 # problem (even though the vast majority of bounce errors are
207                 # because of spam)
208                 pass
209         if crypt_to:
210             plain = pyme.core.Data(message.as_string())
211             cipher = pyme.core.Data()
212             ctx = pyme.core.Context()
213             ctx.set_armor(1)
214             keys = []
215             adrs = []
216             for adr in crypt_to:
217                 ctx.op_keylist_start(adr, 0)
218                 # only first key per email
219                 k = ctx.op_keylist_next()
220                 if k is not None:
221                     adrs.append(adr)
222                     keys.append(k)
223                 ctx.op_keylist_end()
224             crypt_to = adrs
225         if crypt_to:
226             try:
227                 ctx.op_encrypt(keys, 1, plain, cipher)
228                 cipher.seek(0,0)
229                 message=MIMEMultipart('encrypted', boundary=None,
230                     _subparts=None, protocol="application/pgp-encrypted")
231                 part=MIMEBase('application', 'pgp-encrypted')
232                 part.set_payload("Version: 1\r\n")
233                 message.attach(part)
234                 part=MIMEBase('application', 'octet-stream')
235                 part.set_payload(cipher.read())
236                 message.attach(part)
237             except pyme.GPGMEError:
238                 crypt_to = None
239         if crypt_to:
240             self.set_message_attributes(message, crypt_to, subject)
241             try:
242                 self.smtp_send(crypt_to, message.as_string())
243             except MessageSendError:
244                 # ignore on error, see above.
245                 pass
247     def exception_message(self):
248         '''Send a message to the admins with information about the latest
249         traceback.
250         '''
251         subject = '%s: %s'%(self.config.TRACKER_NAME, sys.exc_info()[1])
252         to = [self.config.ADMIN_EMAIL]
253         content = '\n'.join(traceback.format_exception(*sys.exc_info()))
254         self.standard_message(to, subject, content)
256     def smtp_send(self, to, message, sender=None):
257         """Send a message over SMTP, using roundup's config.
259         Arguments:
260         - to: a list of addresses usable by rfc822.parseaddr().
261         - message: a StringIO instance with a full message.
262         - sender: if not 'None', the email address to use as the
263         envelope sender.  If 'None', the admin email is used.
264         """
266         if not sender:
267             sender = self.config.ADMIN_EMAIL
268         if self.debug:
269             # don't send - just write to a file, use unix from line so
270             # that resulting file can be openened in a mailer
271             fmt = '%a %b %m %H:%M:%S %Y'
272             unixfrm = 'From %s %s' % (sender, Date ('.').pretty (fmt))
273             open(self.debug, 'a').write('%s\nFROM: %s\nTO: %s\n%s\n\n' %
274                                         (unixfrm, sender,
275                                          ', '.join(to), message))
276         else:
277             # now try to send the message
278             try:
279                 # send the message as admin so bounces are sent there
280                 # instead of to roundup
281                 smtp = SMTPConnection(self.config)
282                 smtp.sendmail(sender, to, message)
283             except socket.error, value:
284                 raise MessageSendError("Error: couldn't send email: "
285                                        "mailhost %s"%value)
286             except smtplib.SMTPException, msg:
287                 raise MessageSendError("Error: couldn't send email: %s"%msg)
289 class SMTPConnection(smtplib.SMTP):
290     ''' Open an SMTP connection to the mailhost specified in the config
291     '''
292     def __init__(self, config):
293         smtplib.SMTP.__init__(self, config.MAILHOST, port=config['MAIL_PORT'],
294                               local_hostname=config['MAIL_LOCAL_HOSTNAME'])
296         # start the TLS if requested
297         if config["MAIL_TLS"]:
298             self.ehlo()
299             self.starttls(config["MAIL_TLS_KEYFILE"],
300                 config["MAIL_TLS_CERTFILE"])
302         # ok, now do we also need to log in?
303         mailuser = config["MAIL_USERNAME"]
304         if mailuser:
305             self.login(mailuser, config["MAIL_PASSWORD"])
307 # vim: set et sts=4 sw=4 :