comparison rhodecode/lib/rcmail/response.py @ 2031:82a88013a3fd

merge 1.3 into stable
author Marcin Kuzminski <marcin@python-works.com>
date Sun, 26 Feb 2012 17:25:09 +0200
parents b3a3890b7160
children d95ef6587bca
comparison
equal deleted inserted replaced
2005:ab0e122b38a7 2031:82a88013a3fd
1 # The code in this module is entirely lifted from the Lamson project
2 # (http://lamsonproject.org/). Its copyright is:
3
4 # Copyright (c) 2008, Zed A. Shaw
5 # All rights reserved.
6
7 # It is provided under this license:
8
9 # Redistribution and use in source and binary forms, with or without
10 # modification, are permitted provided that the following conditions are met:
11
12 # * Redistributions of source code must retain the above copyright notice, this
13 # list of conditions and the following disclaimer.
14
15 # * Redistributions in binary form must reproduce the above copyright notice,
16 # this list of conditions and the following disclaimer in the documentation
17 # and/or other materials provided with the distribution.
18
19 # * Neither the name of the Zed A. Shaw nor the names of its contributors may
20 # be used to endorse or promote products derived from this software without
21 # specific prior written permission.
22
23 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
24 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
25 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
26 # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
27 # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
28 # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
29 # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
30 # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
31 # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
32 # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
33 # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
34 # POSSIBILITY OF SUCH DAMAGE.
35
36 import os
37 import mimetypes
38 import string
39 from email import encoders
40 from email.charset import Charset
41 from email.utils import parseaddr
42 from email.mime.base import MIMEBase
43
44 ADDRESS_HEADERS_WHITELIST = ['From', 'To', 'Delivered-To', 'Cc']
45 DEFAULT_ENCODING = "utf-8"
46 VALUE_IS_EMAIL_ADDRESS = lambda v: '@' in v
47
48
49 def normalize_header(header):
50 return string.capwords(header.lower(), '-')
51
52
53 class EncodingError(Exception):
54 """Thrown when there is an encoding error."""
55 pass
56
57
58 class MailBase(object):
59 """MailBase is used as the basis of lamson.mail and contains the basics of
60 encoding an email. You actually can do all your email processing with this
61 class, but it's more raw.
62 """
63 def __init__(self, items=()):
64 self.headers = dict(items)
65 self.parts = []
66 self.body = None
67 self.content_encoding = {'Content-Type': (None, {}),
68 'Content-Disposition': (None, {}),
69 'Content-Transfer-Encoding': (None, {})}
70
71 def __getitem__(self, key):
72 return self.headers.get(normalize_header(key), None)
73
74 def __len__(self):
75 return len(self.headers)
76
77 def __iter__(self):
78 return iter(self.headers)
79
80 def __contains__(self, key):
81 return normalize_header(key) in self.headers
82
83 def __setitem__(self, key, value):
84 self.headers[normalize_header(key)] = value
85
86 def __delitem__(self, key):
87 del self.headers[normalize_header(key)]
88
89 def __nonzero__(self):
90 return self.body != None or len(self.headers) > 0 or len(self.parts) > 0
91
92 def keys(self):
93 """Returns the sorted keys."""
94 return sorted(self.headers.keys())
95
96 def attach_file(self, filename, data, ctype, disposition):
97 """
98 A file attachment is a raw attachment with a disposition that
99 indicates the file name.
100 """
101 assert filename, "You can't attach a file without a filename."
102 ctype = ctype.lower()
103
104 part = MailBase()
105 part.body = data
106 part.content_encoding['Content-Type'] = (ctype, {'name': filename})
107 part.content_encoding['Content-Disposition'] = (disposition,
108 {'filename': filename})
109 self.parts.append(part)
110
111 def attach_text(self, data, ctype):
112 """
113 This attaches a simpler text encoded part, which doesn't have a
114 filename.
115 """
116 ctype = ctype.lower()
117
118 part = MailBase()
119 part.body = data
120 part.content_encoding['Content-Type'] = (ctype, {})
121 self.parts.append(part)
122
123 def walk(self):
124 for p in self.parts:
125 yield p
126 for x in p.walk():
127 yield x
128
129
130 class MailResponse(object):
131 """
132 You are given MailResponse objects from the lamson.view methods, and
133 whenever you want to generate an email to send to someone. It has the
134 same basic functionality as MailRequest, but it is designed to be written
135 to, rather than read from (although you can do both).
136
137 You can easily set a Body or Html during creation or after by passing it
138 as __init__ parameters, or by setting those attributes.
139
140 You can initially set the From, To, and Subject, but they are headers so
141 use the dict notation to change them: msg['From'] = 'joe@test.com'.
142
143 The message is not fully crafted until right when you convert it with
144 MailResponse.to_message. This lets you change it and work with it, then
145 send it out when it's ready.
146 """
147 def __init__(self, To=None, From=None, Subject=None, Body=None, Html=None,
148 separator="; "):
149 self.Body = Body
150 self.Html = Html
151 self.base = MailBase([('To', To), ('From', From), ('Subject', Subject)])
152 self.multipart = self.Body and self.Html
153 self.attachments = []
154 self.separator = separator
155
156 def __contains__(self, key):
157 return self.base.__contains__(key)
158
159 def __getitem__(self, key):
160 return self.base.__getitem__(key)
161
162 def __setitem__(self, key, val):
163 return self.base.__setitem__(key, val)
164
165 def __delitem__(self, name):
166 del self.base[name]
167
168 def attach(self, filename=None, content_type=None, data=None,
169 disposition=None):
170 """
171
172 Simplifies attaching files from disk or data as files. To attach
173 simple text simple give data and a content_type. To attach a file,
174 give the data/content_type/filename/disposition combination.
175
176 For convenience, if you don't give data and only a filename, then it
177 will read that file's contents when you call to_message() later. If
178 you give data and filename then it will assume you've filled data
179 with what the file's contents are and filename is just the name to
180 use.
181 """
182
183 assert filename or data, ("You must give a filename or some data to "
184 "attach.")
185 assert data or os.path.exists(filename), ("File doesn't exist, and no "
186 "data given.")
187
188 self.multipart = True
189
190 if filename and not content_type:
191 content_type, encoding = mimetypes.guess_type(filename)
192
193 assert content_type, ("No content type given, and couldn't guess "
194 "from the filename: %r" % filename)
195
196 self.attachments.append({'filename': filename,
197 'content_type': content_type,
198 'data': data,
199 'disposition': disposition,})
200
201 def attach_part(self, part):
202 """
203 Attaches a raw MailBase part from a MailRequest (or anywhere)
204 so that you can copy it over.
205 """
206 self.multipart = True
207
208 self.attachments.append({'filename': None,
209 'content_type': None,
210 'data': None,
211 'disposition': None,
212 'part': part,
213 })
214
215 def attach_all_parts(self, mail_request):
216 """
217 Used for copying the attachment parts of a mail.MailRequest
218 object for mailing lists that need to maintain attachments.
219 """
220 for part in mail_request.all_parts():
221 self.attach_part(part)
222
223 self.base.content_encoding = mail_request.base.content_encoding.copy()
224
225 def clear(self):
226 """
227 Clears out the attachments so you can redo them. Use this to keep the
228 headers for a series of different messages with different attachments.
229 """
230 del self.attachments[:]
231 del self.base.parts[:]
232 self.multipart = False
233
234 def update(self, message):
235 """
236 Used to easily set a bunch of heading from another dict
237 like object.
238 """
239 for k in message.keys():
240 self.base[k] = message[k]
241
242 def __str__(self):
243 """
244 Converts to a string.
245 """
246 return self.to_message().as_string()
247
248 def _encode_attachment(self, filename=None, content_type=None, data=None,
249 disposition=None, part=None):
250 """
251 Used internally to take the attachments mentioned in self.attachments
252 and do the actual encoding in a lazy way when you call to_message.
253 """
254 if part:
255 self.base.parts.append(part)
256 elif filename:
257 if not data:
258 data = open(filename).read()
259
260 self.base.attach_file(filename, data, content_type,
261 disposition or 'attachment')
262 else:
263 self.base.attach_text(data, content_type)
264
265 ctype = self.base.content_encoding['Content-Type'][0]
266
267 if ctype and not ctype.startswith('multipart'):
268 self.base.content_encoding['Content-Type'] = ('multipart/mixed', {})
269
270 def to_message(self):
271 """
272 Figures out all the required steps to finally craft the
273 message you need and return it. The resulting message
274 is also available as a self.base attribute.
275
276 What is returned is a Python email API message you can
277 use with those APIs. The self.base attribute is the raw
278 lamson.encoding.MailBase.
279 """
280 del self.base.parts[:]
281
282 if self.Body and self.Html:
283 self.multipart = True
284 self.base.content_encoding['Content-Type'] = (
285 'multipart/alternative', {})
286
287 if self.multipart:
288 self.base.body = None
289 if self.Body:
290 self.base.attach_text(self.Body, 'text/plain')
291
292 if self.Html:
293 self.base.attach_text(self.Html, 'text/html')
294
295 for args in self.attachments:
296 self._encode_attachment(**args)
297
298 elif self.Body:
299 self.base.body = self.Body
300 self.base.content_encoding['Content-Type'] = ('text/plain', {})
301
302 elif self.Html:
303 self.base.body = self.Html
304 self.base.content_encoding['Content-Type'] = ('text/html', {})
305
306 return to_message(self.base, separator=self.separator)
307
308 def all_parts(self):
309 """
310 Returns all the encoded parts. Only useful for debugging
311 or inspecting after calling to_message().
312 """
313 return self.base.parts
314
315 def keys(self):
316 return self.base.keys()
317
318
319 def to_message(mail, separator="; "):
320 """
321 Given a MailBase message, this will construct a MIMEPart
322 that is canonicalized for use with the Python email API.
323 """
324 ctype, params = mail.content_encoding['Content-Type']
325
326 if not ctype:
327 if mail.parts:
328 ctype = 'multipart/mixed'
329 else:
330 ctype = 'text/plain'
331 else:
332 if mail.parts:
333 assert ctype.startswith(("multipart", "message")), \
334 "Content type should be multipart or message, not %r" % ctype
335
336 # adjust the content type according to what it should be now
337 mail.content_encoding['Content-Type'] = (ctype, params)
338
339 try:
340 out = MIMEPart(ctype, **params)
341 except TypeError, exc: # pragma: no cover
342 raise EncodingError("Content-Type malformed, not allowed: %r; "
343 "%r (Python ERROR: %s" %
344 (ctype, params, exc.message))
345
346 for k in mail.keys():
347 if k in ADDRESS_HEADERS_WHITELIST:
348 out[k.encode('ascii')] = header_to_mime_encoding(
349 mail[k],
350 not_email=False,
351 separator=separator
352 )
353 else:
354 out[k.encode('ascii')] = header_to_mime_encoding(
355 mail[k],
356 not_email=True
357 )
358
359 out.extract_payload(mail)
360
361 # go through the children
362 for part in mail.parts:
363 out.attach(to_message(part))
364
365 return out
366
367 class MIMEPart(MIMEBase):
368 """
369 A reimplementation of nearly everything in email.mime to be more useful
370 for actually attaching things. Rather than one class for every type of
371 thing you'd encode, there's just this one, and it figures out how to
372 encode what you ask it.
373 """
374 def __init__(self, type, **params):
375 self.maintype, self.subtype = type.split('/')
376 MIMEBase.__init__(self, self.maintype, self.subtype, **params)
377
378 def add_text(self, content):
379 # this is text, so encode it in canonical form
380 try:
381 encoded = content.encode('ascii')
382 charset = 'ascii'
383 except UnicodeError:
384 encoded = content.encode('utf-8')
385 charset = 'utf-8'
386
387 self.set_payload(encoded, charset=charset)
388
389 def extract_payload(self, mail):
390 if mail.body == None: return # only None, '' is still ok
391
392 ctype, ctype_params = mail.content_encoding['Content-Type']
393 cdisp, cdisp_params = mail.content_encoding['Content-Disposition']
394
395 assert ctype, ("Extract payload requires that mail.content_encoding "
396 "have a valid Content-Type.")
397
398 if ctype.startswith("text/"):
399 self.add_text(mail.body)
400 else:
401 if cdisp:
402 # replicate the content-disposition settings
403 self.add_header('Content-Disposition', cdisp, **cdisp_params)
404
405 self.set_payload(mail.body)
406 encoders.encode_base64(self)
407
408 def __repr__(self):
409 return "<MIMEPart '%s/%s': %r, %r, multipart=%r>" % (
410 self.subtype,
411 self.maintype,
412 self['Content-Type'],
413 self['Content-Disposition'],
414 self.is_multipart())
415
416
417 def header_to_mime_encoding(value, not_email=False, separator=", "):
418 if not value: return ""
419
420 encoder = Charset(DEFAULT_ENCODING)
421 if type(value) == list:
422 return separator.join(properly_encode_header(
423 v, encoder, not_email) for v in value)
424 else:
425 return properly_encode_header(value, encoder, not_email)
426
427 def properly_encode_header(value, encoder, not_email):
428 """
429 The only thing special (weird) about this function is that it tries
430 to do a fast check to see if the header value has an email address in
431 it. Since random headers could have an email address, and email addresses
432 have weird special formatting rules, we have to check for it.
433
434 Normally this works fine, but in Librelist, we need to "obfuscate" email
435 addresses by changing the '@' to '-AT-'. This is where
436 VALUE_IS_EMAIL_ADDRESS exists. It's a simple lambda returning True/False
437 to check if a header value has an email address. If you need to make this
438 check different, then change this.
439 """
440 try:
441 return value.encode("ascii")
442 except UnicodeEncodeError:
443 if not_email is False and VALUE_IS_EMAIL_ADDRESS(value):
444 # this could have an email address, make sure we don't screw it up
445 name, address = parseaddr(value)
446 return '"%s" <%s>' % (
447 encoder.header_encode(name.encode("utf-8")), address)
448
449 return encoder.header_encode(value.encode("utf-8"))