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 :