1 | ############################################################################## |
---|
2 | # |
---|
3 | # Copyright (c) 2002 Zope Foundation and Contributors. |
---|
4 | # |
---|
5 | # This software is subject to the provisions of the Zope Public License, |
---|
6 | # Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. |
---|
7 | # THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED |
---|
8 | # WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED |
---|
9 | # WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS |
---|
10 | # FOR A PARTICULAR PURPOSE. |
---|
11 | # |
---|
12 | ############################################################################## |
---|
13 | """SMTP mail objects |
---|
14 | """ |
---|
15 | import logging |
---|
16 | from os.path import realpath |
---|
17 | import re |
---|
18 | from cStringIO import StringIO |
---|
19 | from copy import deepcopy |
---|
20 | from email.Header import Header |
---|
21 | from email.Charset import Charset |
---|
22 | from email import message_from_string |
---|
23 | from email.Message import Message |
---|
24 | from email import Encoders |
---|
25 | try: |
---|
26 | import email.utils as emailutils |
---|
27 | except ImportError: |
---|
28 | import email.Utils as emailutils |
---|
29 | import email.Charset |
---|
30 | # We import from a private module here because the email module |
---|
31 | # doesn't provide a good public address list parser |
---|
32 | import uu |
---|
33 | |
---|
34 | from threading import Lock |
---|
35 | import time |
---|
36 | |
---|
37 | from AccessControl.class_init import InitializeClass |
---|
38 | from AccessControl.SecurityInfo import ClassSecurityInfo |
---|
39 | from AccessControl.Permissions import change_configuration, view |
---|
40 | from AccessControl.Permissions import use_mailhost_services |
---|
41 | from Acquisition import Implicit |
---|
42 | from App.special_dtml import DTMLFile |
---|
43 | from DateTime.DateTime import DateTime |
---|
44 | from Persistence import Persistent |
---|
45 | from OFS.role import RoleManager |
---|
46 | from OFS.SimpleItem import Item |
---|
47 | |
---|
48 | from zope.interface import implements |
---|
49 | from zope.sendmail.mailer import SMTPMailer |
---|
50 | from zope.sendmail.maildir import Maildir |
---|
51 | from zope.sendmail.delivery import DirectMailDelivery, QueuedMailDelivery, \ |
---|
52 | QueueProcessorThread |
---|
53 | |
---|
54 | from interfaces import IMailHost |
---|
55 | from decorator import synchronized |
---|
56 | |
---|
57 | queue_threads = {} # maps MailHost path -> queue processor threada |
---|
58 | |
---|
59 | LOG = logging.getLogger('MailHost') |
---|
60 | |
---|
61 | # Encode utf-8 emails as Quoted Printable by default |
---|
62 | email.Charset.add_charset("utf-8", email.Charset.QP, email.Charset.QP, "utf-8") |
---|
63 | formataddr = emailutils.formataddr |
---|
64 | parseaddr = emailutils.parseaddr |
---|
65 | getaddresses = emailutils.getaddresses |
---|
66 | CHARSET_RE = re.compile('charset=[\'"]?([\w-]+)[\'"]?', re.IGNORECASE) |
---|
67 | |
---|
68 | |
---|
69 | class MailHostError(Exception): |
---|
70 | pass |
---|
71 | |
---|
72 | manage_addMailHostForm = DTMLFile('dtml/addMailHost_form', globals()) |
---|
73 | |
---|
74 | |
---|
75 | def manage_addMailHost(self, |
---|
76 | id, |
---|
77 | title='', |
---|
78 | smtp_host='localhost', |
---|
79 | localhost='localhost', |
---|
80 | smtp_port=25, |
---|
81 | timeout=1.0, |
---|
82 | REQUEST=None, |
---|
83 | ): |
---|
84 | """ Add a MailHost into the system. |
---|
85 | """ |
---|
86 | i = MailHost(id, title, smtp_host, smtp_port) |
---|
87 | self._setObject(id, i) |
---|
88 | |
---|
89 | if REQUEST is not None: |
---|
90 | REQUEST['RESPONSE'].redirect(self.absolute_url()+'/manage_main') |
---|
91 | |
---|
92 | add = manage_addMailHost |
---|
93 | |
---|
94 | |
---|
95 | class MailBase(Implicit, Item, RoleManager): |
---|
96 | """a mailhost...?""" |
---|
97 | |
---|
98 | implements(IMailHost) |
---|
99 | |
---|
100 | meta_type = 'Mail Host' |
---|
101 | manage = manage_main = DTMLFile('dtml/manageMailHost', globals()) |
---|
102 | manage_main._setName('manage_main') |
---|
103 | index_html = None |
---|
104 | security = ClassSecurityInfo() |
---|
105 | smtp_uid = '' # Class attributes for smooth upgrades |
---|
106 | smtp_pwd = '' |
---|
107 | smtp_queue = False |
---|
108 | smtp_queue_directory = '/tmp' |
---|
109 | force_tls = False |
---|
110 | lock = Lock() |
---|
111 | |
---|
112 | manage_options = ( |
---|
113 | ( |
---|
114 | {'icon': '', 'label': 'Edit', |
---|
115 | 'action': 'manage_main', |
---|
116 | 'help': ('MailHost', 'Mail-Host_Edit.stx')}, |
---|
117 | ) |
---|
118 | + RoleManager.manage_options |
---|
119 | + Item.manage_options |
---|
120 | ) |
---|
121 | |
---|
122 | def __init__(self, |
---|
123 | id='', |
---|
124 | title='', |
---|
125 | smtp_host='localhost', |
---|
126 | smtp_port=25, |
---|
127 | force_tls=False, |
---|
128 | smtp_uid='', |
---|
129 | smtp_pwd='', |
---|
130 | smtp_queue=False, |
---|
131 | smtp_queue_directory='/tmp', |
---|
132 | ): |
---|
133 | """Initialize a new MailHost instance. |
---|
134 | """ |
---|
135 | self.id = id |
---|
136 | self.title = title |
---|
137 | self.smtp_host = str(smtp_host) |
---|
138 | self.smtp_port = int(smtp_port) |
---|
139 | self.smtp_uid = smtp_uid |
---|
140 | self.smtp_pwd = smtp_pwd |
---|
141 | self.force_tls = force_tls |
---|
142 | self.smtp_queue = smtp_queue |
---|
143 | self.smtp_queue_directory = smtp_queue_directory |
---|
144 | |
---|
145 | |
---|
146 | # staying for now... (backwards compatibility) |
---|
147 | def _init(self, smtp_host, smtp_port): |
---|
148 | self.smtp_host = smtp_host |
---|
149 | self.smtp_port = smtp_port |
---|
150 | |
---|
151 | security.declareProtected(change_configuration, 'manage_makeChanges') |
---|
152 | def manage_makeChanges(self, |
---|
153 | title, |
---|
154 | smtp_host, |
---|
155 | smtp_port, |
---|
156 | smtp_uid='', |
---|
157 | smtp_pwd='', |
---|
158 | smtp_queue=False, |
---|
159 | smtp_queue_directory='/tmp', |
---|
160 | force_tls=False, |
---|
161 | REQUEST=None, |
---|
162 | ): |
---|
163 | """Make the changes. |
---|
164 | """ |
---|
165 | title = str(title) |
---|
166 | smtp_host = str(smtp_host) |
---|
167 | smtp_port = int(smtp_port) |
---|
168 | |
---|
169 | self.title = title |
---|
170 | self.smtp_host = smtp_host |
---|
171 | self.smtp_port = smtp_port |
---|
172 | self.smtp_uid = smtp_uid |
---|
173 | self.smtp_pwd = smtp_pwd |
---|
174 | self.force_tls = force_tls |
---|
175 | self.smtp_queue = smtp_queue |
---|
176 | self.smtp_queue_directory = smtp_queue_directory |
---|
177 | |
---|
178 | # restart queue processor thread |
---|
179 | if self.smtp_queue: |
---|
180 | self._stopQueueProcessorThread() |
---|
181 | self._startQueueProcessorThread() |
---|
182 | else: |
---|
183 | self._stopQueueProcessorThread() |
---|
184 | |
---|
185 | |
---|
186 | if REQUEST is not None: |
---|
187 | msg = 'MailHost %s updated' % self.id |
---|
188 | return self.manage_main(self, REQUEST, manage_tabs_message=msg) |
---|
189 | |
---|
190 | security.declareProtected(use_mailhost_services, 'sendTemplate') |
---|
191 | def sendTemplate(trueself, |
---|
192 | self, |
---|
193 | messageTemplate, |
---|
194 | statusTemplate=None, |
---|
195 | mto=None, |
---|
196 | mfrom=None, |
---|
197 | encode=None, |
---|
198 | REQUEST=None, |
---|
199 | immediate=False, |
---|
200 | charset=None, |
---|
201 | msg_type=None, |
---|
202 | ): |
---|
203 | """Render a mail template, then send it... |
---|
204 | """ |
---|
205 | mtemplate = getattr(self, messageTemplate) |
---|
206 | messageText = mtemplate(self, trueself.REQUEST) |
---|
207 | trueself.send(messageText, mto=mto, mfrom=mfrom, |
---|
208 | encode=encode, immediate=immediate, |
---|
209 | charset=charset, msg_type=msg_type) |
---|
210 | |
---|
211 | if not statusTemplate: |
---|
212 | return "SEND OK" |
---|
213 | try: |
---|
214 | stemplate = getattr(self, statusTemplate) |
---|
215 | return stemplate(self, trueself.REQUEST) |
---|
216 | except: |
---|
217 | return "SEND OK" |
---|
218 | |
---|
219 | security.declareProtected(use_mailhost_services, 'send') |
---|
220 | def send(self, |
---|
221 | messageText, |
---|
222 | mto=None, |
---|
223 | mfrom=None, |
---|
224 | subject=None, |
---|
225 | encode=None, |
---|
226 | immediate=False, |
---|
227 | charset=None, |
---|
228 | msg_type=None, |
---|
229 | ): |
---|
230 | messageText, mto, mfrom = _mungeHeaders(messageText, mto, mfrom, |
---|
231 | subject, charset, msg_type) |
---|
232 | # This encode step is mainly for BBB, encoding should be |
---|
233 | # automatic if charset is passed. The automated charset-based |
---|
234 | # encoding will be preferred if both encode and charset are |
---|
235 | # provided. |
---|
236 | messageText = _encode(messageText, encode) |
---|
237 | self._send(mfrom, mto, messageText, immediate) |
---|
238 | |
---|
239 | # This is here for backwards compatibility only. Possibly it could |
---|
240 | # be used to send messages at a scheduled future time, or via a mail queue? |
---|
241 | security.declareProtected(use_mailhost_services, 'scheduledSend') |
---|
242 | scheduledSend = send |
---|
243 | |
---|
244 | security.declareProtected(use_mailhost_services, 'simple_send') |
---|
245 | def simple_send(self, mto, mfrom, subject, body, immediate=False): |
---|
246 | body = "From: %s\nTo: %s\nSubject: %s\n\n%s" % ( |
---|
247 | mfrom, mto, subject, body) |
---|
248 | |
---|
249 | self._send(mfrom, mto, body, immediate) |
---|
250 | |
---|
251 | |
---|
252 | def _makeMailer(self): |
---|
253 | """ Create a SMTPMailer """ |
---|
254 | return SMTPMailer(hostname=self.smtp_host, |
---|
255 | port=int(self.smtp_port), |
---|
256 | username=self.smtp_uid or None, |
---|
257 | password=self.smtp_pwd or None, |
---|
258 | force_tls=self.force_tls) |
---|
259 | |
---|
260 | security.declarePrivate('_getThreadKey') |
---|
261 | def _getThreadKey(self): |
---|
262 | """ Return the key used to find our processor thread. |
---|
263 | """ |
---|
264 | return realpath(self.smtp_queue_directory) |
---|
265 | |
---|
266 | @synchronized(lock) |
---|
267 | def _stopQueueProcessorThread(self): |
---|
268 | """ Stop thread for processing the mail queue. |
---|
269 | """ |
---|
270 | key = self._getThreadKey() |
---|
271 | if key in queue_threads: |
---|
272 | thread = queue_threads[key] |
---|
273 | thread.stop() |
---|
274 | while thread.isAlive(): |
---|
275 | # wait until thread is really dead |
---|
276 | time.sleep(0.3) |
---|
277 | del queue_threads[key] |
---|
278 | LOG.info('Thread for %s stopped' % key) |
---|
279 | |
---|
280 | @synchronized(lock) |
---|
281 | def _startQueueProcessorThread(self): |
---|
282 | """ Start thread for processing the mail queue. |
---|
283 | """ |
---|
284 | key = self._getThreadKey() |
---|
285 | if key not in queue_threads: |
---|
286 | thread = QueueProcessorThread() |
---|
287 | thread.setMailer(self._makeMailer()) |
---|
288 | thread.setQueuePath(self.smtp_queue_directory) |
---|
289 | thread.start() |
---|
290 | queue_threads[key] = thread |
---|
291 | LOG.info('Thread for %s started' % key) |
---|
292 | |
---|
293 | security.declareProtected(view, 'queueLength') |
---|
294 | def queueLength(self): |
---|
295 | """ return length of mail queue """ |
---|
296 | |
---|
297 | try: |
---|
298 | maildir = Maildir(self.smtp_queue_directory) |
---|
299 | return len([item for item in maildir]) |
---|
300 | except ValueError: |
---|
301 | return 'n/a - %s is not a maildir - please verify your ' \ |
---|
302 | 'configuration' % self.smtp_queue_directory |
---|
303 | |
---|
304 | |
---|
305 | security.declareProtected(view, 'queueThreadAlive') |
---|
306 | def queueThreadAlive(self): |
---|
307 | """ return True/False is queue thread is working |
---|
308 | """ |
---|
309 | th = queue_threads.get(self._getThreadKey()) |
---|
310 | if th: |
---|
311 | return th.isAlive() |
---|
312 | return False |
---|
313 | |
---|
314 | security.declareProtected(change_configuration, |
---|
315 | 'manage_restartQueueThread') |
---|
316 | def manage_restartQueueThread(self, action='start', REQUEST=None): |
---|
317 | """ Restart the queue processor thread """ |
---|
318 | |
---|
319 | if action == 'stop': |
---|
320 | self._stopQueueProcessorThread() |
---|
321 | elif action == 'start': |
---|
322 | self._startQueueProcessorThread() |
---|
323 | else: |
---|
324 | raise ValueError('Unsupported action %s' % action) |
---|
325 | |
---|
326 | if REQUEST is not None: |
---|
327 | msg = 'Queue processor thread %s' % \ |
---|
328 | (action == 'stop' and 'stopped' or 'started') |
---|
329 | return self.manage_main(self, REQUEST, manage_tabs_message=msg) |
---|
330 | |
---|
331 | |
---|
332 | security.declarePrivate('_send') |
---|
333 | def _send(self, mfrom, mto, messageText, immediate=False): |
---|
334 | """ Send the message """ |
---|
335 | |
---|
336 | if immediate: |
---|
337 | self._makeMailer().send(mfrom, mto, messageText) |
---|
338 | else: |
---|
339 | if self.smtp_queue: |
---|
340 | # Start queue processor thread, if necessary |
---|
341 | self._startQueueProcessorThread() |
---|
342 | delivery = QueuedMailDelivery(self.smtp_queue_directory) |
---|
343 | else: |
---|
344 | delivery = DirectMailDelivery(self._makeMailer()) |
---|
345 | |
---|
346 | delivery.send(mfrom, mto, messageText) |
---|
347 | |
---|
348 | InitializeClass(MailBase) |
---|
349 | |
---|
350 | |
---|
351 | class MailHost(Persistent, MailBase): |
---|
352 | """persistent version""" |
---|
353 | |
---|
354 | |
---|
355 | def uu_encoder(msg): |
---|
356 | """For BBB only, don't send uuencoded emails""" |
---|
357 | orig = StringIO(msg.get_payload()) |
---|
358 | encdata = StringIO() |
---|
359 | uu.encode(orig, encdata) |
---|
360 | msg.set_payload(encdata.getvalue()) |
---|
361 | |
---|
362 | # All encodings supported by mimetools for BBB |
---|
363 | ENCODERS = { |
---|
364 | 'base64': Encoders.encode_base64, |
---|
365 | 'quoted-printable': Encoders.encode_quopri, |
---|
366 | '7bit': Encoders.encode_7or8bit, |
---|
367 | '8bit': Encoders.encode_7or8bit, |
---|
368 | 'x-uuencode': uu_encoder, |
---|
369 | 'uuencode': uu_encoder, |
---|
370 | 'x-uue': uu_encoder, |
---|
371 | 'uue': uu_encoder, |
---|
372 | } |
---|
373 | |
---|
374 | |
---|
375 | def _encode(body, encode=None): |
---|
376 | """Manually sets an encoding and encodes the message if not |
---|
377 | already encoded.""" |
---|
378 | if encode is None: |
---|
379 | return body |
---|
380 | mo = message_from_string(body) |
---|
381 | current_coding = mo['Content-Transfer-Encoding'] |
---|
382 | if current_coding == encode: |
---|
383 | # already encoded correctly, may have been automated |
---|
384 | return body |
---|
385 | if mo['Content-Transfer-Encoding'] not in ['7bit', None]: |
---|
386 | raise MailHostError('Message already encoded') |
---|
387 | if encode in ENCODERS: |
---|
388 | ENCODERS[encode](mo) |
---|
389 | if not mo['Content-Transfer-Encoding']: |
---|
390 | mo['Content-Transfer-Encoding'] = encode |
---|
391 | if not mo['Mime-Version']: |
---|
392 | mo['Mime-Version'] = '1.0' |
---|
393 | return mo.as_string() |
---|
394 | |
---|
395 | |
---|
396 | def _mungeHeaders(messageText, mto=None, mfrom=None, subject=None, |
---|
397 | charset=None, msg_type=None): |
---|
398 | """Sets missing message headers, and deletes Bcc. |
---|
399 | returns fixed message, fixed mto and fixed mfrom""" |
---|
400 | # If we have been given unicode fields, attempt to encode them |
---|
401 | if isinstance(messageText, unicode): |
---|
402 | messageText = _try_encode(messageText, charset) |
---|
403 | if isinstance(mto, unicode): |
---|
404 | mto = _try_encode(mto, charset) |
---|
405 | if isinstance(mfrom, unicode): |
---|
406 | mfrom = _try_encode(mfrom, charset) |
---|
407 | if isinstance(subject, unicode): |
---|
408 | subject = _try_encode(subject, charset) |
---|
409 | |
---|
410 | if isinstance(messageText, Message): |
---|
411 | # We already have a message, make a copy to operate on |
---|
412 | mo = deepcopy(messageText) |
---|
413 | else: |
---|
414 | # Otherwise parse the input message |
---|
415 | mo = message_from_string(messageText) |
---|
416 | |
---|
417 | if msg_type and not mo.get('Content-Type'): |
---|
418 | # we don't use get_content_type because that has a default |
---|
419 | # value of 'text/plain' |
---|
420 | mo.set_type(msg_type) |
---|
421 | |
---|
422 | charset = _set_recursive_charset(mo, charset=charset) |
---|
423 | |
---|
424 | # Parameters given will *always* override headers in the messageText. |
---|
425 | # This is so that you can't override or add to subscribers by adding |
---|
426 | # them to # the message text. |
---|
427 | if subject: |
---|
428 | # remove any existing header otherwise we get two |
---|
429 | del mo['Subject'] |
---|
430 | # Perhaps we should ignore errors here and pass 8bit strings |
---|
431 | # on encoding errors |
---|
432 | mo['Subject'] = Header(subject, charset, errors='replace') |
---|
433 | elif not mo.get('Subject'): |
---|
434 | mo['Subject'] = '[No Subject]' |
---|
435 | |
---|
436 | if mto: |
---|
437 | if isinstance(mto, basestring): |
---|
438 | mto = [formataddr(addr) for addr in getaddresses((mto, ))] |
---|
439 | if not mo.get('To'): |
---|
440 | mo['To'] = ', '.join(str(_encode_address_string(e, charset)) |
---|
441 | for e in mto) |
---|
442 | else: |
---|
443 | # If we don't have recipients, extract them from the message |
---|
444 | mto = [] |
---|
445 | for header in ('To', 'Cc', 'Bcc'): |
---|
446 | v = ','.join(mo.get_all(header) or []) |
---|
447 | if v: |
---|
448 | mto += [formataddr(addr) for addr in getaddresses((v, ))] |
---|
449 | if not mto: |
---|
450 | raise MailHostError("No message recipients designated") |
---|
451 | |
---|
452 | if mfrom: |
---|
453 | # XXX: do we really want to override an explicitly set From |
---|
454 | # header in the messageText |
---|
455 | del mo['From'] |
---|
456 | mo['From'] = _encode_address_string(mfrom, charset) |
---|
457 | else: |
---|
458 | if mo.get('From') is None: |
---|
459 | raise MailHostError("Message missing SMTP Header 'From'") |
---|
460 | mfrom = mo['From'] |
---|
461 | |
---|
462 | if mo.get('Bcc'): |
---|
463 | del mo['Bcc'] |
---|
464 | |
---|
465 | if not mo.get('Date'): |
---|
466 | mo['Date'] = DateTime().rfc822() |
---|
467 | |
---|
468 | return mo.as_string(), mto, mfrom |
---|
469 | |
---|
470 | |
---|
471 | def _set_recursive_charset(payload, charset=None): |
---|
472 | """Set charset for all parts of an multipart message.""" |
---|
473 | def _set_payload_charset(payload, charset=None, index=None): |
---|
474 | payload_from_string = False |
---|
475 | if not isinstance(payload, Message): |
---|
476 | payload = message_from_string(payload) |
---|
477 | payload_from_string = True |
---|
478 | charset_match = CHARSET_RE.search(payload['Content-Type'] or '') |
---|
479 | if charset and not charset_match: |
---|
480 | # Don't change the charset if already set |
---|
481 | # This encodes the payload automatically based on the default |
---|
482 | # encoding for the charset |
---|
483 | if payload_from_string: |
---|
484 | payload.get_payload()[index] = payload |
---|
485 | else: |
---|
486 | payload.set_charset(charset) |
---|
487 | elif charset_match and not charset: |
---|
488 | # If a charset parameter was provided use it for header encoding |
---|
489 | # below, otherwise, try to use the charset provided in the message. |
---|
490 | charset = charset_match.groups()[0] |
---|
491 | return charset |
---|
492 | if payload.is_multipart(): |
---|
493 | for index, payload in enumerate(payload.get_payload()): |
---|
494 | if payload.get_filename() is None: |
---|
495 | if not payload.is_multipart(): |
---|
496 | charset = _set_payload_charset(payload, |
---|
497 | charset=charset, |
---|
498 | index=index) |
---|
499 | else: |
---|
500 | _set_recursive_charset(payload, charset=charset) |
---|
501 | else: |
---|
502 | charset = _set_payload_charset(payload, charset=charset) |
---|
503 | return charset |
---|
504 | |
---|
505 | |
---|
506 | def _try_encode(text, charset): |
---|
507 | """Attempt to encode using the default charset if none is |
---|
508 | provided. Should we permit encoding errors?""" |
---|
509 | if charset: |
---|
510 | return text.encode(charset) |
---|
511 | else: |
---|
512 | return text.encode() |
---|
513 | |
---|
514 | |
---|
515 | def _encode_address_string(text, charset): |
---|
516 | """Split the email into parts and use header encoding on the name |
---|
517 | part if needed. We do this because the actual addresses need to be |
---|
518 | ASCII with no encoding for most SMTP servers, but the non-address |
---|
519 | parts should be encoded appropriately.""" |
---|
520 | header = Header() |
---|
521 | name, addr = parseaddr(text) |
---|
522 | try: |
---|
523 | name.decode('us-ascii') |
---|
524 | except UnicodeDecodeError: |
---|
525 | if charset: |
---|
526 | charset = Charset(charset) |
---|
527 | name = charset.header_encode(name) |
---|
528 | # We again replace rather than raise an error or pass an 8bit string |
---|
529 | header.append(formataddr((name, addr)), errors='replace') |
---|
530 | return header |
---|