1
2
3 """MIME-encoded electronic mail message classes."""
4
5 from turbomail import release
6
7 import turbogears, re, os, email
8
9 import email.Message
10 from email import Encoders, Charset
11 from email.Message import Message as MIMEMessage
12 from email.Utils import formatdate
13 from email.MIMEMultipart import MIMEMultipart
14 from email.MIMEBase import MIMEBase
15 from email.MIMEText import MIMEText
16 from email.Header import Header
17
18 import logging
19 log = logging.getLogger("turbomail.message")
20
21
22 __all__ = ['Message', 'KIDMessage']
23 _rich_to_plain = re.compile(r"(<[^>]+>)")
24
25
27 """Simple e-mail message class.
28
29 Message provides a means to easily create e-mail messages to be
30 sent through the Dispatch mechanism or MailPool. Message provides
31 various helper functions to correctly format plain text, dual plain
32 text and rich text MIME encoded messages, as well as handle
33 embedded and external attachments.
34
35 All properties can be set from the constructor.
36
37 Example usage::
38
39 import turbomail
40 message = turbomail.Message(
41 "from@host.com",
42 "to@host.com",
43 "Subject",
44 plain="This is a plain message."
45 )
46
47 E-mail addresses can be represented as any of the following:
48 - A string.
49 - A 2-tuple of ("Full Name", "name@host.tld")
50
51 Encoding can be overridden on a per-message basis, but note that
52 'utf-8-qp' modifies the default 'utf-8' behaviour to output
53 quoted-printable, and you will have to change it back yourself if
54 you want base64 encoding.
55
56 @ivar _processed: Has the MIME-encoded message been generated?
57 @type _processed: bool
58 @ivar _dirty: Has there been changes since the MIME message was last
59 generated?
60 @type _dirty: bool
61 @ivar date: The Date header. Must be correctly formatted.
62 @type date: string
63 @ivar recipient: The To header. A string, 2-tuple, or list of
64 strings or 2-tuples.
65 @ivar sender: The From header. A string or 2-tuple.
66 @ivar organization: The Organization header. I{Optional.}
67 @type organization: string
68 @ivar replyto: The X-Reply-To header. A string or 2-tuple.
69 I{Optional.}
70 @ivar disposition: The Disposition-Notification-To header. A string
71 or 2-tuple. I{Optional.}
72 @ivar cc: The CC header. As per the recipient property.
73 I{Optional.}
74 @ivar bcc: The BCC header. As per the recipient property.
75 I{Optional.}
76 @ivar encoding: Content encoding. Pulled from I{mail.encoding},
77 defaults to 'us-ascii'.
78 @type encoding: string
79 @ivar priority: The X-Priority header, a number ranging from 1-5.
80 I{Optional.} Default: B{3}
81 @type priority: int
82 @ivar subject: The Subject header.
83 @type subject: string
84 @ivar plain: The plain text content of the message.
85 @type plain: string
86 @ivar rich: The rich text (HTML) content of the message. Plain text
87 content B{must} be available as well.
88 @type rich: string
89 @ivar attachments: A list of MIME-encoded attachments.
90 @type attachments: list
91 @ivar embedded: A list of MIME-encoded embedded obejects for use in
92 the text/html part.
93 @type embedded: list
94 @ivar headers: A list of additional headers. Can be added in a wide
95 variety of formats: a list of strings, list of
96 tuples, a dictionary, etc. Look at the code.
97 @ivar smtpfrom: The envelope address, if different than the sender.
98 """
99
100 - def __init__(self, sender=None, recipient=None, subject=None, **kw):
101 """Instantiate a new Message object.
102
103 No arguments are required, as everything can be set using class
104 properties. Alternatively, I{everything} can be set using the
105 constructor, using named arguments. The first three positional
106 arguments can be used to quickly prepare a simple message.
107
108 An instance of Message is callable.
109
110 @param sender: The e-mail address of the sender. This is
111 encoded as the "From:" SMTP header.
112 @type sender: string
113
114 @param recipient: The recipient of the message. This gets
115 encoded as the "To:" SMTP header.
116 @type recipient: string
117
118 @param subject: The subject of the message. This gets encoded
119 as the "Subject:" SMTP header.
120 @type subject: string
121 """
122
123 super(Message, self).__init__()
124
125 self._processed = False
126 self._dirty = False
127
128 self.date = formatdate(localtime=True)
129 self.recipient = recipient
130 self.sender = sender
131 self.organization = None
132 self.replyto = None
133 self.disposition = None
134 self.cc = []
135 self.bcc = []
136 self.encoding = turbogears.config.get("mail.encoding", 'us-ascii')
137 self.priority = 3
138 self.subject = subject
139 self.plain = None
140 self.rich = None
141 self.attachments = []
142 self.embedded = []
143 self.headers = []
144 self.smtpfrom = None
145
146 for i, j in kw.iteritems():
147 assert i in self.__dict__, "Unknown attribute: '%s'" % i
148 self.__dict__[i] = j
149
150 - def attach(self, file, name=None):
151 """Attach an on-disk file to this message.
152
153 @param file: The path to the file you wish to attach, or an
154 instance of a file-like object.
155
156 @param name: You can optionally override the filename of the
157 attached file. This name will appear in the
158 recipient's mail viewer. B{Optional if passing
159 an on-disk path. Required if passing a file-like
160 object.}
161 @type name: string
162 """
163
164 part = MIMEBase('application', "octet-stream")
165
166 if isinstance(file, (str, unicode)):
167 fp = open(file, "rb")
168 else:
169 assert name is not None, "If attaching a file-like object, you must pass a custom filename."
170 fp = file
171
172 part.set_payload(fp.read())
173 Encoders.encode_base64(part)
174
175 part.add_header('Content-Disposition', 'attachment', filename=os.path.basename([name, file][name is None]))
176
177 self.attachments.append(part)
178
179 - def embed(self, file, name):
180 """Attach an on-disk image file and prepare for HTML embedding.
181
182 This method should only be used to embed images.
183
184 @param file: The path to the file you wish to attach, or an
185 instance of a file-like object.
186
187 @param name: You can optionally override the filename of the
188 attached file. This name will appear in the
189 recipient's mail viewer. B{Optional if passing
190 an on-disk path. Required if passing a file-like
191 object.}
192 @type name: string
193 """
194
195 from email.MIMEImage import MIMEImage
196
197 if isinstance(file, (str, unicode)):
198 fp = open(file, "rb")
199 else:
200 assert name is not None, "If embedding a file-like object, you must pass a custom filename."
201 fp = file
202
203 part = MIMEImage(fp.read())
204 fp.close()
205
206 part.add_header('Content-ID', '<%s>' % name)
207
208 self.embedded.append(part)
209
211 """A utility function to return a list of addresses as a string."""
212
213 addresses = []
214 for i in [[addresslist], addresslist][type(addresslist) == type([])]:
215 if type(i) == type(()):
216 addresses.append('"%s" <%s>' % (str(Header(i[0])), i[1]))
217 else: addresses.append(i)
218
219 return ",\n ".join(addresses)
220
222 """Produce the final MIME message.
223
224 Additinoally, if only a rich text part exits, strip the HTML to
225 produce the plain text part. (This produces identical output as
226 KID, although lacks reverse entity conversion -- &, etc.)
227 """
228
229 if self.encoding == 'utf-8-qp':
230 Charset.add_charset('utf-8', Charset.SHORTEST, Charset.QP, 'utf-8')
231 self.encoding = 'utf-8'
232
233 if callable(self.plain):
234 self.plain = self.plain()
235
236 if callable(self.rich):
237 self.rich = self.rich()
238
239 if self.rich and not self.plain:
240 self.plain = _rich_to_plain.sub('', self.rich)
241
242 if not self.rich:
243 if not self.attachments:
244 message = MIMEText(self.plain.encode(self.encoding), 'plain', self.encoding)
245
246 else:
247 message = MIMEMultipart()
248 message.attach(MIMEText(self.plain.encode(self.encoding), 'plain', self.encoding))
249
250 else:
251 if not self.attachments:
252 message = MIMEMultipart('alternative')
253 message.attach(MIMEText(self.plain.encode(self.encoding), 'plain', self.encoding))
254
255 if not self.embedded:
256 message.attach(MIMEText(self.rich.encode(self.encoding), 'html', self.encoding))
257 else:
258 related = MIMEMultipart('related')
259 message.attach(related)
260 related.attach(MIMEText(self.rich.encode(self.encoding), 'html', self.encoding))
261
262 for attachment in self.embedded:
263 related.attach(attachment)
264
265 else:
266 message = MIMEMultipart()
267 alternative = MIMEMultipart('alternative')
268 message.attach(alternative)
269
270 alternative.attach(MIMEText(self.plain.encode(self.encoding), 'plain', self.encoding))
271
272 if not self.embedded:
273 alternative.attach(MIMEText(self.rich.encode(self.encoding), 'html', self.encoding))
274 else:
275 related = MIMEMultipart('related')
276 alternative.attach(related)
277 related.attach(MIMEText(self.rich.encode(self.encoding), 'html', self.encoding))
278
279 for attachment in self.embedded:
280 related.attach(attachment)
281
282 for attachment in self.attachments:
283 message.attach(attachment)
284
285 message.add_header('From', self._normalize(self.sender))
286 message.add_header('Subject', self.subject)
287 message.add_header('Date', formatdate(localtime=True))
288 message.add_header('To', self._normalize(self.recipient))
289 if self.replyto: message.add_header('Reply-To', self._normalize(self.replyto))
290 if self.cc: message.add_header('Cc', self._normalize(self.cc))
291 if self.disposition: message.add_header('Disposition-Notification-To', self._normalize(self.disposition))
292 if self.organization: message.add_header('Organization', self.organization)
293 if self.priority != 3: message.add_header('X-Priority', self.priority)
294
295 if not self.smtpfrom:
296 if type(self.sender) == type([]) and len(self.sender) > 1:
297 message.add_header('Sender', self._normalize(self.sender[0]))
298 message.add_header('Return-Path', self._normalize(self.sender[0]))
299 else:
300 message.add_header('Return-Path', self._normalize(self.sender))
301 else:
302 message.add_header('Return-Path', self._normalize(self.sender))
303 message.add_header('Sender', self._normalize(self.smtpfrom))
304 message.add_header('Return-Path', self._normalize(self.smtpfrom))
305 message.add_header('Old-Return-Path', self._normalize(self.smtpfrom))
306
307 message.add_header('X-Mailer', "TurboMail TurboGears Extension v.%s" % release.version)
308
309 if type(self.headers) == type(()):
310 for header in self.headers:
311 if type(header) in [type(()), type([])]:
312 message.add_header(*header)
313 elif type(header) == type({}):
314 message.add_header(**header)
315
316 if type(self.headers) == type({}):
317 for name, header in self.headers.iteritems():
318 if type(header) in [type(()), type([])]:
319 message.add_header(name, *header)
320 elif type(header) == type({}):
321 message.add_header(name, **header)
322 else:
323 message.add_header(name, header)
324
325 self._message = message
326 self._processed = True
327 self._dirty = False
328
330 """Set the dirty flag as properties are updated."""
331
332 self.__dict__[name] = value
333 if name != '_dirty': self.__dict__['_dirty'] = True
334
336 """Produce a valid MIME-encoded message and return valid input
337 for the Dispatch class to process.
338
339 @return: Returns a tuple containing sender and recipient e-mail
340 addresses and the string output of MIMEMultipart.
341 @rtype: tuple
342 """
343
344 if not self._processed or self._dirty:
345 self._process()
346
347 recipients = []
348
349 if isinstance(self.recipient, list):
350 recipients.extend(self.recipient)
351 else: recipients.append(self.recipient)
352
353 if isinstance(self.cc, list):
354 recipients.extend(self.cc)
355 else: recipients.append(self.cc)
356
357 if isinstance(self.bcc, list):
358 recipients.extend(self.bcc)
359 else: recipients.append(self.bcc)
360
361 return dict(
362 sender=self.sender,
363 to=[[self.recipient], self.recipient][isinstance(self.recipient, list)],
364 recipients=[i[1] for i in recipients if isinstance(i, tuple)] + [i for i in recipients if not isinstance(i, tuple)],
365 subject=self.subject,
366 message=self._message.as_string(),
367 )
368
369
371 """A message that accepts a named template with arguments.
372
373 Example usage::
374
375 import turbomail
376 message = turbomail.KIDMessage(
377 "from@host.com",
378 "to@host.com",
379 "Subject",
380 "app.templates.mail",
381 dict()
382 )
383
384 Do not specify message.plain or message.rich content - the template
385 will override what you set. If you wish to hand-produce content,
386 use the Message class.
387 """
388
389 - def __init__(self, sender, recipient, subject, template, variables={}, **kw):
390 """Store the additonal template and variable information.
391
392 @param template: A dot-path to a valid KID template.
393 @type template: string
394
395 @param variables: A dictionary containing named variables to
396 pass to the template engine.
397 @type variables: dict
398 """
399
400 log.warn("Use of KIDMessage is deprecated and will be removed in version 2.1.")
401
402 self._template = template
403 self._variables = dict(sender=sender, recipient=recipient, subject=subject)
404 self._variables.update(variables)
405
406 super(KIDMessage, self).__init__(sender, recipient, subject, **kw)
407
409 """Automatically generate the plain and rich text content."""
410
411
412
413 data = dict()
414
415 for (i, j) in self._variables.iteritems():
416 if callable(j): data[i] = j()
417 else: data[i] = j
418
419 self.plain = turbogears.view.engines.get('kid').render(data, format="plain", template=self._template)
420 self.rich = turbogears.view.engines.get('kid').render(data, template=self._template)
421
422 return super(KIDMessage, self)._process()
423