1 | # -*- coding: utf-8 -*- |
---|
2 | # |
---|
3 | # Copyright (c) 2008, Stephen Hansen |
---|
4 | # Copyright (c) 2009, Robert Corsaro |
---|
5 | # Copyright (c) 2010, Steffen Hoffmann |
---|
6 | # |
---|
7 | # All rights reserved. |
---|
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 |
---|
13 | # notice, this list of conditions and the following disclaimer. |
---|
14 | # * Redistributions in binary form must reproduce the above copyright |
---|
15 | # notice, this list of conditions and the following disclaimer in the |
---|
16 | # documentation and/or other materials provided with the distribution. |
---|
17 | # * Neither the name of the <ORGANIZATION> nor the names of its |
---|
18 | # contributors may be used to endorse or promote products derived from |
---|
19 | # this software without specific prior written permission. |
---|
20 | # |
---|
21 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
---|
22 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
---|
23 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR |
---|
24 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR |
---|
25 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, |
---|
26 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, |
---|
27 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR |
---|
28 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF |
---|
29 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING |
---|
30 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS |
---|
31 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
---|
32 | # ---------------------------------------------------------------------------- |
---|
33 | |
---|
34 | # TODO: pick format based on subscription. For now users will use the same |
---|
35 | # format for all announcements, but in the future we can make this more |
---|
36 | # flexible, since it's in the subscription table. |
---|
37 | import Queue |
---|
38 | import random |
---|
39 | import re |
---|
40 | import smtplib |
---|
41 | import sys |
---|
42 | import threading |
---|
43 | import time |
---|
44 | |
---|
45 | from subprocess import Popen, PIPE |
---|
46 | |
---|
47 | from email.MIMEMultipart import MIMEMultipart |
---|
48 | from email.MIMEText import MIMEText |
---|
49 | from email.Utils import formatdate, formataddr |
---|
50 | from email.Charset import Charset, QP, BASE64 |
---|
51 | try: |
---|
52 | from email.header import Header |
---|
53 | except: |
---|
54 | from email.Header import Header |
---|
55 | |
---|
56 | from trac.core import * |
---|
57 | from trac.util.compat import set, sorted |
---|
58 | from trac.config import Option, BoolOption, IntOption, \ |
---|
59 | OrderedExtensionsOption, ChoiceOption |
---|
60 | from trac.config import ExtensionOption |
---|
61 | from trac.util import get_pkginfo, md5 |
---|
62 | from trac.util.datefmt import to_timestamp |
---|
63 | from trac.util.text import to_unicode, CRLF |
---|
64 | |
---|
65 | from announcer.api import AnnouncementSystem |
---|
66 | from announcer.api import IAnnouncementAddressResolver |
---|
67 | from announcer.api import IAnnouncementDistributor |
---|
68 | from announcer.api import IAnnouncementFormatter |
---|
69 | from announcer.api import IAnnouncementPreferenceProvider |
---|
70 | from announcer.api import IAnnouncementProducer |
---|
71 | from announcer.api import _ |
---|
72 | |
---|
73 | from announcer.util.mail import set_header |
---|
74 | from announcer.util.mail_crypto import CryptoTxt |
---|
75 | |
---|
76 | |
---|
77 | class IEmailSender(Interface): |
---|
78 | """Extension point interface for components that allow sending e-mail.""" |
---|
79 | |
---|
80 | def send(self, from_addr, recipients, message): |
---|
81 | """Send message to recipients.""" |
---|
82 | |
---|
83 | |
---|
84 | class IAnnouncementEmailDecorator(Interface): |
---|
85 | def decorate_message(event, message, decorators): |
---|
86 | """Manipulate the message before it is sent on it's way. The callee |
---|
87 | should call the next decorator by popping decorators and calling the |
---|
88 | popped decorator. If decorators is empty, don't worry about it. |
---|
89 | """ |
---|
90 | |
---|
91 | |
---|
92 | class EmailDistributor(Component): |
---|
93 | |
---|
94 | implements(IAnnouncementDistributor) |
---|
95 | |
---|
96 | formatters = ExtensionPoint(IAnnouncementFormatter) |
---|
97 | # Make ordered |
---|
98 | decorators = ExtensionPoint(IAnnouncementEmailDecorator) |
---|
99 | |
---|
100 | resolvers = OrderedExtensionsOption('announcer', 'email_address_resolvers', |
---|
101 | IAnnouncementAddressResolver, 'SpecifiedEmailResolver, '\ |
---|
102 | 'SessionEmailResolver, DefaultDomainEmailResolver', |
---|
103 | """Comma seperated list of email resolver components in the order |
---|
104 | they will be called. If an email address is resolved, the remaining |
---|
105 | resolvers will not be called. |
---|
106 | """) |
---|
107 | |
---|
108 | email_sender = ExtensionOption('announcer', 'email_sender', |
---|
109 | IEmailSender, 'SmtpEmailSender', |
---|
110 | """Name of the component implementing `IEmailSender`. |
---|
111 | |
---|
112 | This component is used by the announcer system to send emails. |
---|
113 | Currently, `SmtpEmailSender` and `SendmailEmailSender` are provided. |
---|
114 | """) |
---|
115 | |
---|
116 | enabled = BoolOption('announcer', 'email_enabled', 'true', |
---|
117 | """Enable email notification.""") |
---|
118 | |
---|
119 | email_from = Option('announcer', 'email_from', 'trac@localhost', |
---|
120 | """Sender address to use in notification emails.""") |
---|
121 | |
---|
122 | from_name = Option('announcer', 'email_from_name', '', |
---|
123 | """Sender name to use in notification emails.""") |
---|
124 | |
---|
125 | replyto = Option('announcer', 'email_replyto', 'trac@localhost', |
---|
126 | """Reply-To address to use in notification emails.""") |
---|
127 | |
---|
128 | mime_encoding = ChoiceOption('announcer', 'mime_encoding', |
---|
129 | ['base64', 'qp', 'none'], |
---|
130 | """Specifies the MIME encoding scheme for emails. |
---|
131 | |
---|
132 | Valid options are 'base64' for Base64 encoding, 'qp' for |
---|
133 | Quoted-Printable, and 'none' for no encoding. Note that the no encoding |
---|
134 | means that non-ASCII characters in text are going to cause problems |
---|
135 | with notifications. |
---|
136 | """) |
---|
137 | |
---|
138 | use_public_cc = BoolOption('announcer', 'use_public_cc', 'false', |
---|
139 | """Recipients can see email addresses of other CC'ed recipients. |
---|
140 | |
---|
141 | If this option is disabled (the default), recipients are put on BCC |
---|
142 | """) |
---|
143 | |
---|
144 | # used in email decorators, but not here |
---|
145 | subject_prefix = Option('announcer', 'email_subject_prefix', |
---|
146 | '__default__', |
---|
147 | """Text to prepend to subject line of notification emails. |
---|
148 | |
---|
149 | If the setting is not defined, then the [$project_name] prefix. |
---|
150 | If no prefix is desired, then specifying an empty option |
---|
151 | will disable it. |
---|
152 | """) |
---|
153 | |
---|
154 | to = Option('announcer', 'email_to', 'undisclosed-recipients: ;', |
---|
155 | 'Default To: field') |
---|
156 | |
---|
157 | use_threaded_delivery = BoolOption('announcer', 'use_threaded_delivery', |
---|
158 | 'false', |
---|
159 | """Do message delivery in a separate thread. |
---|
160 | |
---|
161 | Enabling this will improve responsiveness for requests that end up |
---|
162 | with an announcement being sent over email. It requires building |
---|
163 | Python with threading support enabled-- which is usually the case. |
---|
164 | To test, start Python and type 'import threading' to see |
---|
165 | if it raises an error. |
---|
166 | """) |
---|
167 | |
---|
168 | default_email_format = Option('announcer', 'default_email_format', |
---|
169 | 'text/plain', |
---|
170 | """The default mime type of the email notifications. |
---|
171 | |
---|
172 | This can be overridden on a per user basis through the announcer |
---|
173 | preferences panel. |
---|
174 | """) |
---|
175 | |
---|
176 | rcpt_allow_regexp = Option('announcer', 'rcpt_allow_regexp', '', |
---|
177 | """A whitelist pattern to match any address to before adding to |
---|
178 | recipients list. |
---|
179 | """) |
---|
180 | |
---|
181 | rcpt_local_regexp = Option('announcer', 'rcpt_local_regexp', '', |
---|
182 | """A whitelist pattern to match any address, that should be |
---|
183 | considered local. |
---|
184 | |
---|
185 | This will be evaluated only if msg encryption is set too. |
---|
186 | Recipients with matching email addresses will continue to |
---|
187 | receive unencrypted email messages. |
---|
188 | """) |
---|
189 | |
---|
190 | crypto = Option('announcer', 'email_crypto', '', |
---|
191 | """Enable cryptographically operation on email msg body. |
---|
192 | |
---|
193 | Empty string, the default for unset, disables all crypto operations. |
---|
194 | Valid values are: |
---|
195 | sign sign msg body with given privkey |
---|
196 | encrypt encrypt msg body with pubkeys of all recipients |
---|
197 | sign,encrypt sign, than encrypt msg body |
---|
198 | """) |
---|
199 | |
---|
200 | # get GnuPG configuration options |
---|
201 | gpg_binary = Option('announcer', 'gpg_binary', 'gpg', |
---|
202 | """GnuPG binary name, allows for full path too. |
---|
203 | |
---|
204 | Value 'gpg' is same default as in python-gnupg itself. |
---|
205 | For usual installations location of the gpg binary is auto-detected. |
---|
206 | """) |
---|
207 | |
---|
208 | gpg_home = Option('announcer', 'gpg_home', '', |
---|
209 | """Directory containing keyring files. |
---|
210 | |
---|
211 | In case of wrong configuration missing keyring files without content |
---|
212 | will be created in the configured location, provided necessary |
---|
213 | write permssion is granted for the corresponding parent directory. |
---|
214 | """) |
---|
215 | |
---|
216 | private_key = Option('announcer', 'gpg_signing_key', None, |
---|
217 | """Keyid of private key (last 8 chars or more) used for signing. |
---|
218 | |
---|
219 | If unset, a private key will be selected from keyring automagicly. |
---|
220 | The password must be available i.e. provided by running gpg-agent |
---|
221 | or empty (bad security). On failing to unlock the private key, |
---|
222 | msg body will get emptied. |
---|
223 | """) |
---|
224 | |
---|
225 | |
---|
226 | def __init__(self): |
---|
227 | self.delivery_queue = None |
---|
228 | self._init_pref_encoding() |
---|
229 | |
---|
230 | def get_delivery_queue(self): |
---|
231 | if not self.delivery_queue: |
---|
232 | self.delivery_queue = Queue.Queue() |
---|
233 | thread = DeliveryThread(self.delivery_queue, self.send) |
---|
234 | thread.start() |
---|
235 | return self.delivery_queue |
---|
236 | |
---|
237 | # IAnnouncementDistributor |
---|
238 | def transports(self): |
---|
239 | yield "email" |
---|
240 | |
---|
241 | def formats(self, transport, realm): |
---|
242 | "Find valid formats for transport and realm" |
---|
243 | formats = {} |
---|
244 | for f in self.formatters: |
---|
245 | for style in f.styles(transport, realm): |
---|
246 | formats[style] = f |
---|
247 | self.log.debug( |
---|
248 | "EmailDistributor has found the following formats capable " |
---|
249 | "of handling '%s' of '%s': %s"%(transport, realm, |
---|
250 | ', '.join(formats.keys()))) |
---|
251 | if not formats: |
---|
252 | self.log.error("EmailDistributor is unable to continue " \ |
---|
253 | "without supporting formatters.") |
---|
254 | return formats |
---|
255 | |
---|
256 | def distribute(self, transport, recipients, event): |
---|
257 | found = False |
---|
258 | for supported_transport in self.transports(): |
---|
259 | if supported_transport == transport: |
---|
260 | found = True |
---|
261 | if not self.enabled or not found: |
---|
262 | self.log.debug("EmailDistributer email_enabled set to false") |
---|
263 | return |
---|
264 | fmtdict = self.formats(transport, event.realm) |
---|
265 | if not fmtdict: |
---|
266 | self.log.error( |
---|
267 | "EmailDistributer No formats found for %s %s"%( |
---|
268 | transport, event.realm)) |
---|
269 | return |
---|
270 | msgdict = {} |
---|
271 | msgdict_encrypt = {} |
---|
272 | msg_pubkey_ids = [] |
---|
273 | # compile pattern before use for better performance |
---|
274 | RCPT_ALLOW_RE = re.compile(self.rcpt_allow_regexp) |
---|
275 | RCPT_LOCAL_RE = re.compile(self.rcpt_local_regexp) |
---|
276 | |
---|
277 | if self.crypto != '': |
---|
278 | self.log.debug("EmailDistributor attempts crypto operation.") |
---|
279 | self.enigma = CryptoTxt(self.gpg_binary, self.gpg_home) |
---|
280 | |
---|
281 | for name, authed, addr in recipients: |
---|
282 | fmt = name and \ |
---|
283 | self._get_preferred_format(event.realm, name, authed) or \ |
---|
284 | self._get_default_format() |
---|
285 | if fmt not in fmtdict: |
---|
286 | self.log.debug(("EmailDistributer format %s not available " + |
---|
287 | "for %s %s, looking for an alternative")%( |
---|
288 | fmt, transport, event.realm)) |
---|
289 | # If the fmt is not available for this realm, then try to find |
---|
290 | # an alternative |
---|
291 | oldfmt = fmt |
---|
292 | fmt = None |
---|
293 | for f in fmtdict.values(): |
---|
294 | fmt = f.alternative_style_for( |
---|
295 | transport, event.realm, oldfmt) |
---|
296 | if fmt: break |
---|
297 | if not fmt: |
---|
298 | self.log.error( |
---|
299 | "EmailDistributer was unable to find a formatter " + |
---|
300 | "for format %s"%k |
---|
301 | ) |
---|
302 | continue |
---|
303 | rslvr = None |
---|
304 | if name and not addr: |
---|
305 | # figure out what the addr should be if it's not defined |
---|
306 | for rslvr in self.resolvers: |
---|
307 | addr = rslvr.get_address_for_name(name, authed) |
---|
308 | if addr: break |
---|
309 | if addr: |
---|
310 | self.log.debug("EmailDistributor found the " \ |
---|
311 | "address '%s' for '%s (%s)' via: %s"%( |
---|
312 | addr, name, authed and \ |
---|
313 | 'authenticated' or 'not authenticated', |
---|
314 | rslvr.__class__.__name__)) |
---|
315 | |
---|
316 | # ok, we found an addr, add the message |
---|
317 | # but wait, check for allowed rcpt first, if set |
---|
318 | if RCPT_ALLOW_RE.search(addr) is not None: |
---|
319 | # check for local recipients now |
---|
320 | local_match = RCPT_LOCAL_RE.search(addr) |
---|
321 | if self.crypto in ['encrypt', 'sign,encrypt'] and \ |
---|
322 | local_match is None: |
---|
323 | # search available public keys for matching UID |
---|
324 | pubkey_ids = self.enigma.get_pubkey_ids(addr) |
---|
325 | if len(pubkey_ids) > 0: |
---|
326 | msgdict_encrypt.setdefault(fmt, set()).add((name, |
---|
327 | authed, addr)) |
---|
328 | msg_pubkey_ids[len(msg_pubkey_ids):] = pubkey_ids |
---|
329 | self.log.debug("EmailDistributor got pubkeys " \ |
---|
330 | "for %s: %s" % (addr, pubkey_ids)) |
---|
331 | else: |
---|
332 | self.log.debug("EmailDistributor dropped %s " \ |
---|
333 | "after missing pubkey with corresponding " \ |
---|
334 | "address %s in any UID" % (name, addr)) |
---|
335 | else: |
---|
336 | msgdict.setdefault(fmt, set()).add((name, authed, |
---|
337 | addr)) |
---|
338 | if local_match is not None: |
---|
339 | self.log.debug("EmailDistributor expected " \ |
---|
340 | "local delivery for %s to: %s" % (name, addr)) |
---|
341 | else: |
---|
342 | self.log.debug("EmailDistributor dropped %s for " \ |
---|
343 | "not matching allowed recipient pattern %s" % \ |
---|
344 | (addr, self.rcpt_allow_regexp)) |
---|
345 | else: |
---|
346 | self.log.debug("EmailDistributor was unable to find an " \ |
---|
347 | "address for: %s (%s)"%(name, authed and \ |
---|
348 | 'authenticated' or 'not authenticated')) |
---|
349 | for k, v in msgdict.items(): |
---|
350 | if not v or not fmtdict.get(k): |
---|
351 | continue |
---|
352 | self.log.debug( |
---|
353 | "EmailDistributor is sending event as '%s' to: %s"%( |
---|
354 | fmt, ', '.join(x[2] for x in v))) |
---|
355 | self._do_send(transport, event, k, v, fmtdict[k]) |
---|
356 | for k, v in msgdict_encrypt.items(): |
---|
357 | if not v or not fmtdict.get(k): |
---|
358 | continue |
---|
359 | self.log.debug( |
---|
360 | "EmailDistributor is sending encrypted info on event " \ |
---|
361 | "as '%s' to: %s"%(fmt, ', '.join(x[2] for x in v))) |
---|
362 | self._do_send(transport, event, k, v, fmtdict[k], msg_pubkey_ids) |
---|
363 | |
---|
364 | def _get_default_format(self): |
---|
365 | return self.default_email_format |
---|
366 | |
---|
367 | def _get_preferred_format(self, realm, sid, authenticated): |
---|
368 | if authenticated is None: |
---|
369 | authenticated = 0 |
---|
370 | db = self.env.get_db_cnx() |
---|
371 | cursor = db.cursor() |
---|
372 | cursor.execute(""" |
---|
373 | SELECT value |
---|
374 | FROM session_attribute |
---|
375 | WHERE sid=%s |
---|
376 | AND authenticated=%s |
---|
377 | AND name=%s |
---|
378 | """, (sid, int(authenticated), 'announcer_email_format_%s' % realm)) |
---|
379 | result = cursor.fetchone() |
---|
380 | if result: |
---|
381 | chosen = result[0] |
---|
382 | self.log.debug("EmailDistributor determined the preferred format" \ |
---|
383 | " for '%s (%s)' is: %s"%(sid, authenticated and \ |
---|
384 | 'authenticated' or 'not authenticated', chosen)) |
---|
385 | return chosen |
---|
386 | else: |
---|
387 | return self._get_default_format() |
---|
388 | |
---|
389 | def _init_pref_encoding(self): |
---|
390 | self._charset = Charset() |
---|
391 | self._charset.input_charset = 'utf-8' |
---|
392 | pref = self.mime_encoding.lower() |
---|
393 | if pref == 'base64': |
---|
394 | self._charset.header_encoding = BASE64 |
---|
395 | self._charset.body_encoding = BASE64 |
---|
396 | self._charset.output_charset = 'utf-8' |
---|
397 | self._charset.input_codec = 'utf-8' |
---|
398 | self._charset.output_codec = 'utf-8' |
---|
399 | elif pref in ['qp', 'quoted-printable']: |
---|
400 | self._charset.header_encoding = QP |
---|
401 | self._charset.body_encoding = QP |
---|
402 | self._charset.output_charset = 'utf-8' |
---|
403 | self._charset.input_codec = 'utf-8' |
---|
404 | self._charset.output_codec = 'utf-8' |
---|
405 | elif pref == 'none': |
---|
406 | self._charset.header_encoding = None |
---|
407 | self._charset.body_encoding = None |
---|
408 | self._charset.input_codec = None |
---|
409 | self._charset.output_charset = 'ascii' |
---|
410 | else: |
---|
411 | raise TracError(_('Invalid email encoding setting: %s'%pref)) |
---|
412 | |
---|
413 | def _message_id(self, realm): |
---|
414 | """Generate a predictable, but sufficiently unique message ID.""" |
---|
415 | modtime = time.time() |
---|
416 | rand = random.randint(0,32000) |
---|
417 | s = '%s.%d.%d.%s' % (self.env.project_url, |
---|
418 | modtime, rand, |
---|
419 | realm.encode('ascii', 'ignore')) |
---|
420 | dig = md5(s).hexdigest() |
---|
421 | host = self.email_from[self.email_from.find('@') + 1:] |
---|
422 | msgid = '<%03d.%s@%s>' % (len(s), dig, host) |
---|
423 | return msgid |
---|
424 | |
---|
425 | def _filter_recipients(self, rcpt): |
---|
426 | return rcpt |
---|
427 | |
---|
428 | def _do_send(self, transport, event, format, recipients, formatter, |
---|
429 | pubkey_ids=[]): |
---|
430 | |
---|
431 | output = formatter.format(transport, event.realm, format, event) |
---|
432 | |
---|
433 | # DEVEL: force message body plaintext style for crypto operations |
---|
434 | if self.crypto != '' and pubkey_ids != []: |
---|
435 | if self.crypto == 'sign': |
---|
436 | output = self.enigma.sign(output, self.private_key) |
---|
437 | elif self.crypto == 'encrypt': |
---|
438 | output = self.enigma.encrypt(output, pubkey_ids) |
---|
439 | elif self.crypto == 'sign,encrypt': |
---|
440 | output = self.enigma.sign_encrypt(output, pubkey_ids, |
---|
441 | self.private_key) |
---|
442 | |
---|
443 | self.log.debug(output) |
---|
444 | self.log.debug(_("EmailDistributor crypto operaton successful.")) |
---|
445 | alternate_output = None |
---|
446 | else: |
---|
447 | alternate_style = formatter.alternative_style_for( |
---|
448 | transport, |
---|
449 | event.realm, |
---|
450 | format |
---|
451 | ) |
---|
452 | if alternate_style: |
---|
453 | alternate_output = formatter.format( |
---|
454 | transport, |
---|
455 | event.realm, |
---|
456 | alternate_style, |
---|
457 | event |
---|
458 | ) |
---|
459 | else: |
---|
460 | alternate_output = None |
---|
461 | |
---|
462 | # sanity check |
---|
463 | if not self._charset.body_encoding: |
---|
464 | try: |
---|
465 | dummy = output.encode('ascii') |
---|
466 | except UnicodeDecodeError: |
---|
467 | raise TracError(_("Ticket contains non-ASCII chars. " \ |
---|
468 | "Please change encoding setting")) |
---|
469 | |
---|
470 | rootMessage = MIMEMultipart("related") |
---|
471 | # TODO: is this good? (from jabber branch) |
---|
472 | #rootMessage.set_charset(self._charset) |
---|
473 | |
---|
474 | headers = dict() |
---|
475 | headers['Message-ID'] = self._message_id(event.realm) |
---|
476 | headers['Date'] = formatdate() |
---|
477 | from_header = formataddr(( |
---|
478 | self.from_name or self.env.project_name, |
---|
479 | self.email_from |
---|
480 | )) |
---|
481 | headers['From'] = from_header |
---|
482 | headers['To'] = '"%s"'%(self.to) |
---|
483 | if self.use_public_cc: |
---|
484 | headers['Cc'] = ', '.join([x[2] for x in recipients if x]) |
---|
485 | headers['Reply-To'] = self.replyto |
---|
486 | for k, v in headers.iteritems(): |
---|
487 | set_header(rootMessage, k, v) |
---|
488 | |
---|
489 | rootMessage.preamble = 'This is a multi-part message in MIME format.' |
---|
490 | if alternate_output: |
---|
491 | parentMessage = MIMEMultipart('alternative') |
---|
492 | rootMessage.attach(parentMessage) |
---|
493 | |
---|
494 | alt_msg_format = 'html' in alternate_style and 'html' or 'plain' |
---|
495 | msgText = MIMEText(alternate_output, alt_msg_format) |
---|
496 | parentMessage.attach(msgText) |
---|
497 | else: |
---|
498 | parentMessage = rootMessage |
---|
499 | |
---|
500 | msg_format = 'html' in format and 'html' or 'plain' |
---|
501 | msgText = MIMEText(output, msg_format) |
---|
502 | del msgText['Content-Transfer-Encoding'] |
---|
503 | msgText.set_charset(self._charset) |
---|
504 | parentMessage.attach(msgText) |
---|
505 | decorators = self._get_decorators() |
---|
506 | if len(decorators) > 0: |
---|
507 | decorator = decorators.pop() |
---|
508 | decorator.decorate_message(event, rootMessage, decorators) |
---|
509 | |
---|
510 | recip_adds = [x[2] for x in recipients if x] |
---|
511 | # Append any to, cc or bccs added to the recipient list |
---|
512 | for field in ('To', 'Cc', 'Bcc'): |
---|
513 | if rootMessage[field] and \ |
---|
514 | len(str(rootMessage[field]).split(',')) > 0: |
---|
515 | for addy in str(rootMessage[field]).split(','): |
---|
516 | self._add_recipient(recip_adds, addy) |
---|
517 | # replace with localized bcc hint |
---|
518 | if headers['To'] == 'undisclosed-recipients: ;': |
---|
519 | set_header(rootMessage, 'To', _('undisclosed-recipients: ;')) |
---|
520 | |
---|
521 | self.log.debug("Content of recip_adds: %s" %(recip_adds)) |
---|
522 | package = (from_header, recip_adds, rootMessage.as_string()) |
---|
523 | start = time.time() |
---|
524 | if self.use_threaded_delivery: |
---|
525 | self.get_delivery_queue().put(package) |
---|
526 | else: |
---|
527 | self.send(*package) |
---|
528 | stop = time.time() |
---|
529 | self.log.debug("EmailDistributor took %s seconds to send."\ |
---|
530 | %(round(stop-start,2))) |
---|
531 | |
---|
532 | def send(self, from_addr, recipients, message): |
---|
533 | """Send message to recipients via e-mail.""" |
---|
534 | # Ensure the message complies with RFC2822: use CRLF line endings |
---|
535 | message = CRLF.join(re.split("\r?\n", message)) |
---|
536 | self.email_sender.send(from_addr, recipients, message) |
---|
537 | |
---|
538 | def _get_decorators(self): |
---|
539 | return self.decorators[:] |
---|
540 | |
---|
541 | def _add_recipient(self, recipients, addy): |
---|
542 | if addy.strip() != '"undisclosed-recipients: ;"': |
---|
543 | recipients.append(addy) |
---|
544 | |
---|
545 | |
---|
546 | |
---|
547 | class SmtpEmailSender(Component): |
---|
548 | """E-mail sender connecting to an SMTP server.""" |
---|
549 | |
---|
550 | implements(IEmailSender) |
---|
551 | |
---|
552 | server = Option('smtp', 'server', 'localhost', |
---|
553 | """SMTP server hostname to use for email notifications.""") |
---|
554 | |
---|
555 | timeout = IntOption('smtp', 'timeout', 10, |
---|
556 | """SMTP server connection timeout. (requires python-2.6)""") |
---|
557 | |
---|
558 | port = IntOption('smtp', 'port', 25, |
---|
559 | """SMTP server port to use for email notification.""") |
---|
560 | |
---|
561 | user = Option('smtp', 'user', '', |
---|
562 | """Username for SMTP server.""") |
---|
563 | |
---|
564 | password = Option('smtp', 'password', '', |
---|
565 | """Password for SMTP server.""") |
---|
566 | |
---|
567 | use_tls = BoolOption('smtp', 'use_tls', 'false', |
---|
568 | """Use SSL/TLS to send notifications over SMTP.""") |
---|
569 | |
---|
570 | use_ssl = BoolOption('smtp', 'use_ssl', 'false', |
---|
571 | """Use ssl for smtp connection.""") |
---|
572 | |
---|
573 | debuglevel = IntOption('smtp', 'debuglevel', 0, |
---|
574 | """Set to 1 for useful smtp debugging on stdout.""") |
---|
575 | |
---|
576 | |
---|
577 | def send(self, from_addr, recipients, message): |
---|
578 | # use defaults to make sure connect() is called in the constructor |
---|
579 | smtpclass = smtplib.SMTP |
---|
580 | if self.use_ssl: |
---|
581 | smtpclass = smtplib.SMTP_SSL |
---|
582 | |
---|
583 | args = { |
---|
584 | 'host': self.server, |
---|
585 | 'port': self.port |
---|
586 | } |
---|
587 | # timeout isn't supported until python 2.6 |
---|
588 | vparts = sys.version_info[0:2] |
---|
589 | if vparts[0] >= 2 and vparts[1] >= 6: |
---|
590 | args['timeout'] = self.timeout |
---|
591 | |
---|
592 | smtp = smtpclass(**args) |
---|
593 | smtp.set_debuglevel(self.debuglevel) |
---|
594 | if self.use_tls: |
---|
595 | smtp.ehlo() |
---|
596 | if not smtp.esmtp_features.has_key('starttls'): |
---|
597 | raise TracError(_("TLS enabled but server does not support " \ |
---|
598 | "TLS")) |
---|
599 | smtp.starttls() |
---|
600 | smtp.ehlo() |
---|
601 | if self.user: |
---|
602 | smtp.login( |
---|
603 | self.user.encode('utf-8'), |
---|
604 | self.password.encode('utf-8') |
---|
605 | ) |
---|
606 | smtp.sendmail(from_addr, recipients, message) |
---|
607 | if self.use_tls or self.use_ssl: |
---|
608 | # avoid false failure detection when the server closes |
---|
609 | # the SMTP connection with TLS/SSL enabled |
---|
610 | import socket |
---|
611 | try: |
---|
612 | smtp.quit() |
---|
613 | except socket.sslerror: |
---|
614 | pass |
---|
615 | else: |
---|
616 | smtp.quit() |
---|
617 | |
---|
618 | |
---|
619 | class SendmailEmailSender(Component): |
---|
620 | """E-mail sender using a locally-installed sendmail program.""" |
---|
621 | |
---|
622 | implements(IEmailSender) |
---|
623 | |
---|
624 | sendmail_path = Option('sendmail', 'sendmail_path', 'sendmail', |
---|
625 | """Path to the sendmail executable. |
---|
626 | |
---|
627 | The sendmail program must accept the `-i` and `-f` options. |
---|
628 | """) |
---|
629 | |
---|
630 | def send(self, from_addr, recipients, message): |
---|
631 | self.log.info("Sending notification through sendmail at %s to %s" |
---|
632 | % (self.sendmail_path, recipients)) |
---|
633 | cmdline = [self.sendmail_path, "-i", "-f", from_addr] |
---|
634 | cmdline.extend(recipients) |
---|
635 | self.log.debug("Sendmail command line: %s" % ' '.join(cmdline)) |
---|
636 | try: |
---|
637 | child = Popen(cmdline, bufsize=-1, stdin=PIPE, stdout=PIPE, |
---|
638 | stderr=PIPE) |
---|
639 | (out, err) = child.communicate(message) |
---|
640 | if child.returncode or err: |
---|
641 | raise Exception("Sendmail failed with (%s, %s), command: '%s'" |
---|
642 | % (child.returncode, err.strip(), cmdline)) |
---|
643 | except OSError, e: |
---|
644 | self.log.error("Failed to run sendmail[%s] with error %s"%\ |
---|
645 | (self.sendmail_path, e)) |
---|
646 | |
---|
647 | |
---|
648 | class DeliveryThread(threading.Thread): |
---|
649 | def __init__(self, queue, sender): |
---|
650 | threading.Thread.__init__(self) |
---|
651 | self._sender = sender |
---|
652 | self._queue = queue |
---|
653 | self.setDaemon(True) |
---|
654 | |
---|
655 | def run(self): |
---|
656 | while 1: |
---|
657 | sendfrom, recipients, message = self._queue.get() |
---|
658 | self._sender(sendfrom, recipients, message) |
---|
659 | |
---|