1# -*- coding: utf-8 -*- 2# 3# Copyright (C) 2003-2021 Edgewall Software 4# Copyright (C) 2003-2005 Daniel Lundin <daniel@edgewall.com> 5# Copyright (C) 2005-2006 Emmanuel Blot <emmanuel.blot@free.fr> 6# Copyright (C) 2008 Stephen Hansen 7# Copyright (C) 2009 Robert Corsaro 8# Copyright (C) 2010-2012 Steffen Hoffmann 9# All rights reserved. 10# 11# This software is licensed as described in the file COPYING, which 12# you should have received as part of this distribution. The terms 13# are also available at https://trac.edgewall.org/wiki/TracLicense. 14# 15# This software consists of voluntary contributions made by many 16# individuals. For the exact contribution history, see the revision 17# history and logs, available at https://trac.edgewall.org/log/. 18 19from collections import defaultdict 20 21from trac.config import (BoolOption, ConfigSection, ExtensionOption, 22 ListOption, Option) 23from trac.core import Component, Interface, ExtensionPoint 24from trac.util import as_bool, lazy, to_list 25 26 27__all__ = ['IEmailAddressResolver', 'IEmailDecorator', 'IEmailSender', 28 'INotificationDistributor', 'INotificationFormatter', 29 'INotificationSubscriber', 'NotificationEvent', 30 'NotificationSystem', 'get_target_id', 'parse_subscriber_config'] 31 32 33class INotificationDistributor(Interface): 34 """Deliver events over some transport (i.e. messaging protocol).""" 35 36 def transports(): 37 """Return a list of supported transport names.""" 38 39 def distribute(transport, recipients, event): 40 """Distribute the notification event. 41 42 :param transport: the name of a supported transport 43 :param recipients: a list of (sid, authenticated, address, format) 44 tuples, where either `sid` or `address` can be 45 `None` 46 :param event: a `NotificationEvent` 47 """ 48 49 50class INotificationFormatter(Interface): 51 """Convert events into messages appropriate for a given transport.""" 52 53 def get_supported_styles(transport): 54 """Return a list of supported styles. 55 56 :param transport: the name of a transport 57 :return: a list of tuples (style, realm) 58 """ 59 60 def format(transport, style, event): 61 """Convert the event to an appropriate message. 62 63 :param transport: the name of a transport 64 :param style: the name of a supported style 65 :return: The return type of this method depends on transport and must 66 be compatible with the `INotificationDistributor` that 67 handles messages for this transport. 68 """ 69 70 71class INotificationSubscriber(Interface): 72 """Subscribe to notification events.""" 73 74 def matches(event): 75 """Return a list of subscriptions that match the given event. 76 77 :param event: a `NotificationEvent` 78 :return: a list of tuples (class, distributor, sid, authenticated, 79 address, format, priority, adverb), where small `priority` 80 values override larger ones and `adverb` is either 81 'always' or 'never'. 82 """ 83 84 def description(): 85 """Description of the subscription shown in the preferences UI.""" 86 87 def requires_authentication(): 88 """Can only authenticated users subscribe?""" 89 90 def default_subscriptions(): 91 """Optionally return a list of default subscriptions. 92 93 Default subscriptions that the module will automatically generate. 94 This should only be used in reasonable situations, where users can be 95 determined by the event itself. For instance, ticket author has a 96 default subscription that is controlled via trac.ini. This is because 97 we can lookup the ticket author during the event and create a 98 subscription for them. Default subscriptions should be low priority 99 so that the user can easily override them. 100 101 :return: a list of tuples (class, distributor, format, priority, 102 adverb) 103 """ 104 105 106class IEmailAddressResolver(Interface): 107 """Map sessions to email addresses.""" 108 109 def get_address_for_session(sid, authenticated): 110 """Map a session id and authenticated flag to an e-mail address. 111 112 :param sid: the session id 113 :param authenticated: 1 for authenticated sessions, 0 otherwise 114 :return: an email address or `None` 115 """ 116 117 118class IEmailDecorator(Interface): 119 def decorate_message(event, message, charset): 120 """Manipulate the message before it is sent on it's way. 121 122 :param event: a `NotificationEvent` 123 :param message: an `email.message.Message` to manipulate 124 :param charset: the `email.charset.Charset` to use 125 """ 126 127 128class IEmailSender(Interface): 129 """Extension point interface for components that allow sending e-mail.""" 130 131 def send(from_addr, recipients, message): 132 """Send message to recipients.""" 133 134 135def get_target_id(target): 136 """Extract the resource ID from event targets. 137 138 :param target: a resource model (e.g. `Ticket` or `WikiPage`) 139 :return: the resource ID 140 """ 141 # Common Trac resource. 142 if hasattr(target, 'id'): 143 return str(target.id) 144 # Wiki page special case. 145 elif hasattr(target, 'name'): 146 return target.name 147 # Last resort: just stringify. 148 return str(target) 149 150 151def parse_subscriber_config(rawsubscriptions): 152 """Given a list of options from [notification-subscriber]""" 153 154 required_attrs = { 155 'distributor': 'email', 156 'priority': 100, 157 'adverb': 'always', 158 'format': None, 159 } 160 optional_attrs = {} 161 known_attrs = required_attrs.copy() 162 known_attrs.update(optional_attrs) 163 164 byname = defaultdict(dict) 165 for option, value in rawsubscriptions: 166 parts = option.split('.', 1) 167 name = parts[0] 168 if len(parts) == 1: 169 byname[name].update({'name': name, 'class': value.strip()}) 170 else: 171 attribute = parts[1] 172 known = known_attrs.get(attribute) 173 if known is None or isinstance(known, str): 174 pass 175 elif isinstance(known, int): 176 value = int(value) 177 elif isinstance(known, bool): 178 value = as_bool(value) 179 elif isinstance(known, list): 180 value = to_list(value) 181 byname[name][attribute] = value 182 183 byclass = defaultdict(list) 184 for name, attributes in byname.items(): 185 for key, value in required_attrs.items(): 186 attributes.setdefault(key, value) 187 byclass[attributes['class']].append(attributes) 188 for values in byclass.values(): 189 values.sort(key=lambda value: (value['priority'], value['name'])) 190 191 return byclass 192 193 194class NotificationEvent(object): 195 """All data related to a particular notification event. 196 197 :param realm: the resource realm (e.g. 'ticket' or 'wiki') 198 :param category: the kind of event that happened to the resource 199 (e.g. 'created', 'changed' or 'deleted') 200 :param target: the resource model (e.g. Ticket or WikiPage) or `None` 201 :param time: the `datetime` when the event happened 202 """ 203 204 def __init__(self, realm, category, target, time, author=""): 205 self.realm = realm 206 self.category = category 207 self.target = target 208 self.time = time 209 self.author = author 210 211 212class NotificationSystem(Component): 213 214 email_sender = ExtensionOption('notification', 'email_sender', 215 IEmailSender, 'SmtpEmailSender', 216 """Name of the component implementing `IEmailSender`. 217 218 This component is used by the notification system to send emails. 219 Trac currently provides `SmtpEmailSender` for connecting to an SMTP 220 server, and `SendmailEmailSender` for running a `sendmail`-compatible 221 executable. 222 """) 223 224 smtp_enabled = BoolOption('notification', 'smtp_enabled', 'false', 225 """Enable email notification.""") 226 227 smtp_from = Option('notification', 'smtp_from', 'trac@localhost', 228 """Sender address to use in notification emails. 229 230 At least one of `smtp_from` and `smtp_replyto` must be set, otherwise 231 Trac refuses to send notification mails.""") 232 233 smtp_from_name = Option('notification', 'smtp_from_name', '', 234 """Sender name to use in notification emails.""") 235 236 smtp_from_author = BoolOption('notification', 'smtp_from_author', 'false', 237 """Use the author of the change as the sender in notification emails 238 (e.g. reporter of a new ticket, author of a comment). If the 239 author hasn't set an email address, `smtp_from` and 240 `smtp_from_name` are used instead. 241 (''since 1.0'')""") 242 243 smtp_replyto = Option('notification', 'smtp_replyto', 'trac@localhost', 244 """Reply-To address to use in notification emails. 245 246 At least one of `smtp_from` and `smtp_replyto` must be set, otherwise 247 Trac refuses to send notification mails.""") 248 249 smtp_always_cc_list = ListOption( 250 'notification', 'smtp_always_cc', '', sep=(',', ' '), 251 doc="""Comma-separated list of email addresses to always send 252 notifications to. Addresses can be seen by all recipients 253 (Cc:).""") 254 255 smtp_always_bcc_list = ListOption( 256 'notification', 'smtp_always_bcc', '', sep=(',', ' '), 257 doc="""Comma-separated list of email addresses to always send 258 notifications to. Addresses are not public (Bcc:). 259 """) 260 261 smtp_default_domain = Option('notification', 'smtp_default_domain', '', 262 """Default host/domain to append to addresses that do not specify 263 one. Fully qualified addresses are not modified. The default 264 domain is appended to all username/login for which an email 265 address cannot be found in the user settings.""") 266 267 ignore_domains_list = ListOption('notification', 'ignore_domains', '', 268 doc="""Comma-separated list of domains that should not be considered 269 part of email addresses (for usernames with Kerberos domains).""") 270 271 admit_domains_list = ListOption('notification', 'admit_domains', '', 272 doc="""Comma-separated list of domains that should be considered as 273 valid for email addresses (such as localdomain).""") 274 275 mime_encoding = Option('notification', 'mime_encoding', 'none', 276 """Specifies the MIME encoding scheme for emails. 277 278 Supported values are: `none`, the default value which uses 7-bit 279 encoding if the text is plain ASCII or 8-bit otherwise. `base64`, 280 which works with any kind of content but may cause some issues with 281 touchy anti-spam/anti-virus engine. `qp` or `quoted-printable`, 282 which works best for european languages (more compact than base64) if 283 8-bit encoding cannot be used. 284 """) 285 286 use_public_cc = BoolOption('notification', 'use_public_cc', 'false', 287 """Addresses in the To and Cc fields are visible to all recipients. 288 289 If this option is disabled, recipients are put in the Bcc list. 290 """) 291 292 use_short_addr = BoolOption('notification', 'use_short_addr', 'false', 293 """Permit email address without a host/domain (i.e. username only). 294 295 The SMTP server should accept those addresses, and either append 296 a FQDN or use local delivery. See also `smtp_default_domain`. Do not 297 use this option with a public SMTP server. 298 """) 299 300 smtp_subject_prefix = Option('notification', 'smtp_subject_prefix', 301 '__default__', 302 """Text to prepend to subject line of notification emails. 303 304 If the setting is not defined, then `[$project_name]` is used as the 305 prefix. If no prefix is desired, then specifying an empty option 306 will disable it. 307 """) 308 309 message_id_hash = Option('notification', 'message_id_hash', 'md5', 310 """Hash algorithm to create unique Message-ID header. 311 ''(since 1.0.13)''""") 312 313 notification_subscriber_section = ConfigSection('notification-subscriber', 314 """The notifications subscriptions are controlled by plugins. All 315 `INotificationSubscriber` components are in charge. These components 316 may allow to be configured via this section in the `trac.ini` file. 317 318 See TracNotification for more details. 319 320 Available subscribers: 321 [[SubscriberList]] 322 """) 323 324 distributors = ExtensionPoint(INotificationDistributor) 325 subscribers = ExtensionPoint(INotificationSubscriber) 326 327 @lazy 328 def subscriber_defaults(self): 329 rawsubscriptions = self.notification_subscriber_section.options() 330 return parse_subscriber_config(rawsubscriptions) 331 332 def default_subscriptions(self, klass): 333 for d in self.subscriber_defaults[klass]: 334 yield (klass, d['distributor'], d['format'], d['priority'], 335 d['adverb']) 336 337 def get_default_format(self, transport): 338 return self.config.get('notification', 339 'default_format.' + transport) or 'text/plain' 340 341 def get_preferred_format(self, sid, authenticated, transport): 342 from trac.notification.prefs import get_preferred_format 343 return get_preferred_format(self.env, sid, authenticated, 344 transport) or \ 345 self.get_default_format(transport) 346 347 def send_email(self, from_addr, recipients, message): 348 """Send message to recipients via e-mail.""" 349 self.email_sender.send(from_addr, recipients, message) 350 351 def notify(self, event): 352 """Distribute an event to all subscriptions. 353 354 :param event: a `NotificationEvent` 355 """ 356 self.distribute_event(event, self.subscriptions(event)) 357 358 def distribute_event(self, event, subscriptions): 359 """Distribute a event to all subscriptions. 360 361 :param event: a `NotificationEvent` 362 :param subscriptions: a list of tuples (sid, authenticated, address, 363 transport, format) where either sid or 364 address can be `None` 365 """ 366 packages = {} 367 for sid, authenticated, address, transport, format in subscriptions: 368 package = packages.setdefault(transport, {}) 369 key = (sid, authenticated, address) 370 if key in package: 371 continue 372 package[key] = format or self.get_preferred_format( 373 sid, authenticated, transport) 374 for distributor in self.distributors: 375 for transport in distributor.transports(): 376 if transport in packages: 377 recipients = [(k[0], k[1], k[2], format) 378 for k, format 379 in packages[transport].items()] 380 distributor.distribute(transport, recipients, event) 381 382 def subscriptions(self, event): 383 """Return all subscriptions for a given event. 384 385 :return: a list of (sid, authenticated, address, transport, format) 386 """ 387 subscriptions = [] 388 for subscriber in self.subscribers: 389 if event.category == 'batchmodify': 390 for ticket_event in event.get_ticket_change_events(self.env): 391 subscriptions.extend(x for x in subscriber.matches(ticket_event) if x) 392 else: 393 subscriptions.extend(x for x in subscriber.matches(event) if x) 394 395 # For each (transport, sid, authenticated) combination check the 396 # subscription with the highest priority: 397 # If it is "always" keep it. If it is "never" drop it. 398 399 # sort by (transport, sid, authenticated, priority) 400 ordered = sorted(subscriptions, 401 key=lambda v: (v[1], '' if v[2] is None else v[2], 402 v[3], v[6])) 403 previous_combination = None 404 for rule, transport, sid, auth, addr, fmt, prio, adverb in ordered: 405 if (transport, sid, auth) == previous_combination: 406 continue 407 if adverb == 'always': 408 self.log.debug("Adding (%s [%s]) for 'always' on rule (%s) " 409 "for (%s)", sid, auth, rule, transport) 410 yield (sid, auth, addr, transport, fmt) 411 else: 412 self.log.debug("Ignoring (%s [%s]) for 'never' on rule (%s) " 413 "for (%s)", sid, auth, rule, transport) 414 # Also keep subscriptions without sid (raw email subscription) 415 if sid: 416 previous_combination = (transport, sid, auth) 417