1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 import hashlib
20 import os
21 import re
22 import smtplib
23 from email.charset import BASE64, QP, SHORTEST, Charset
24 from email.header import Header
25 from email.mime.multipart import MIMEMultipart
26 from email.mime.text import MIMEText
27 from email.utils import formatdate, parseaddr, getaddresses
28 from subprocess import Popen, PIPE
29
30 from genshi.builder import tag
31
32 from trac.config import (BoolOption, ConfigurationError, IntOption, Option,
33 OrderedExtensionsOption)
34 from trac.core import Component, ExtensionPoint, TracError, implements
35 from trac.notification.api import (
36 get_target_id, IEmailAddressResolver, IEmailDecorator, IEmailSender,
37 INotificationDistributor, INotificationFormatter, INotificationSubscriber,
38 NotificationSystem)
39 from trac.util import lazy
40 from trac.util.compat import close_fds
41 from trac.util.datefmt import time_now, to_utimestamp
42 from trac.util.text import CRLF, exception_to_unicode, fix_eol, to_unicode
43 from trac.util.translation import _, tag_
44 from trac.web.session import get_session_attribute
45
46
47 __all__ = ['AlwaysEmailSubscriber', 'EMAIL_LOOKALIKE_PATTERN',
48 'EmailDistributor', 'FromAuthorEmailDecorator', 'MAXHEADERLEN',
49 'RecipientMatcher', 'SendmailEmailSender', 'SessionEmailResolver',
50 'SmtpEmailSender', 'create_charset', 'create_header',
51 'create_message_id', 'create_mime_multipart', 'create_mime_text',
52 'get_message_addresses', 'get_from_author', 'set_header']
53
54
55 MAXHEADERLEN = 76
56 EMAIL_LOOKALIKE_PATTERN = (
57
58 r"[a-zA-Z0-9.'+_-]+" '@'
59
60 '(?:[a-zA-Z0-9_-]+\.)+'
61 '[a-zA-Z](?:[-a-zA-Z\d]*[a-zA-Z\d])?'
62 )
63
64 _mime_encoding_re = re.compile(r'=\?[^?]+\?[bq]\?[^?]+\?=', re.IGNORECASE)
65
66 local_hostname = None
70 """Create an appropriate email charset for the given encoding.
71
72 Valid options are 'base64' for Base64 encoding, 'qp' for
73 Quoted-Printable, and 'none' for no encoding, in which case mails
74 will be sent as 7bit if the content is all ASCII, or 8bit otherwise.
75 """
76 charset = Charset()
77 charset.input_charset = 'utf-8'
78 charset.output_charset = 'utf-8'
79 charset.input_codec = 'utf-8'
80 charset.output_codec = 'utf-8'
81 pref = mime_encoding.lower()
82 if pref == 'base64':
83 charset.header_encoding = BASE64
84 charset.body_encoding = BASE64
85 elif pref in ('qp', 'quoted-printable'):
86 charset.header_encoding = QP
87 charset.body_encoding = QP
88 elif pref == 'none':
89 charset.header_encoding = SHORTEST
90 charset.body_encoding = None
91 else:
92 raise TracError(_("Invalid email encoding setting: %(mime_encoding)s",
93 mime_encoding=mime_encoding))
94 return charset
95
98 """Create an email Header.
99
100 The `key` is always a string and will be converted to the
101 appropriate `charset`. The `value` can either be a string or a
102 two-element tuple where the first item is the name and the
103 second item is the email address.
104
105 See `set_header()` for a helper that sets a header directly on a
106 message.
107 """
108 maxlength = MAXHEADERLEN-(len(key)+2)
109
110 if maxlength < 10:
111 raise TracError(_("Header length is too short"))
112
113 email = None
114 if isinstance(value, (tuple, list)):
115 value, email = value
116 if not isinstance(value, basestring):
117 value = to_unicode(value)
118 if not value:
119 return email
120
121
122
123 header = None
124 if not _mime_encoding_re.search(value):
125 try:
126 tmp = value.encode('ascii')
127 except UnicodeEncodeError:
128 pass
129 else:
130 header = Header(tmp, 'ascii', maxlinelen=maxlength)
131 if not header:
132 header = Header(value.encode(charset.output_codec), charset,
133 maxlinelen=maxlength)
134 header = str(header)
135 if email:
136 header = header.replace('\\', r'\\').replace('"', r'\"')
137 header = '"%s" <%s>' % (header, email)
138 return header
139
142 """Create and add or replace a header in a `MIMEMultipart`.
143
144 The `key` is always a string and will be converted to the
145 appropriate `charset`. The `value` can either be a string or a
146 two-element tuple where the first item is the name and the
147 second item is the email address.
148
149 The `charset` should be created using `create_charset()`
150
151 Example::
152
153 set_header(my_message, 'From', ('Trac', '[email protected]'),
154 my_charset)
155 """
156 header = create_header(key, value, charset)
157 if key in message:
158 message.replace_header(key, header)
159 else:
160 message[key] = header
161
164 """Create an email `MIMEMultipart`.
165
166 The `subtype` is a string that describes the type of multipart
167 message you are defining. You should pick one that is defined
168 by the email standards. The function does not check if the `subtype`
169 is valid.
170
171 The most common examples are:
172
173 * `related` infers that each part is in an integrated whole, like
174 images that are embedded in a html part.
175 * `alternative` infers that the message contains different formats
176 and the client can choose which to display based on capabilities
177 and user preferences, such as a text/html with an alternative
178 text/plain.
179
180 The `MIMEMultipart` is defined in the `email.mime.multipart` module in the
181 Python standard library.
182 """
183 msg = MIMEMultipart(subtype)
184 del msg['Content-Transfer-Encoding']
185 return msg
186
187
188 -def create_mime_text(body, format, charset):
189 """Create a `MIMEText` that can be added to an email message.
190
191 :param body: a string with the body of the message.
192 :param format: each text has a MIMEType, like `text/plain`. The
193 supertype is always `text`, so in the `format` parameter you
194 pass the subtype, like `plain` or `html`.
195 :param charset: should be created using `create_charset()`.
196 """
197 if isinstance(body, unicode):
198 body = body.encode('utf-8')
199 msg = MIMEText(body, format)
200
201
202
203 del msg['Content-Transfer-Encoding']
204 msg.set_charset(charset)
205 return msg
206
209 """Generate a predictable, but sufficiently unique message ID.
210
211 In case you want to set the "Message ID" header, this convenience
212 function will generate one by running a hash algorithm over a number
213 of properties.
214
215 :param env: the `Environment`
216 :param targetid: a string that identifies the target, like
217 `NotificationEvent.target`
218 :param from_email: the email address that the message is sent from
219 :param time: a Python `datetime`
220 :param more: a string that contains additional information that
221 makes this message unique
222 """
223 items = [env.project_url.encode('utf-8'), targetid, to_utimestamp(time)]
224 if more is not None:
225 items.append(more.encode('ascii', 'ignore'))
226 source = '.'.join(str(item) for item in items)
227 hash_type = NotificationSystem(env).message_id_hash
228 try:
229 h = hashlib.new(hash_type)
230 except:
231 raise ConfigurationError(_("Unknown hash type '%(type)s'",
232 type=hash_type))
233 h.update(source)
234 host = from_email[from_email.find('@') + 1:]
235 return '<%03d.%s@%s>' % (len(source), h.hexdigest(), host)
236
239 return getaddresses(str(header) for header in message.get_all(name, ()))
240
243 """Get the author name and email from a given `event`.
244
245 The `event` parameter should be of the type `NotificationEvent`.
246 If you only have the username of a Trac user, you should instead
247 use the `RecipientMatcher` to find the user's details.
248
249 The method returns a tuple that contains the name and email address
250 of the user. For example: `('developer', '[email protected]')`.
251 This tuple can be parsed by `set_header()`.
252 """
253 if event.author and NotificationSystem(env).smtp_from_author:
254 matcher = RecipientMatcher(env)
255 from_ = matcher.match_from_author(event.author)
256 if from_:
257 return from_
258
261 """Matches user names and email addresses.
262
263 :param env: The `trac.env.Enviroment`
264 """
265 nodomaddr_re = re.compile(r"^[-A-Za-z0-9!*+/=_.]+$")
266
282
283 @lazy
286
287 @lazy
289 return self.notify_sys.smtp_default_domain
290
291 @lazy
294
296 """Check if an email address is valid.
297
298 This method checks against the list of domains that are
299 to be ignored, which is controlled by the `ignore_domains_list`
300 configuration option.
301
302 :param address: the address to validate
303 :return: `True` if it is a valid email address that is not in
304 the ignore list.
305 """
306 if not address:
307 return False
308 match = self.shortaddr_re.match(address)
309 if match:
310 domain = address[address.find('@') + 1:].lower()
311 if domain not in self.ignore_domains:
312 return True
313 return False
314
316 """Convenience function to check for an email address
317
318 The parameter `address` can either be a valid user name,
319 or an email address. The method first checks if the parameter
320 is a valid user name. If so, it will look up the address. If
321 there is no match, the function will check if it is a valid
322 email address.
323
324 :return: A tuple with a session id, a `1` or `0` to indicate
325 whether the user is authenticated, and the matched address.
326 Returns `None` when `address` does not match a valid user,
327 nor a valid email address. When `address` is an email address,
328 the sid will be `None` and the authentication parameter
329 will always be `0`
330 """
331 if not address or address == 'anonymous':
332 return None
333
334 if address in self.users:
335 sid = address
336 auth = 1
337 address = (self.users[address][1] or '').strip() or sid
338 else:
339 sid = None
340 auth = 0
341 address = address.strip()
342
343 if self.nodomaddr_re.match(address):
344 if self.use_short_addr:
345 return sid, auth, address
346 if self.smtp_default_domain:
347 address = "%s@%s" % (address, self.smtp_default_domain)
348 return sid, auth, address
349 self.env.log.debug("Email address w/o domain: %s", address)
350 return None
351
352 mo = self.shortaddr_re.match(address)
353 if mo:
354 address = mo.group(1)
355 else:
356 mo = self.longaddr_re.match(address)
357 if mo:
358 address = mo.group(2)
359 if not self.is_email(address):
360 self.env.log.debug("Invalid email address: %s", address)
361 return None
362 return sid, auth, address
363
365 """Find a name and email address for a specific user
366
367 :param author: The username that you want to query.
368 :return: On success, a two-item tuple is returned, with the
369 real name and the email address of the user.
370 """
371 if author:
372 author = author.strip()
373 recipient = self.match_recipient(author)
374 if not recipient:
375 return None
376 sid, authenticated, address = recipient
377 if not address:
378 return None
379 from_name = None
380 if sid and authenticated and sid in self.users:
381 from_name = self.users[sid][0]
382 if not from_name:
383 mo = self.longaddr_re.match(author)
384 if mo:
385 from_name = mo.group(1)
386 return (from_name, address) if from_name else address
387
390 """Distributes notification events as emails."""
391
392 implements(INotificationDistributor)
393
394 formatters = ExtensionPoint(INotificationFormatter)
395 decorators = ExtensionPoint(IEmailDecorator)
396
397 resolvers = OrderedExtensionsOption('notification',
398 'email_address_resolvers', IEmailAddressResolver,
399 'SessionEmailResolver',
400 include_missing=False,
401 doc="""Comma separated list of email resolver components in the order
402 they will be called. If an email address is resolved, the remaining
403 resolvers will not be called.
404 """)
405
406 default_format = Option('notification', 'default_format.email',
407 'text/plain', doc="Default format to distribute email notifications.")
408
412
413
414
417
418 - def distribute(self, transport, recipients, event):
419 if transport != 'email':
420 return
421 if not self.config.getbool('notification', 'smtp_enabled'):
422 self.log.debug("%s skipped because smtp_enabled set to false",
423 self.__class__.__name__)
424 return
425
426 formats = {}
427 for f in self.formatters:
428 for style, realm in f.get_supported_styles(transport):
429 if realm == event.realm:
430 formats[style] = f
431 if not formats:
432 self.log.error("%s No formats found for %s %s",
433 self.__class__.__name__, transport, event.realm)
434 return
435 self.log.debug("%s has found the following formats capable of "
436 "handling '%s' of '%s': %s", self.__class__.__name__,
437 transport, event.realm, ', '.join(formats.keys()))
438
439 matcher = RecipientMatcher(self.env)
440 notify_sys = NotificationSystem(self.env)
441 always_cc = set(notify_sys.smtp_always_cc_list)
442 addresses = {}
443 for sid, auth, addr, fmt in recipients:
444 if fmt not in formats:
445 self.log.debug("%s format %s not available for %s %s",
446 self.__class__.__name__, fmt, transport,
447 event.realm)
448 continue
449
450 if sid and not addr:
451 for resolver in self.resolvers:
452 addr = resolver.get_address_for_session(sid, auth) or None
453 if addr:
454 self.log.debug(
455 "%s found the address '%s' for '%s [%s]' via %s",
456 self.__class__.__name__, addr, sid, auth,
457 resolver.__class__.__name__)
458 break
459 if sid and auth and not addr:
460 addr = sid
461 if notify_sys.smtp_default_domain and \
462 not notify_sys.use_short_addr and \
463 addr and matcher.nodomaddr_re.match(addr):
464 addr = '%s@%s' % (addr, notify_sys.smtp_default_domain)
465 if not addr:
466 self.log.debug("%s was unable to find an address for "
467 "'%s [%s]'", self.__class__.__name__, sid, auth)
468 elif matcher.is_email(addr) or \
469 notify_sys.use_short_addr and \
470 matcher.nodomaddr_re.match(addr):
471 addresses.setdefault(fmt, set()).add(addr)
472 if sid and auth and sid in always_cc:
473 always_cc.discard(sid)
474 always_cc.add(addr)
475 elif notify_sys.use_public_cc:
476 always_cc.add(addr)
477 else:
478 self.log.debug("%s was unable to use an address '%s' for '%s "
479 "[%s]'", self.__class__.__name__, addr, sid,
480 auth)
481
482 outputs = {}
483 failed = []
484 for fmt, formatter in formats.iteritems():
485 if fmt not in addresses and fmt != 'text/plain':
486 continue
487 try:
488 outputs[fmt] = formatter.format(transport, fmt, event)
489 except Exception as e:
490 self.log.warn('%s caught exception while '
491 'formatting %s to %s for %s: %s%s',
492 self.__class__.__name__, event.realm, fmt,
493 transport, formatter.__class__,
494 exception_to_unicode(e, traceback=True))
495 failed.append(fmt)
496
497
498 if failed and 'text/plain' in outputs:
499 for fmt in failed:
500 addresses.setdefault('text/plain', set()) \
501 .update(addresses.pop(fmt, ()))
502
503 for fmt, addrs in addresses.iteritems():
504 self.log.debug("%s is sending event as '%s' to: %s",
505 self.__class__.__name__, fmt, ', '.join(addrs))
506 message = self._create_message(fmt, outputs)
507 if message:
508 addrs = set(addrs)
509 cc_addrs = sorted(addrs & always_cc)
510 bcc_addrs = sorted(addrs - always_cc)
511 self._do_send(transport, event, message, cc_addrs, bcc_addrs)
512 else:
513 self.log.warn("%s cannot send event '%s' as '%s': %s",
514 self.__class__.__name__, event.realm, fmt,
515 ', '.join(addrs))
516
531
532 - def _do_send(self, transport, event, message, cc_addrs, bcc_addrs):
533 notify_sys = NotificationSystem(self.env)
534 smtp_from = notify_sys.smtp_from
535 smtp_from_name = notify_sys.smtp_from_name or self.env.project_name
536 smtp_replyto = notify_sys.smtp_replyto
537 if not notify_sys.use_short_addr and notify_sys.smtp_default_domain:
538 if smtp_from and '@' not in smtp_from:
539 smtp_from = '%s@%s' % (smtp_from,
540 notify_sys.smtp_default_domain)
541 if smtp_replyto and '@' not in smtp_replyto:
542 smtp_replyto = '%s@%s' % (smtp_replyto,
543 notify_sys.smtp_default_domain)
544
545 headers = {}
546 headers['X-Mailer'] = 'Trac %s, by Edgewall Software'\
547 % self.env.trac_version
548 headers['X-Trac-Version'] = self.env.trac_version
549 headers['X-Trac-Project'] = self.env.project_name
550 headers['X-URL'] = self.env.project_url
551 headers['X-Trac-Realm'] = event.realm
552 headers['Precedence'] = 'bulk'
553 headers['Auto-Submitted'] = 'auto-generated'
554 if isinstance(event.target, (list, tuple)):
555 targetid = ','.join(map(get_target_id, event.target))
556 else:
557 targetid = get_target_id(event.target)
558 rootid = create_message_id(self.env, targetid, smtp_from, None,
559 more=event.realm)
560 if event.category == 'created':
561 headers['Message-ID'] = rootid
562 else:
563 headers['Message-ID'] = create_message_id(self.env, targetid,
564 smtp_from, event.time,
565 more=event.realm)
566 headers['In-Reply-To'] = rootid
567 headers['References'] = rootid
568 headers['Date'] = formatdate()
569 headers['From'] = (smtp_from_name, smtp_from) \
570 if smtp_from_name else smtp_from
571 headers['To'] = 'undisclosed-recipients: ;'
572 if cc_addrs:
573 headers['Cc'] = ', '.join(cc_addrs)
574 if bcc_addrs:
575 headers['Bcc'] = ', '.join(bcc_addrs)
576 headers['Reply-To'] = smtp_replyto
577
578 for k, v in headers.iteritems():
579 set_header(message, k, v, self._charset)
580 for decorator in self.decorators:
581 decorator.decorate_message(event, message, self._charset)
582
583 from_name, from_addr = parseaddr(str(message['From']))
584 to_addrs = set()
585 for name in ('To', 'Cc', 'Bcc'):
586 values = map(str, message.get_all(name, ()))
587 to_addrs.update(addr for name, addr in getaddresses(values)
588 if addr)
589 del message['Bcc']
590 notify_sys.send_email(from_addr, list(to_addrs), message.as_string())
591
594 """E-mail sender connecting to an SMTP server."""
595
596 implements(IEmailSender)
597
598 smtp_server = Option('notification', 'smtp_server', 'localhost',
599 """SMTP server hostname to use for email notifications.""")
600
601 smtp_port = IntOption('notification', 'smtp_port', 25,
602 """SMTP server port to use for email notification.""")
603
604 smtp_user = Option('notification', 'smtp_user', '',
605 """Username for authenticating with SMTP server.""")
606
607 smtp_password = Option('notification', 'smtp_password', '',
608 """Password for authenticating with SMTP server.""")
609
610 use_tls = BoolOption('notification', 'use_tls', 'false',
611 """Use SSL/TLS to send notifications over SMTP.""")
612
613 - def send(self, from_addr, recipients, message):
614 global local_hostname
615
616 message = fix_eol(message, CRLF)
617
618 self.log.info("Sending notification through SMTP at %s:%d to %s",
619 self.smtp_server, self.smtp_port, recipients)
620 try:
621 server = smtplib.SMTP(self.smtp_server, self.smtp_port,
622 local_hostname)
623 local_hostname = server.local_hostname
624 except smtplib.socket.error as e:
625 raise ConfigurationError(
626 tag_("SMTP server connection error (%(error)s). Please "
627 "modify %(option1)s or %(option2)s in your "
628 "configuration.",
629 error=to_unicode(e),
630 option1=tag.code("[notification] smtp_server"),
631 option2=tag.code("[notification] smtp_port")))
632
633 if self.use_tls:
634 server.ehlo()
635 if 'starttls' not in server.esmtp_features:
636 raise TracError(_("TLS enabled but server does not support"
637 " TLS"))
638 server.starttls()
639 server.ehlo()
640 if self.smtp_user:
641 server.login(self.smtp_user.encode('utf-8'),
642 self.smtp_password.encode('utf-8'))
643 start = time_now()
644 server.sendmail(from_addr, recipients, message)
645 t = time_now() - start
646 if t > 5:
647 self.log.warning("Slow mail submission (%.2f s), "
648 "check your mail setup", t)
649 if self.use_tls:
650
651
652 import socket
653 try:
654 server.quit()
655 except socket.sslerror:
656 pass
657 else:
658 server.quit()
659
662 """E-mail sender using a locally-installed sendmail program."""
663
664 implements(IEmailSender)
665
666 sendmail_path = Option('notification', 'sendmail_path', 'sendmail',
667 """Path to the sendmail executable.
668
669 The sendmail program must accept the `-i` and `-f` options.
670 (''since 0.12'')""")
671
672 - def send(self, from_addr, recipients, message):
673
674 message = fix_eol(message, os.linesep)
675
676 self.log.info("Sending notification through sendmail at %s to %s",
677 self.sendmail_path, recipients)
678 cmdline = [self.sendmail_path, '-i', '-f', from_addr] + recipients
679 self.log.debug("Sendmail command line: %s", cmdline)
680 try:
681 child = Popen(cmdline, bufsize=-1, stdin=PIPE, stdout=PIPE,
682 stderr=PIPE, close_fds=close_fds)
683 except OSError as e:
684 raise ConfigurationError(
685 tag_("Sendmail error (%(error)s). Please modify %(option)s "
686 "in your configuration.",
687 error=to_unicode(e),
688 option=tag.code("[notification] sendmail_path")))
689 out, err = child.communicate(message)
690 if child.returncode or err:
691 raise Exception("Sendmail failed with (%s, %s), command: '%s'"
692 % (child.returncode, err.strip(), cmdline))
693
702
705 """Implement a policy to -always- send an email to a certain address.
706
707 Controlled via the smtp_always_cc and smtp_always_bcc option in the
708 notification section of trac.ini.
709 """
710
711 implements(INotificationSubscriber)
712
714 matcher = RecipientMatcher(self.env)
715 klass = self.__class__.__name__
716 format = None
717 priority = 0
718 for address in self._get_address_list():
719 recipient = matcher.match_recipient(address)
720 if recipient:
721 sid, authenticated, address = recipient
722 yield (klass, 'email', sid, authenticated, address, format,
723 priority, 'always')
724
727
730
733
735 section = self.config['notification']
736 def getlist(name):
737 return section.getlist(name, sep=(',', ' '), keep_empty=False)
738 return set(getlist('smtp_always_cc')) | \
739 set(getlist('smtp_always_bcc'))
740
743 """Implement a policy to use the author of the event as the sender in
744 notification emails.
745
746 Controlled via the smtp_from_author option in the notification section
747 of trac.ini.
748 """
749
750 implements(IEmailDecorator)
751
756