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