1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2005-2021 Edgewall Software
4# Copyright (C) 2005-2006 Emmanuel Blot <emmanuel.blot@free.fr>
5# All rights reserved.
6#
7# This software is licensed as described in the file COPYING, which
8# you should have received as part of this distribution. The terms
9# are also available at https://trac.edgewall.org/wiki/TracLicense.
10#
11# This software consists of voluntary contributions made by many
12# individuals. For the exact contribution history, see the revision
13# history and logs, available at https://trac.edgewall.org/log/.
14#
15# Include a basic SMTP server, based on L. Smithson
16# (lsmithson@open-networks.co.uk) extensible Python SMTP Server
17#
18
19import base64
20import io
21import quopri
22import re
23import unittest
24from datetime import datetime, timedelta
25
26from trac.attachment import Attachment
27from trac.notification.api import NotificationSystem
28from trac.perm import PermissionSystem
29from trac.test import EnvironmentStub, MockRequest, mkdtemp
30from trac.tests.notification import SMTP_TEST_PORT, SMTPThreadedServer, \
31                                    parse_smtp_message
32from trac.ticket.model import Ticket
33from trac.ticket.notification import (
34    BatchTicketChangeEvent, Subscription, TicketChangeEvent,
35    TicketNotificationSystem)
36from trac.ticket.test import insert_ticket
37from trac.ticket.web_ui import TicketModule
38from trac.util.datefmt import datetime_now, utc
39
40MAXBODYWIDTH = 76
41smtpd = None
42
43
44def setUpModule():
45    global smtpd
46    smtpd = SMTPThreadedServer(SMTP_TEST_PORT)
47    smtpd.start()
48
49
50def tearDownModule():
51    smtpd.stop()
52
53
54def notify_ticket_created(env, ticket):
55    smtpd.cleanup()
56    event = TicketChangeEvent('created', ticket, ticket['time'],
57                              ticket['reporter'])
58    NotificationSystem(env).notify(event)
59
60
61def notify_ticket_changed(env, ticket, author='anonymous'):
62    smtpd.cleanup()
63    event = TicketChangeEvent('changed', ticket, ticket['changetime'], author)
64    NotificationSystem(env).notify(event)
65
66
67def config_subscriber(env, updater=False, owner=False, reporter=False):
68    section = 'notification-subscriber'
69    env.config.set(section, 'always_notify_cc', 'CarbonCopySubscriber')
70    if updater:
71        env.config.set(section, 'always_notify_updater',
72                       'TicketUpdaterSubscriber')
73        env.config.set(section, 'always_notify_previous_updater',
74                       'TicketPreviousUpdatersSubscriber')
75    if owner:
76        env.config.set(section, 'always_notify_owner',
77                       'TicketOwnerSubscriber')
78    if reporter:
79        env.config.set(section, 'always_notify_reporter',
80                       'TicketReporterSubscriber')
81    del NotificationSystem(env).subscriber_defaults
82
83
84def config_smtp(env):
85    env.config.set('project', 'name', 'TracTest')
86    env.config.set('notification', 'smtp_enabled', 'true')
87    env.config.set('notification', 'smtp_port', str(SMTP_TEST_PORT))
88    env.config.set('notification', 'smtp_server', smtpd.host)
89    # Note: when specifying 'localhost', the connection may be attempted
90    #       for '::1' first, then only '127.0.0.1' after a 1s timeout
91
92
93def extract_recipients(header):
94    rcpts = [rcpt.strip() for rcpt in header.split(',')]
95    return [rcpt for rcpt in rcpts if rcpt]
96
97
98class RecipientTestCase(unittest.TestCase):
99    """Notification test cases for email recipients."""
100
101    def setUp(self):
102        self.env = EnvironmentStub(default_data=True)
103        config_smtp(self.env)
104
105    def tearDown(self):
106        smtpd.cleanup()
107        self.env.reset_db()
108
109    def _insert_ticket(self, **props):
110        return insert_ticket(self.env, **props)
111
112    def test_no_recipients(self):
113        """No recipient case"""
114        ticket = insert_ticket(self.env, reporter='anonymous', summary='Foo')
115        notify_ticket_created(self.env, ticket)
116        recipients = smtpd.get_recipients()
117        sender = smtpd.get_sender()
118        message = smtpd.get_message()
119        self.assertEqual([], recipients)
120        self.assertIsNone(sender)
121        self.assertIsNone(message)
122
123    def _test_smtp_always_cc(self, key, sep):
124        cc_list = ('joe.user@example.net', 'joe.bar@example.net')
125        self.env.config.set('notification', key, sep.join(cc_list))
126        ticket = self._insert_ticket(reporter='joe.bar@example.org',
127                                     owner='joe.user@example.net',
128                                     summary='New ticket recipients')
129
130        notify_ticket_created(self.env, ticket)
131        recipients = smtpd.get_recipients()
132
133        self.assertEqual(2, len(recipients))
134        for r in cc_list:
135            self.assertIn(r, recipients)
136
137    def test_smtp_always_cc_comma_separator(self):
138        self._test_smtp_always_cc('smtp_always_cc', ', ')
139
140    def test_smtp_always_cc_space_separator(self):
141        self._test_smtp_always_cc('smtp_always_cc', ' ')
142
143    def test_smtp_always_bcc_comma_separator(self):
144        self._test_smtp_always_cc('smtp_always_bcc', ', ')
145
146    def test_smtp_always_bcc_space_separator(self):
147        self._test_smtp_always_cc('smtp_always_bcc', ' ')
148
149    def test_cc_permission_group_new_ticket(self):
150        """Permission groups are resolved in CC for new ticket."""
151        config_subscriber(self.env)
152        ticket = self._insert_ticket(reporter='joe.bar@example.org',
153                                     owner='joe.user@example.net',
154                                     cc='group1, user1@example.net',
155                                     summary='CC permission group')
156        group1 = ('user2@example.net', 'user3@example.net')
157        perm = PermissionSystem(self.env)
158        for user in group1:
159            perm.grant_permission(user, 'group1')
160
161        notify_ticket_created(self.env, ticket)
162        recipients = smtpd.get_recipients()
163
164        self.assertEqual(3, len(recipients))
165        for user in group1 + ('user1@example.net',):
166            self.assertIn(user, recipients)
167
168    def test_cc_permission_group_changed_ticket(self):
169        """Permission groups are resolved in CC for ticket change."""
170        config_subscriber(self.env)
171        ticket = self._insert_ticket(reporter='joe.bar@example.org',
172                                     owner='joe.user@example.net',
173                                     cc='user1@example.net',
174                                     summary='CC permission group')
175        group1 = ('user2@example.net',)
176        perm = PermissionSystem(self.env)
177        for user in group1:
178            perm.grant_permission(user, 'group1')
179
180        ticket['cc'] += ', group1'
181        ticket.save_changes('joe.bar2@example.com', 'This is a change')
182        notify_ticket_changed(self.env, ticket)
183        recipients = smtpd.get_recipients()
184
185        self.assertEqual(2, len(recipients))
186        for user in group1 + ('user1@example.net',):
187            self.assertIn(user, recipients)
188
189    def test_new_ticket_recipients(self):
190        """Report and CC list should be in recipient list for new tickets."""
191        config_subscriber(self.env, updater=True)
192        always_cc = ('joe.user@example.net', 'joe.bar@example.net')
193        ticket_cc = ('joe.user@example.com', 'joe.bar@example.org')
194        self.env.config.set('notification', 'smtp_always_cc',
195                            ', '.join(always_cc))
196        ticket = insert_ticket(self.env, reporter='joe.bar@example.org',
197                               owner='joe.user@example.net',
198                               cc=' '.join(ticket_cc),
199                               summary='New ticket recipients')
200        notify_ticket_created(self.env, ticket)
201        recipients = smtpd.get_recipients()
202        for r in always_cc + ticket_cc + \
203                (ticket['owner'], ticket['reporter']):
204            self.assertIn(r, recipients)
205
206    def test_cc_only(self):
207        """Notification w/o explicit recipients but Cc: (#3101)"""
208        always_cc = ('joe.user@example.net', 'joe.bar@example.net')
209        self.env.config.set('notification', 'smtp_always_cc',
210                            ', '.join(always_cc))
211        ticket = insert_ticket(self.env, summary='Foo')
212        notify_ticket_created(self.env, ticket)
213        recipients = smtpd.get_recipients()
214        for r in always_cc:
215            self.assertIn(r, recipients)
216
217    def test_always_notify_updater(self):
218        """The `always_notify_updater` option."""
219        def _test_updater(enabled):
220            config_subscriber(self.env, updater=enabled)
221            ticket = insert_ticket(self.env, reporter='joe.user@example.org',
222                                   summary='This is a súmmäry')
223            now = datetime_now(utc)
224            ticket.save_changes('joe.bar2@example.com', 'This is a change',
225                                when=now)
226            notify_ticket_changed(self.env, ticket)
227            recipients = smtpd.get_recipients()
228            if enabled:
229                self.assertEqual(['joe.bar2@example.com'], recipients)
230            else:
231                self.assertEqual([], recipients)
232
233        # Validate with and without a default domain
234        for enable in False, True:
235            _test_updater(enable)
236
237    def test_always_notify_owner(self):
238        """The `always_notify_owner` option."""
239        def _test_reporter(enabled):
240            config_subscriber(self.env, owner=enabled)
241            ticket = insert_ticket(self.env, summary='Foo',
242                                   reporter='joe@example.org',
243                                   owner='jim@example.org')
244            now = datetime_now(utc)
245            ticket.save_changes('joe@example.org', 'this is my comment',
246                                when=now)
247            notify_ticket_changed(self.env, ticket)
248            recipients = smtpd.get_recipients()
249            if enabled:
250                self.assertEqual(['jim@example.org'], recipients)
251            else:
252                self.assertEqual([], recipients)
253
254        for enable in False, True:
255            _test_reporter(enable)
256
257    def test_always_notify_reporter(self):
258        """Notification to reporter w/ updater option disabled (#3780)"""
259        def _test_reporter(enabled):
260            config_subscriber(self.env, reporter=enabled)
261            ticket = insert_ticket(self.env, summary='Foo',
262                                   reporter='joe@example.org')
263            now = datetime_now(utc)
264            ticket.save_changes('joe@example.org', 'this is my comment',
265                                when=now)
266            notify_ticket_changed(self.env, ticket)
267            recipients = smtpd.get_recipients()
268            if enabled:
269                self.assertEqual(['joe@example.org'], recipients)
270            else:
271                self.assertEqual([], recipients)
272
273        for enable in False, True:
274            _test_reporter(enable)
275
276    def test_notify_new_tickets(self):
277        """Notification to NewTicketSubscribers."""
278        def _test_new_ticket():
279            ticket = Ticket(self.env)
280            ticket['reporter'] = 'user4'
281            ticket['owner'] = 'user5'
282            ticket['summary'] = 'New Ticket Subscribers'
283            ticket.insert()
284            notify_ticket_created(self.env, ticket)
285            return ticket
286
287        def _add_new_ticket_subscriber(sid, authenticated):
288            Subscription.add(self.env, {
289                'sid': sid, 'authenticated': authenticated,
290                'distributor': 'email', 'format': None, 'adverb': 'always',
291                'class': 'NewTicketSubscriber'
292            })
293
294        _test_new_ticket()
295        recipients = smtpd.get_recipients()
296        self.assertEqual([], recipients)
297
298        self.env.insert_users([
299            ('user1', 'User One', 'joe@example.org', 1),
300            ('user2', 'User Two', 'bar@example.org', 1),
301            ('58bd3a', 'User Three', 'jim@example.org', 0),
302        ])
303        _add_new_ticket_subscriber('user1', 1)
304        _add_new_ticket_subscriber('58bd3a', 0)
305
306        ticket = _test_new_ticket()
307        self.assertEqual({'joe@example.org', 'jim@example.org'},
308                         set(smtpd.get_recipients()))
309
310        ticket.save_changes('user4', 'this is my comment',
311                            when=datetime_now(utc))
312        notify_ticket_changed(self.env, ticket)
313
314        self.assertEqual([], smtpd.get_recipients())
315
316    def test_no_duplicates(self):
317        """Email addresses should be found only once in the recipient list."""
318        self.env.config.set('notification', 'smtp_always_cc',
319                            'joe.user@example.com')
320        ticket = insert_ticket(self.env, reporter='joe.user@example.com',
321                               owner='joe.user@example.com',
322                               cc='joe.user@example.com',
323                               summary='No duplicates')
324        notify_ticket_created(self.env, ticket)
325        self.assertEqual(['joe.user@example.com'], smtpd.get_recipients())
326
327    def test_long_forms(self):
328        """Long forms of SMTP email addresses 'Display Name <address>'"""
329        config_subscriber(self.env, updater=True, owner=True)
330        ticket = insert_ticket(self.env,
331           reporter='"Joe" <joe.user@example.com>',
332           owner='Joe <joe.user@example.net>',
333           cc=' \u00a0 Jóe \u3000 < joe.user@example.org > \u00a0 ',
334           summary='Long form')
335        notify_ticket_created(self.env, ticket)
336        self.assertEqual({'joe.user@example.com', 'joe.user@example.net',
337                          'joe.user@example.org'}, set(smtpd.get_recipients()))
338
339
340class NotificationTestCase(unittest.TestCase):
341    """Notification test cases that send email over SMTP"""
342
343    def setUp(self):
344        self.env = EnvironmentStub(default_data=True)
345        config_smtp(self.env)
346        self.env.config.set('trac', 'base_url', 'http://localhost/trac')
347        self.env.config.set('project', 'url', 'http://localhost/project.url')
348        self.env.config.set('notification', 'smtp_enabled', 'true')
349        self.env.config.set('notification', 'smtp_always_cc',
350                            'joe.user@example.net, joe.bar@example.net')
351        self.env.config.set('notification', 'use_public_cc', 'true')
352        self.req = MockRequest(self.env)
353
354    def tearDown(self):
355        """Signal the notification test suite that a test is over"""
356        smtpd.cleanup()
357        self.env.reset_db()
358
359    def _insert_ticket(self, **props):
360        reporter = props.pop('reporter', 'joeuser')
361        summary = props.pop('summary', 'Summary')
362        return insert_ticket(self.env, reporter=reporter, summary=summary,
363                             **props)
364
365    def test_structure(self):
366        """Basic SMTP message structure (headers, body)"""
367        ticket = insert_ticket(self.env,
368                               reporter='"Joe User" <joe.user@example.org>',
369                               owner='joe.user@example.net',
370                               cc='joe.user@example.com, joe.bar@example.org, '
371                                  'joe.bar@example.net',
372                               summary='This is a summary')
373        notify_ticket_created(self.env, ticket)
374        message = smtpd.get_message()
375        headers, body = parse_smtp_message(message)
376        # checks for header existence
377        self.assertTrue(headers)
378        # checks for body existence
379        self.assertTrue(body)
380        # checks for expected headers
381        self.assertIn('Date', headers)
382        self.assertIn('Subject', headers)
383        self.assertEqual('<073.8a48f9c2ab2dc64e820e391f8f784a04@localhost>',
384                         headers['Message-ID'])
385        self.assertIn('From', headers)
386        self.assertIn('\n-- \nTicket URL: <', body)
387
388    def test_date(self):
389        """Date format compliance (RFC822)
390           we do not support 'military' format"""
391        date_str = r"^((?P<day>\w{3}),\s*)*(?P<dm>\d{2})\s+" \
392                   r"(?P<month>\w{3})\s+(?P<year>\d{4})\s+" \
393                   r"(?P<hour>\d{2}):(?P<min>[0-5][0-9])" \
394                   r"(:(?P<sec>[0-5][0-9]))*\s" \
395                   r"((?P<tz>\w{2,3})|(?P<offset>[+\-]\d{4}))$"
396        date_re = re.compile(date_str)
397        # python time module does not detect incorrect time values
398        days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
399        months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
400                  'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
401        tz = ['UT', 'GMT', 'EST', 'EDT', 'CST', 'CDT', 'MST', 'MDT',
402              'PST', 'PDT']
403        ticket = insert_ticket(self.env,
404                               reporter='"Joe User" <joe.user@example.org>',
405                               summary='This is a summary')
406        notify_ticket_created(self.env, ticket)
407        message = smtpd.get_message()
408        headers, body = parse_smtp_message(message)
409        self.assertIn('Date', headers)
410        mo = date_re.match(headers['Date'])
411        self.assertTrue(mo)
412        if mo.group('day'):
413            self.assertIn(mo.group('day'), days)
414        self.assertIn(int(mo.group('dm')), range(1, 32))
415        self.assertIn(mo.group('month'), months)
416        self.assertIn(int(mo.group('hour')), range(24))
417        if mo.group('tz'):
418            self.assertIn(mo.group('tz'), tz)
419
420    def test_bcc_privacy(self):
421        """Visibility of recipients"""
422        def run_bcc_feature(public_cc):
423            # CC list should be private
424            self.env.config.set('notification', 'use_public_cc', public_cc)
425            self.env.config.set('notification', 'smtp_always_bcc',
426                                'joe.foobar@example.net')
427            ticket = insert_ticket(self.env,
428                                   reporter='"Joe User" <joe.user@example.org>',
429                                   summary='This is a summary')
430            notify_ticket_created(self.env, ticket)
431            message = smtpd.get_message()
432            headers, body = parse_smtp_message(message)
433            # Msg should have a To header
434            self.assertEqual('undisclosed-recipients: ;', headers['To'])
435            # Extract the list of 'Cc' recipients from the message
436            cc = extract_recipients(headers['Cc'])
437            # Extract the list of the actual SMTP recipients
438            rcptlist = smtpd.get_recipients()
439            # Build the list of the expected 'Cc' recipients
440            ccrcpt = self.env.config.getlist('notification', 'smtp_always_cc')
441            for rcpt in ccrcpt:
442                # Each recipient of the 'Cc' list should appear
443                # in the 'Cc' header
444                self.assertIn(rcpt, cc)
445                # Check the message has actually been sent to the recipients
446                self.assertIn(rcpt, rcptlist)
447            # Build the list of the expected 'Bcc' recipients
448            bccrcpt = self.env.config.getlist('notification',
449                                              'smtp_always_bcc')
450            for rcpt in bccrcpt:
451                # Check the message has actually been sent to the recipients
452                self.assertIn(rcpt, rcptlist)
453        for public in False, True:
454            run_bcc_feature(public)
455
456    def test_short_login(self):
457        """Email addresses without a FQDN"""
458        def _test_short_login(use_short_addr, username, address):
459            config_subscriber(self.env, reporter=True)
460            ticket = insert_ticket(self.env, reporter=username,
461                                   summary='This is a summary')
462            # Be sure that at least one email address is valid, so that we
463            # send a notification even if other addresses are not valid
464            self.env.config.set('notification', 'smtp_always_cc',
465                                'joe.bar@example.net, john')
466            self.env.config.set('notification', 'use_short_addr',
467                                'enabled' if use_short_addr else 'disabled')
468            notify_ticket_created(self.env, ticket)
469            message = smtpd.get_message()
470            recipients = set(smtpd.get_recipients())
471            headers, body = parse_smtp_message(message)
472            # Msg should always have a 'To' field
473            if use_short_addr:
474                self.assertEqual(address, headers['To'])
475            else:
476                self.assertEqual('undisclosed-recipients: ;', headers['To'])
477            # Msg should have a 'Cc' field
478            self.assertIn('Cc', headers)
479            cclist = set(extract_recipients(headers['Cc']))
480            if use_short_addr:
481                # Msg should be delivered to the reporter
482                self.assertEqual({'joe.bar@example.net', 'john'}, cclist)
483                self.assertEqual({address, 'joe.bar@example.net', 'john'},
484                                 recipients)
485            else:
486                # Msg should not be delivered to the reporter
487                self.assertEqual({'joe.bar@example.net'}, cclist)
488                self.assertEqual({'joe.bar@example.net'}, recipients)
489
490        # Validate with and without the short addr option enabled
491        self.env.insert_users([('bar', 'Bar User', ''),
492                               ('qux', 'Qux User', 'qux-mail')])
493        for use_short_addr in (False, True):
494            _test_short_login(use_short_addr, 'foo', 'foo')
495            _test_short_login(use_short_addr, 'bar', 'bar')
496            _test_short_login(use_short_addr, 'qux', 'qux-mail')
497
498    def test_default_domain(self):
499        """Default domain name"""
500        def _test_default_domain(enable):
501            config_subscriber(self.env)
502            self.env.config.set('notification', 'smtp_always_cc', '')
503            ticket = insert_ticket(self.env, cc='joenodom, foo, bar, qux, '
504                                                'joewithdom@example.com',
505                                   summary='This is a summary')
506            # Be sure that at least one email address is valid, so that we
507            # send a notification even if other addresses are not valid
508            self.env.config.set('notification', 'smtp_always_cc',
509                                'joe.bar@example.net')
510            self.env.config.set('notification', 'smtp_default_domain',
511                                'example.org' if enable else '')
512            notify_ticket_created(self.env, ticket)
513            message = smtpd.get_message()
514            headers, body = parse_smtp_message(message)
515            # Msg should always have a 'Cc' field
516            self.assertIn('Cc', headers)
517            cclist = set(extract_recipients(headers['Cc']))
518            if enable:
519                self.assertEqual({'joenodom@example.org', 'foo@example.org',
520                                  'bar@example.org', 'qux-mail@example.org',
521                                  'joewithdom@example.com',
522                                  'joe.bar@example.net'}, cclist)
523            else:
524                self.assertEqual({'joewithdom@example.com',
525                                  'joe.bar@example.net'}, cclist)
526
527        # Validate with and without a default domain
528        self.env.insert_users([('bar', 'Bar User', ''),
529                               ('qux', 'Qux User', 'qux-mail')])
530        for enable in (False, True):
531            _test_default_domain(enable)
532
533    def test_email_map(self):
534        """Login-to-email mapping"""
535        config_subscriber(self.env, reporter=True, owner=True)
536        self.env.config.set('notification', 'smtp_always_cc',
537                            'joe@example.com')
538        self.env.insert_users(
539            [('joeuser', 'Joe User', 'user-joe@example.com'),
540             ('jim@domain', 'Jim User', 'user-jim@example.com')])
541        ticket = insert_ticket(self.env, reporter='joeuser', owner='jim@domain',
542                               summary='This is a summary')
543        notify_ticket_created(self.env, ticket)
544        message = smtpd.get_message()
545        headers, body = parse_smtp_message(message)
546        tolist = sorted(extract_recipients(headers['To']))
547        cclist = sorted(extract_recipients(headers['Cc']))
548        # 'To' list should have been resolved to the real email address
549        self.assertEqual(['user-jim@example.com', 'user-joe@example.com'],
550                         tolist)
551        self.assertEqual(['joe@example.com'], cclist)
552
553    def test_from_author(self):
554        """Using the reporter or change author as the notification sender"""
555        self.env.config.set('notification', 'smtp_from', 'trac@example.com')
556        self.env.config.set('notification', 'smtp_from_name', 'My Trac')
557        self.env.config.set('notification', 'smtp_from_author', 'true')
558        self.env.insert_users(
559            [('joeuser', 'Joe User', 'user-joe@example.com'),
560             ('jim@domain', 'Jim User', 'user-jim@example.com'),
561             ('noemail', 'No e-mail', ''),
562             ('noname', '', 'user-noname@example.com')])
563        def modtime(delta):
564            return datetime(2016, 8, 21, 12, 34, 56, 987654, utc) + \
565                   timedelta(seconds=delta)
566        # Ticket creation uses the reporter
567        ticket = insert_ticket(self.env, reporter='joeuser',
568                               summary='This is a summary', when=modtime(0))
569        notify_ticket_created(self.env, ticket)
570        message = smtpd.get_message()
571        headers, body = parse_smtp_message(message)
572        self.assertEqual('Joe User <user-joe@example.com>', headers['From'])
573        self.assertEqual('<047.54e62c60198a043f858f1311784a5791@example.com>',
574                         headers['Message-ID'])
575        self.assertNotIn('In-Reply-To', headers)
576        self.assertNotIn('References', headers)
577        # Ticket change uses the change author
578        ticket['summary'] = 'Modified summary'
579        ticket.save_changes('jim@domain', 'Made some changes', modtime(1))
580        notify_ticket_changed(self.env, ticket, 'jim@domain')
581        message = smtpd.get_message()
582        headers, body = parse_smtp_message(message)
583        self.assertEqual('Jim User <user-jim@example.com>', headers['From'])
584        self.assertEqual('<062.a890ee4ad5488fb49e60b68099995ba3@example.com>',
585                         headers['Message-ID'])
586        self.assertEqual('<047.54e62c60198a043f858f1311784a5791@example.com>',
587                         headers['In-Reply-To'])
588        self.assertEqual('<047.54e62c60198a043f858f1311784a5791@example.com>',
589                         headers['References'])
590        # Known author without name uses e-mail address only
591        ticket['summary'] = 'Final summary'
592        ticket.save_changes('noname', 'Final changes', modtime(2))
593        notify_ticket_changed(self.env, ticket, 'noname')
594        message = smtpd.get_message()
595        headers, body = parse_smtp_message(message)
596        self.assertEqual('user-noname@example.com', headers['From'])
597        self.assertEqual('<062.732a7b25a21f5a86478c4fe47e86ade4@example.com>',
598                         headers['Message-ID'])
599        # Known author without e-mail uses smtp_from and smtp_from_name
600        ticket['summary'] = 'Other summary'
601        ticket.save_changes('noemail', 'More changes', modtime(3))
602        notify_ticket_changed(self.env, ticket, 'noemail')
603        message = smtpd.get_message()
604        headers, body = parse_smtp_message(message)
605        self.assertEqual('My Trac <trac@example.com>', headers['From'])
606        self.assertEqual('<062.98cff27cb9fabd799bcb09f9edd6c99e@example.com>',
607                         headers['Message-ID'])
608        # Unknown author with name and e-mail address
609        ticket['summary'] = 'Some summary'
610        ticket.save_changes('Test User <test@example.com>', 'Some changes',
611                            modtime(4))
612        notify_ticket_changed(self.env, ticket, 'Test User <test@example.com>')
613        message = smtpd.get_message()
614        headers, body = parse_smtp_message(message)
615        self.assertEqual('Test User <test@example.com>', headers['From'])
616        self.assertEqual('<062.6e08a363c340c1d4e2ed84c6123a1e9d@example.com>',
617                         headers['Message-ID'])
618        # Unknown author with e-mail address only
619        ticket['summary'] = 'Some summary'
620        ticket.save_changes('test@example.com', 'Some changes', modtime(5))
621        notify_ticket_changed(self.env, ticket, 'test@example.com')
622        message = smtpd.get_message()
623        headers, body = parse_smtp_message(message)
624        self.assertEqual('test@example.com', headers['From'])
625        self.assertEqual('<062.5eb050ae6f322c33dde5940a5f318343@example.com>',
626                         headers['Message-ID'])
627        # Unknown author uses smtp_from and smtp_from_name
628        ticket['summary'] = 'Better summary'
629        ticket.save_changes('unknown', 'Made more changes', modtime(6))
630        notify_ticket_changed(self.env, ticket, 'unknown')
631        message = smtpd.get_message()
632        headers, body = parse_smtp_message(message)
633        self.assertEqual('My Trac <trac@example.com>', headers['From'])
634        self.assertEqual('<062.6d5543782e7aba4100302487e75ce16f@example.com>',
635                         headers['Message-ID'])
636
637    def test_ignore_domains(self):
638        """Non-SMTP domain exclusion"""
639        config_subscriber(self.env, reporter=True, owner=True)
640        self.env.config.set('notification', 'ignore_domains',
641                            'example.com, example.org')
642        self.env.insert_users(
643            [('kerberos@example.com', 'No Email', ''),
644             ('kerberos@example.org', 'With Email', 'kerb@example.net')])
645        ticket = insert_ticket(self.env, reporter='kerberos@example.com',
646                               owner='kerberos@example.org',
647                               summary='This is a summary')
648        notify_ticket_created(self.env, ticket)
649        message = smtpd.get_message()
650        headers, body = parse_smtp_message(message)
651        tolist = set(extract_recipients(headers['To']))
652        cclist = set(extract_recipients(headers['Cc']))
653        # 'To' list should not contain addresses with non-SMTP domains
654        self.assertNotIn('kerberos@example.com', tolist)
655        self.assertNotIn('kerberos@example.org', tolist)
656        # 'To' list should have been resolved to the actual email address
657        self.assertEqual({'kerb@example.net'}, tolist)
658        # 'Cc' list should have been resolved to the actual email address
659        self.assertEqual({'joe.bar@example.net', 'joe.user@example.net'},
660                         cclist)
661
662    def test_admit_domains(self):
663        """SMTP domain inclusion"""
664        config_subscriber(self.env, reporter=True)
665        self.env.config.set('notification', 'admit_domains',
666                            'localdomain, server')
667        ticket = insert_ticket(self.env, reporter='joeuser@example.com',
668                               summary='This is a summary',
669                               cc='joe.user@localdomain, joe.user@unknown, '
670                                  'joe.user@server')
671        notify_ticket_created(self.env, ticket)
672        message = smtpd.get_message()
673        headers, body = parse_smtp_message(message)
674        # Msg should always have a 'To' field
675        self.assertEqual('joeuser@example.com', headers['To'])
676        self.assertIn('Cc', headers)
677        cclist = set(extract_recipients(headers['Cc']))
678        # 'Cc' list should contain addresses with SMTP included domains
679        self.assertIn('joe.user@localdomain', cclist)
680        self.assertIn('joe.user@server', cclist)
681        # 'Cc' list should not contain non-FQDN domains
682        self.assertNotIn('joe.user@unknown', cclist)
683        self.assertEqual({'joe.user@localdomain', 'joe.user@server',
684                          'joe.user@example.net', 'joe.bar@example.net'},
685                         cclist)
686
687    def test_multiline_header(self):
688        """Encoded headers split into multiple lines"""
689        self.env.config.set('notification', 'mime_encoding', 'qp')
690        ticket = insert_ticket(self.env, reporter='joe.user@example.org',
691                               summary='A_very %s súmmäry'
692                                       % ' '.join(['long'] * 20))
693        notify_ticket_created(self.env, ticket)
694        message = smtpd.get_message()
695        headers, body = parse_smtp_message(message)
696        # Discards the project name & ticket number
697        subject = headers['Subject']
698        summary = subject[subject.find(':')+2:]
699        self.assertEqual(ticket['summary'], summary)
700
701    def test_mimebody_b64(self):
702        """MIME Base64/utf-8 encoding"""
703        self.env.config.set('notification', 'mime_encoding', 'base64')
704        summary = 'This is a long enough summary to cause Trac ' \
705                  'to generate a multi-line (2 lines) súmmäry'
706        ticket = insert_ticket(self.env, reporter='joe.user@example.org',
707                               summary=summary)
708        self._validate_mimebody((base64.b64decode, 'base64', 'utf-8'),
709                                ticket, True)
710
711    def test_mimebody_qp(self):
712        """MIME QP/utf-8 encoding"""
713        self.env.config.set('notification', 'mime_encoding', 'qp')
714        summary = 'This is a long enough summary to cause Trac ' \
715                  'to generate a multi-line (2 lines) súmmäry'
716        ticket = insert_ticket(self.env, reporter='joe.user@example.org',
717                               summary=summary)
718        self._validate_mimebody((quopri.decodestring, 'quoted-printable',
719                                 'utf-8'), ticket, True)
720
721    def test_mimebody_none_7bit(self):
722        """MIME None encoding resulting in 7bit"""
723        self.env.config.set('notification', 'mime_encoding', 'none')
724        ticket = insert_ticket(self.env, reporter='joe.user',
725                               summary='This is a summary')
726        self._validate_mimebody((None, '7bit', 'utf-8'), ticket, True)
727
728    def test_mimebody_none_8bit(self):
729        """MIME None encoding resulting in 8bit"""
730        self.env.config.set('notification', 'mime_encoding', 'none')
731        ticket = insert_ticket(self.env, reporter='joe.user',
732                               summary='This is a summary for Jöe Usèr')
733        self._validate_mimebody((None, '8bit', 'utf-8'), ticket, True)
734
735    def _test_msgid_digest(self, hash_type):
736        """MD5 digest w/ non-ASCII recipient address (#3491)"""
737        config_subscriber(self.env, reporter=True)
738        self.env.config.set('notification', 'smtp_always_cc', '')
739        if hash_type:
740            self.env.config.set('notification', 'message_id_hash', hash_type)
741        ticket = insert_ticket(self.env, summary='This is a summary',
742                               reporter='"Jöe Usèr" <joe.user@example.org>')
743        notify_ticket_created(self.env, ticket)
744        message = smtpd.get_message()
745        headers, body = parse_smtp_message(message)
746        self.assertEqual('joe.user@example.org', headers['To'])
747        self.assertNotIn('Cc', headers)
748        return headers
749
750    def test_md5_digest(self):
751        headers = self._test_msgid_digest(None)
752        self.assertEqual('<071.cbea352f8c4fa58e4b10d24c17b091e6@localhost>',
753                         headers['Message-ID'])
754
755    def test_sha1_digest(self):
756        headers = self._test_msgid_digest('sha1')
757        self.assertEqual(
758            '<071.0b6459808bc3603bd642b9a478928d9b5542a803@localhost>',
759            headers['Message-ID'])
760
761    def test_add_to_cc_list(self):
762        """Members added to CC list receive notifications."""
763        config_subscriber(self.env)
764        ticket = insert_ticket(self.env, summary='Foo')
765        ticket['cc'] = 'joe.user1@example.net'
766        now = datetime_now(utc)
767        ticket.save_changes('joe.bar@example.com', 'Added to cc', now)
768        notify_ticket_changed(self.env, ticket)
769        recipients = smtpd.get_recipients()
770        self.assertIn('joe.user1@example.net', recipients)
771
772    def test_previous_cc_list(self):
773        """Members removed from CC list receive notifications"""
774        config_subscriber(self.env)
775        ticket = insert_ticket(self.env, summary='Foo',
776                               cc='joe.user1@example.net')
777        ticket['cc'] = 'joe.user2@example.net'
778        now = datetime_now(utc)
779        ticket.save_changes('joe.bar@example.com', 'Removed from cc', now)
780        notify_ticket_changed(self.env, ticket)
781        recipients = smtpd.get_recipients()
782        self.assertIn('joe.user1@example.net', recipients)
783        self.assertIn('joe.user2@example.net', recipients)
784
785    def test_previous_owner(self):
786        """Previous owner is notified when ticket is reassigned (#2311)
787           if always_notify_owner is set to True"""
788        def _test_owner(enabled):
789            config_subscriber(self.env, owner=enabled)
790            prev_owner = 'joe.user1@example.net'
791            ticket = insert_ticket(self.env, summary='Foo', owner=prev_owner)
792            ticket['owner'] = new_owner = 'joe.user2@example.net'
793            now = datetime_now(utc)
794            ticket.save_changes('joe.bar@example.com', 'Changed owner', now)
795            notify_ticket_changed(self.env, ticket)
796            recipients = smtpd.get_recipients()
797            if enabled:
798                self.assertIn(prev_owner, recipients)
799                self.assertIn(new_owner, recipients)
800            else:
801                self.assertNotIn(prev_owner, recipients)
802                self.assertNotIn(new_owner, recipients)
803
804        for enable in False, True:
805            _test_owner(enable)
806
807    def _validate_mimebody(self, mime, ticket, newtk):
808        """Body of a ticket notification message"""
809        mime_decoder, mime_name, mime_charset = mime
810        if newtk:
811            notify_ticket_created(self.env, ticket)
812        else:
813            notify_ticket_changed(self.env, ticket)
814        message = smtpd.get_message()
815        headers, body = parse_smtp_message(message)
816        self.assertIn('MIME-Version', headers)
817        self.assertIn('Content-Type', headers)
818        self.assertTrue(re.compile(r"1.\d").match(headers['MIME-Version']))
819        ctype_mo = re.match(r'\s*([^;\s]*)\s*(?:;\s*boundary="([^"]*)")?',
820                            headers['Content-Type'])
821        self.assertEqual('multipart/related', ctype_mo.group(1))
822        boundary_re = re.compile(r'(?:\r\n)*^--%s(?:--)?(?:\r\n|\Z)' %
823                                 re.escape(ctype_mo.group(2)), re.MULTILINE)
824        body = boundary_re.split(message)[1]
825        headers, body = parse_smtp_message(body)
826        self.assertIn('Content-Type', headers)
827        self.assertIn('Content-Transfer-Encoding', headers)
828        type_re = re.compile(r'^text/plain;\scharset="([\w\-\d]+)"$')
829        charset = type_re.match(headers['Content-Type'])
830        self.assertTrue(charset)
831        charset = charset.group(1)
832        self.assertEqual(mime_charset, charset)
833        self.assertEqual(headers['Content-Transfer-Encoding'], mime_name)
834        # checks the width of each body line
835        for line in body.splitlines():
836            self.assertTrue(len(line) <= MAXBODYWIDTH)
837        # attempts to decode the body, following the specified MIME encoding
838        # and charset
839        try:
840            if mime_decoder:
841                body = mime_decoder(body)
842                body = str(body, charset)
843        except Exception as e:
844            raise AssertionError(e) from e
845        # now processes each line of the body
846        bodylines = body.splitlines()
847        # body starts with one of more summary lines, first line is prefixed
848        # with the ticket number such as #<n>: summary
849        # finds the banner after the summary
850        banner_delim_re = re.compile(r'^\-+\+\-+$')
851        bodyheader = []
852        while not banner_delim_re.match(bodylines[0]):
853            bodyheader.append(bodylines.pop(0))
854        # summary should be present
855        self.assertTrue(bodyheader)
856        # banner should not be empty
857        self.assertTrue(bodylines)
858        # extracts the ticket ID from the first line
859        tknum, bodyheader[0] = bodyheader[0].split(' ', 1)
860        self.assertEqual('#', tknum[0])
861        try:
862            tkid = int(tknum[1:-1])
863            self.assertEqual(1, tkid)
864        except ValueError:
865            raise AssertionError("invalid ticket number")
866        self.assertEqual(':', tknum[-1])
867        summary = ' '.join(bodyheader)
868        self.assertEqual(summary, ticket['summary'])
869        # now checks the banner contents
870        self.assertTrue(banner_delim_re.match(bodylines[0]))
871        banner = True
872        footer = None
873        props = {}
874        for line in bodylines[1:]:
875            # detect end of banner
876            if banner_delim_re.match(line):
877                banner = False
878                continue
879            if banner:
880                # parse banner and fill in a property dict
881                properties = line.split('|')
882                self.assertEqual(2, len(properties))
883                for prop in properties:
884                    if prop.strip() == '':
885                        continue
886                    k, v = prop.split(':')
887                    props[k.strip().lower()] = v.strip()
888            # detect footer marker (weak detection)
889            if not footer:
890                if line.strip() == '--':
891                    footer = 0
892                    continue
893            # check footer
894            if footer is not None:
895                footer += 1
896                # invalid footer detection
897                self.assertTrue(footer <= 3)
898                # check ticket link
899                if line[:11] == 'Ticket URL:':
900                    ticket_link = self.env.abs_href.ticket(ticket.id)
901                    self.assertEqual(line[12:].strip(), "<%s>" % ticket_link)
902                # note project title / URL are not validated yet
903
904        # ticket properties which are not expected in the banner
905        xlist = ['summary', 'description', 'comment', 'time', 'changetime']
906        # check banner content (field exists, msg value matches ticket value)
907        for p in [prop for prop in ticket.values if prop not in xlist]:
908            self.assertIn(p, props)
909            # Email addresses might be obfuscated
910            if '@' in ticket[p] and '@' in props[p]:
911                self.assertEqual(props[p].split('@')[0],
912                                 ticket[p].split('@')[0])
913            else:
914                self.assertEqual(props[p], ticket[p])
915
916    def test_props_format_ambiwidth_single(self):
917        self.env.config.set('notification', 'mime_encoding', 'none')
918        self.env.config.set('notification', 'ambiguous_char_width', '')
919        ticket = Ticket(self.env)
920        ticket['summary'] = 'This is a summary'
921        ticket['reporter'] = 'аnonymoиs'
922        ticket['status'] = 'new'
923        ticket['owner'] = 'somеbody'
924        ticket['type'] = 'バグ(dеfеct)'
925        ticket['priority'] = 'メジャー(mаjor)'
926        ticket['milestone'] = 'マイルストーン1'
927        ticket['component'] = 'コンポーネント1'
928        ticket['version'] = '2.0 аlphа'
929        ticket['resolution'] = 'fixed'
930        ticket['keywords'] = ''
931        ticket.insert()
932        formatted = """\
933  Reporter:  аnonymoиs        |      Owner:  somеbody
934      Type:  バグ(dеfеct)     |     Status:  new
935  Priority:  メジャー(mаjor)  |  Milestone:  マイルストーン1
936 Component:  コンポーネント1  |    Version:  2.0 аlphа
937Resolution:  fixed            |   Keywords:"""
938        self._validate_props_format(formatted, ticket)
939
940    def test_props_format_ambiwidth_double(self):
941        self.env.config.set('notification', 'mime_encoding', 'none')
942        self.env.config.set('notification', 'ambiguous_char_width', 'double')
943        ticket = Ticket(self.env)
944        ticket['summary'] = 'This is a summary'
945        ticket['reporter'] = 'аnonymoиs'
946        ticket['status'] = 'new'
947        ticket['owner'] = 'somеbody'
948        ticket['type'] = 'バグ(dеfеct)'
949        ticket['priority'] = 'メジャー(mаjor)'
950        ticket['milestone'] = 'マイルストーン1'
951        ticket['component'] = 'コンポーネント1'
952        ticket['version'] = '2.0 аlphа'
953        ticket['resolution'] = 'fixed'
954        ticket['keywords'] = ''
955        ticket.insert()
956        formatted = """\
957  Reporter:  аnonymoиs       |      Owner:  somеbody
958      Type:  バグ(dеfеct)    |     Status:  new
959  Priority:  メジャー(mаjor)  |  Milestone:  マイルストーン1
960 Component:  コンポーネント1   |    Version:  2.0 аlphа
961Resolution:  fixed             |   Keywords:"""
962        self._validate_props_format(formatted, ticket)
963
964    def test_props_format_obfuscated_email(self):
965        self.env.config.set('notification', 'mime_encoding', 'none')
966        ticket = Ticket(self.env)
967        ticket['summary'] = 'This is a summary'
968        ticket['reporter'] = 'joe@foobar.foo.bar.example.org'
969        ticket['status'] = 'new'
970        ticket['owner'] = 'joe.bar@foobar.foo.bar.example.org'
971        ticket['type'] = 'defect'
972        ticket['priority'] = 'major'
973        ticket['milestone'] = 'milestone1'
974        ticket['component'] = 'component1'
975        ticket['version'] = '2.0'
976        ticket['resolution'] = 'fixed'
977        ticket['keywords'] = ''
978        ticket.insert()
979        formatted = """\
980  Reporter:  joe@…       |      Owner:  joe.bar@…
981      Type:  defect      |     Status:  new
982  Priority:  major       |  Milestone:  milestone1
983 Component:  component1  |    Version:  2.0
984Resolution:  fixed       |   Keywords:"""
985        self._validate_props_format(formatted, ticket)
986
987    def test_props_format_obfuscated_email_disabled(self):
988        self.env.config.set('notification', 'mime_encoding', 'none')
989        self.env.config.set('trac', 'show_email_addresses', 'true')
990        ticket = Ticket(self.env)
991        ticket['summary'] = 'This is a summary'
992        ticket['reporter'] = 'joe@foobar.foo.bar.example.org'
993        ticket['status'] = 'new'
994        ticket['owner'] = 'joe.bar@foobar.foo.bar.example.org'
995        ticket['type'] = 'defect'
996        ticket['priority'] = 'major'
997        ticket['milestone'] = 'milestone1'
998        ticket['component'] = 'component1'
999        ticket['version'] = '2.0'
1000        ticket['resolution'] = 'fixed'
1001        ticket['keywords'] = ''
1002        ticket.insert()
1003        formatted = """\
1004  Reporter:                          |      Owner:
1005  joe@foobar.foo.bar.example.org     |  joe.bar@foobar.foo.bar.example.org
1006      Type:  defect                  |     Status:  new
1007  Priority:  major                   |  Milestone:  milestone1
1008 Component:  component1              |    Version:  2.0
1009Resolution:  fixed                   |   Keywords:"""
1010        self._validate_props_format(formatted, ticket)
1011
1012    def test_props_format_show_full_names(self):
1013        self.env.insert_users([
1014            ('joefoo', 'Joę Fœœ', 'joe@foobar.foo.bar.example.org'),
1015            ('joebar', 'Jœe Bær', 'joe.bar@foobar.foo.bar.example.org')
1016        ])
1017        self.env.config.set('notification', 'mime_encoding', 'none')
1018        ticket = Ticket(self.env)
1019        ticket['summary'] = 'This is a summary'
1020        ticket['reporter'] = 'joefoo'
1021        ticket['status'] = 'new'
1022        ticket['owner'] = 'joebar'
1023        ticket['type'] = 'defect'
1024        ticket['priority'] = 'major'
1025        ticket['milestone'] = 'milestone1'
1026        ticket['component'] = 'component1'
1027        ticket['version'] = '2.0'
1028        ticket['resolution'] = 'fixed'
1029        ticket['keywords'] = ''
1030        ticket.insert()
1031        formatted = """\
1032  Reporter:  Joę Fœœ     |      Owner:  Jœe Bær
1033      Type:  defect      |     Status:  new
1034  Priority:  major       |  Milestone:  milestone1
1035 Component:  component1  |    Version:  2.0
1036Resolution:  fixed       |   Keywords:"""
1037        self._validate_props_format(formatted, ticket)
1038
1039    def test_props_format_wrap_leftside(self):
1040        self.env.config.set('notification', 'mime_encoding', 'none')
1041        ticket = Ticket(self.env)
1042        ticket['summary'] = 'This is a summary'
1043        ticket['reporter'] = 'anonymous'
1044        ticket['status'] = 'new'
1045        ticket['owner'] = 'somebody'
1046        ticket['type'] = 'defect'
1047        ticket['priority'] = 'major'
1048        ticket['milestone'] = 'milestone1'
1049        ticket['component'] = 'Lorem ipsum dolor sit amet, consectetur ' \
1050                              'adipisicing elit, sed do eiusmod tempor ' \
1051                              'incididunt ut labore et dolore magna ' \
1052                              'aliqua. Ut enim ad minim veniam, quis ' \
1053                              'nostrud exercitation ullamco laboris nisi ' \
1054                              'ut aliquip ex ea commodo consequat. Duis ' \
1055                              'aute irure dolor in reprehenderit in ' \
1056                              'voluptate velit esse cillum dolore eu ' \
1057                              'fugiat nulla pariatur. Excepteur sint ' \
1058                              'occaecat cupidatat non proident, sunt in ' \
1059                              'culpa qui officia deserunt mollit anim id ' \
1060                              'est laborum.'
1061        ticket['version'] = '2.0'
1062        ticket['resolution'] = 'fixed'
1063        ticket['keywords'] = ''
1064        ticket.insert()
1065        formatted = """\
1066  Reporter:  anonymous                           |      Owner:  somebody
1067      Type:  defect                              |     Status:  new
1068  Priority:  major                               |  Milestone:  milestone1
1069 Component:  Lorem ipsum dolor sit amet,         |    Version:  2.0
1070  consectetur adipisicing elit, sed do eiusmod   |
1071  tempor incididunt ut labore et dolore magna    |
1072  aliqua. Ut enim ad minim veniam, quis nostrud  |
1073  exercitation ullamco laboris nisi ut aliquip   |
1074  ex ea commodo consequat. Duis aute irure       |
1075  dolor in reprehenderit in voluptate velit      |
1076  esse cillum dolore eu fugiat nulla pariatur.   |
1077  Excepteur sint occaecat cupidatat non          |
1078  proident, sunt in culpa qui officia deserunt   |
1079  mollit anim id est laborum.                    |
1080Resolution:  fixed                               |   Keywords:"""
1081        self._validate_props_format(formatted, ticket)
1082
1083    def test_props_format_wrap_leftside_unicode(self):
1084        self.env.config.set('notification', 'mime_encoding', 'none')
1085        ticket = Ticket(self.env)
1086        ticket['summary'] = 'This is a summary'
1087        ticket['reporter'] = 'anonymous'
1088        ticket['status'] = 'new'
1089        ticket['owner'] = 'somebody'
1090        ticket['type'] = 'defect'
1091        ticket['priority'] = 'major'
1092        ticket['milestone'] = 'milestone1'
1093        ticket['component'] = 'Trac は BSD ライセンスのもとで配' \
1094                              '布されています。[1:]このライセ' \
1095                              'ンスの全文は、配布ファイルに' \
1096                              '含まれている [3:COPYING] ファイル' \
1097                              'と同じものが[2:オンライン]で参' \
1098                              '照できます。'
1099        ticket['version'] = '2.0'
1100        ticket['resolution'] = 'fixed'
1101        ticket['keywords'] = ''
1102        ticket.insert()
1103        formatted = """\
1104  Reporter:  anonymous                           |      Owner:  somebody
1105      Type:  defect                              |     Status:  new
1106  Priority:  major                               |  Milestone:  milestone1
1107 Component:  Trac は BSD ライセンスのもとで配布  |    Version:  2.0
1108  されています。[1:]このライセンスの全文は、配   |
1109  布ファイルに含まれている [3:COPYING] ファイル  |
1110  と同じものが[2:オンライン]で参照できます。     |
1111Resolution:  fixed                               |   Keywords:"""
1112        self._validate_props_format(formatted, ticket)
1113
1114    def test_props_format_wrap_rightside(self):
1115        self.env.config.set('notification', 'mime_encoding', 'none')
1116        ticket = Ticket(self.env)
1117        ticket['summary'] = 'This is a summary'
1118        ticket['reporter'] = 'anonymous'
1119        ticket['status'] = 'new'
1120        ticket['owner'] = 'somebody'
1121        ticket['type'] = 'defect'
1122        ticket['priority'] = 'major'
1123        ticket['milestone'] = 'Lorem ipsum dolor sit amet, consectetur ' \
1124                              'adipisicing elit, sed do eiusmod tempor ' \
1125                              'incididunt ut labore et dolore magna ' \
1126                              'aliqua. Ut enim ad minim veniam, quis ' \
1127                              'nostrud exercitation ullamco laboris nisi ' \
1128                              'ut aliquip ex ea commodo consequat. Duis ' \
1129                              'aute irure dolor in reprehenderit in ' \
1130                              'voluptate velit esse cillum dolore eu ' \
1131                              'fugiat nulla pariatur. Excepteur sint ' \
1132                              'occaecat cupidatat non proident, sunt in ' \
1133                              'culpa qui officia deserunt mollit anim id ' \
1134                              'est laborum.'
1135        ticket['component'] = 'component1'
1136        ticket['version'] = '2.0 Standard and International Edition'
1137        ticket['resolution'] = 'fixed'
1138        ticket['keywords'] = ''
1139        ticket.insert()
1140        formatted = """\
1141  Reporter:  anonymous   |      Owner:  somebody
1142      Type:  defect      |     Status:  new
1143  Priority:  major       |  Milestone:  Lorem ipsum dolor sit amet,
1144                         |  consectetur adipisicing elit, sed do eiusmod
1145                         |  tempor incididunt ut labore et dolore magna
1146                         |  aliqua. Ut enim ad minim veniam, quis nostrud
1147                         |  exercitation ullamco laboris nisi ut aliquip ex
1148                         |  ea commodo consequat. Duis aute irure dolor in
1149                         |  reprehenderit in voluptate velit esse cillum
1150                         |  dolore eu fugiat nulla pariatur. Excepteur sint
1151                         |  occaecat cupidatat non proident, sunt in culpa
1152                         |  qui officia deserunt mollit anim id est
1153                         |  laborum.
1154 Component:  component1  |    Version:  2.0 Standard and International
1155                         |  Edition
1156Resolution:  fixed       |   Keywords:"""
1157        self._validate_props_format(formatted, ticket)
1158
1159    def test_props_format_wrap_rightside_unicode(self):
1160        self.env.config.set('notification', 'mime_encoding', 'none')
1161        ticket = Ticket(self.env)
1162        ticket['summary'] = 'This is a summary'
1163        ticket['reporter'] = 'anonymous'
1164        ticket['status'] = 'new'
1165        ticket['owner'] = 'somebody'
1166        ticket['type'] = 'defect'
1167        ticket['priority'] = 'major'
1168        ticket['milestone'] = 'Trac 在经过修改的BSD协议下发布。' \
1169                              '[1:]协议的完整文本可以[2:在线查' \
1170                              '看]也可在发布版的 [3:COPYING] 文' \
1171                              '件中找到。'
1172        ticket['component'] = 'component1'
1173        ticket['version'] = '2.0'
1174        ticket['resolution'] = 'fixed'
1175        ticket['keywords'] = ''
1176        ticket.insert()
1177        formatted = """\
1178  Reporter:  anonymous   |      Owner:  somebody
1179      Type:  defect      |     Status:  new
1180  Priority:  major       |  Milestone:  Trac 在经过修改的BSD协议下发布。
1181                         |  [1:]协议的完整文本可以[2:在线查看]也可在发布版
1182                         |  的 [3:COPYING] 文件中找到。
1183 Component:  component1  |    Version:  2.0
1184Resolution:  fixed       |   Keywords:"""
1185        self._validate_props_format(formatted, ticket)
1186
1187    def test_props_format_wrap_bothsides(self):
1188        self.env.config.set('notification', 'mime_encoding', 'none')
1189        ticket = Ticket(self.env)
1190        ticket['summary'] = 'This is a summary'
1191        ticket['reporter'] = 'anonymous'
1192        ticket['status'] = 'new'
1193        ticket['owner'] = 'somebody'
1194        ticket['type'] = 'defect'
1195        ticket['priority'] = 'major'
1196        ticket['milestone'] = 'Lorem ipsum dolor sit amet, consectetur ' \
1197                              'adipisicing elit, sed do eiusmod tempor ' \
1198                              'incididunt ut labore et dolore magna ' \
1199                              'aliqua. Ut enim ad minim veniam, quis ' \
1200                              'nostrud exercitation ullamco laboris nisi ' \
1201                              'ut aliquip ex ea commodo consequat. Duis ' \
1202                              'aute irure dolor in reprehenderit in ' \
1203                              'voluptate velit esse cillum dolore eu ' \
1204                              'fugiat nulla pariatur. Excepteur sint ' \
1205                              'occaecat cupidatat non proident, sunt in ' \
1206                              'culpa qui officia deserunt mollit anim id ' \
1207                              'est laborum.'
1208        ticket['component'] = ('Lorem ipsum dolor sit amet, consectetur '
1209                               'adipisicing elit, sed do eiusmod tempor '
1210                               'incididunt ut labore et dolore magna aliqua.')
1211        ticket['version'] = '2.0'
1212        ticket['resolution'] = 'fixed'
1213        ticket['keywords'] = 'Ut enim ad minim veniam, ....'
1214        ticket.insert()
1215        formatted = """\
1216  Reporter:  anonymous               |      Owner:  somebody
1217      Type:  defect                  |     Status:  new
1218  Priority:  major                   |  Milestone:  Lorem ipsum dolor sit
1219                                     |  amet, consectetur adipisicing elit,
1220                                     |  sed do eiusmod tempor incididunt ut
1221                                     |  labore et dolore magna aliqua. Ut
1222                                     |  enim ad minim veniam, quis nostrud
1223                                     |  exercitation ullamco laboris nisi
1224                                     |  ut aliquip ex ea commodo consequat.
1225                                     |  Duis aute irure dolor in
1226                                     |  reprehenderit in voluptate velit
1227                                     |  esse cillum dolore eu fugiat nulla
1228 Component:  Lorem ipsum dolor sit   |  pariatur. Excepteur sint occaecat
1229  amet, consectetur adipisicing      |  cupidatat non proident, sunt in
1230  elit, sed do eiusmod tempor        |  culpa qui officia deserunt mollit
1231  incididunt ut labore et dolore     |  anim id est laborum.
1232  magna aliqua.                      |    Version:  2.0
1233Resolution:  fixed                   |   Keywords:  Ut enim ad minim
1234                                     |  veniam, ...."""
1235        self._validate_props_format(formatted, ticket)
1236
1237    def test_props_format_wrap_bothsides_unicode(self):
1238        self.env.config.set('notification', 'mime_encoding', 'none')
1239        self.env.config.set('notification', 'ambiguous_char_width', 'double')
1240        ticket = Ticket(self.env)
1241        ticket['summary'] = 'This is a summary'
1242        ticket['reporter'] = 'anonymous'
1243        ticket['status'] = 'new'
1244        ticket['owner'] = 'somebody'
1245        ticket['type'] = 'defect'
1246        ticket['priority'] = 'major'
1247        ticket['milestone'] = 'Trac 在经过修改的BSD协议下发布。' \
1248                              '[1:]协议的完整文本可以[2:在线查' \
1249                              '看]也可在发布版的 [3:COPYING] 文' \
1250                              '件中找到。'
1251        ticket['component'] = 'Trac は BSD ライセンスのもとで配' \
1252                              '布されています。[1:]このライセ' \
1253                              'ンスの全文は、※配布ファイル' \
1254                              'に含まれている[3:CОPYING]ファイ' \
1255                              'ルと同じものが[2:オンライン]で' \
1256                              '参照できます。'
1257        ticket['version'] = '2.0 International Edition'
1258        ticket['resolution'] = 'fixed'
1259        ticket['keywords'] = ''
1260        ticket.insert()
1261        formatted = """\
1262  Reporter:  anonymous               |      Owner:  somebody
1263      Type:  defect                  |     Status:  new
1264  Priority:  major                   |  Milestone:  Trac 在经过修改的BSD协
1265 Component:  Trac は BSD ライセンス  |  议下发布。[1:]协议的完整文本可以[2:
1266  のもとで配布されています。[1:]こ   |  在线查看]也可在发布版的 [3:COPYING]
1267  のライセンスの全文は、※配布ファ   |  文件中找到。
1268  イルに含まれている[3:CОPYING]フ   |    Version:  2.0 International
1269  ァイルと同じものが[2:オンライン]   |  Edition
1270  で参照できます。                   |
1271Resolution:  fixed                   |   Keywords:"""
1272        self._validate_props_format(formatted, ticket)
1273
1274    def test_props_format_wrap_ticket_10283(self):
1275        self.env.config.set('notification', 'mime_encoding', 'none')
1276        for name, value in (('blockedby', 'text'),
1277                            ('blockedby.label', 'Blocked by'),
1278                            ('blockedby.order', '6'),
1279                            ('blocking', 'text'),
1280                            ('blocking.label', 'Blocking'),
1281                            ('blocking.order', '5'),
1282                            ('deployment', 'text'),
1283                            ('deployment.label', 'Deployment state'),
1284                            ('deployment.order', '1'),
1285                            ('nodes', 'text'),
1286                            ('nodes.label', 'Related nodes'),
1287                            ('nodes.order', '3'),
1288                            ('privacy', 'text'),
1289                            ('privacy.label', 'Privacy sensitive'),
1290                            ('privacy.order', '2'),
1291                            ('sensitive', 'text'),
1292                            ('sensitive.label', 'Security sensitive'),
1293                            ('sensitive.order', '4')):
1294            self.env.config.set('ticket-custom', name, value)
1295
1296        ticket = Ticket(self.env)
1297        ticket['summary'] = 'This is a summary'
1298        ticket['reporter'] = 'anonymous'
1299        ticket['owner'] = 'somebody'
1300        ticket['type'] = 'defect'
1301        ticket['status'] = 'closed'
1302        ticket['priority'] = 'normal'
1303        ticket['milestone'] = 'iter_01'
1304        ticket['component'] = 'XXXXXXXXXXXXXXXXXXXXXXXXXX'
1305        ticket['resolution'] = 'fixed'
1306        ticket['keywords'] = ''
1307        ticket['deployment'] = ''
1308        ticket['privacy'] = '0'
1309        ticket['nodes'] = 'XXXXXXXXXX'
1310        ticket['sensitive'] = '0'
1311        ticket['blocking'] = ''
1312        ticket['blockedby'] = ''
1313        ticket.insert()
1314
1315        formatted = """\
1316          Reporter:  anonymous                   |             Owner:
1317                                                 |  somebody
1318              Type:  defect                      |            Status:
1319                                                 |  closed
1320          Priority:  normal                      |         Milestone:
1321                                                 |  iter_01
1322         Component:  XXXXXXXXXXXXXXXXXXXXXXXXXX  |        Resolution:
1323                                                 |  fixed
1324          Keywords:                              |  Deployment state:
1325 Privacy sensitive:  0                           |     Related nodes:
1326                                                 |  XXXXXXXXXX
1327Security sensitive:  0                           |          Blocking:
1328        Blocked by:                              |"""
1329        self._validate_props_format(formatted, ticket)
1330
1331    def _validate_props_format(self, expected, ticket):
1332        notify_ticket_created(self.env, ticket)
1333        message = smtpd.get_message()
1334        headers, body = parse_smtp_message(message)
1335        bodylines = body.splitlines()
1336        # Extract ticket properties
1337        delim_re = re.compile(r'^\-+\+\-+$')
1338        while not delim_re.match(bodylines[0]):
1339            bodylines.pop(0)
1340        lines = []
1341        for line in bodylines[1:]:
1342            if delim_re.match(line):
1343                break
1344            lines.append(line)
1345        self.assertEqual(expected, '\n'.join(lines))
1346
1347    def test_notification_does_not_alter_ticket_instance(self):
1348        ticket = insert_ticket(self.env, summary='My Summary',
1349                               description='Some description')
1350        notify_ticket_created(self.env, ticket)
1351        self.assertIsNotNone(smtpd.get_message())
1352        self.assertEqual('My Summary', ticket['summary'])
1353        self.assertEqual('Some description', ticket['description'])
1354        valid_fieldnames = {f['name'] for f in ticket.fields}
1355        current_fieldnames = set(ticket.values)
1356        self.assertEqual(set(), current_fieldnames - valid_fieldnames)
1357
1358    def test_mime_meta_characters_in_from_header(self):
1359        """MIME encoding with meta characters in From header"""
1360
1361        self.env.config.set('notification', 'smtp_from', 'trac@example.com')
1362        self.env.config.set('notification', 'mime_encoding', 'base64')
1363        ticket = insert_ticket(self.env, reporter='joeuser',
1364                               summary='This is a summary')
1365
1366        def notify(from_name):
1367            self.env.config.set('notification', 'smtp_from_name', from_name)
1368            notify_ticket_created(self.env, ticket)
1369            message = smtpd.get_message()
1370            headers, body = parse_smtp_message(message, decode=False)
1371            return message, headers, body
1372
1373        message, headers, body = notify('Träc')
1374        self.assertIn(headers['From'],
1375                      ('=?utf-8?b?VHLDpGM=?= <trac@example.com>',
1376                       '=?utf-8?q?Tr=C3=A4c?= <trac@example.com>'))
1377        message, headers, body = notify('Trac\\')
1378        self.assertEqual(r'"Trac\\" <trac@example.com>', headers['From'])
1379        message, headers, body = notify('Trac"')
1380        self.assertEqual(r'"Trac\"" <trac@example.com>', headers['From'])
1381        message, headers, body = notify('=?utf-8?q?e_?=')
1382        self.assertEqual('=?utf-8?b?PeKAiz91dGYtOD9xP2VfPz0=?= '
1383                         '<trac@example.com>', headers['From'])
1384
1385    def test_mime_meta_characters_in_subject_header(self):
1386        """MIME encoding with meta characters in Subject header"""
1387
1388        self.env.config.set('notification', 'smtp_from', 'trac@example.com')
1389        self.env.config.set('notification', 'mime_encoding', 'base64')
1390        summary = '=?utf-8?q?e_?='
1391        ticket = insert_ticket(self.env, reporter='joeuser', summary=summary)
1392        notify_ticket_created(self.env, ticket)
1393        message = smtpd.get_message()
1394        headers, body = parse_smtp_message(message)
1395        self.assertEqual('[TracTest] #1: =\u200b?utf-8?q?e_?=',
1396                         headers['Subject'])
1397        self.assertIn('\nSubject: [TracTest] #1: '
1398                      '=?utf-8?b?PeKAiz91dGYtOD9xP2VfPz0=?=', message)
1399
1400    def test_mail_headers(self):
1401        def validates(headers):
1402            self.assertEqual('http://localhost/project.url',
1403                             headers.get('X-URL'))
1404            self.assertEqual('ticket', headers.get('X-Trac-Realm'))
1405            self.assertEqual(str(ticket.id), headers.get('X-Trac-Ticket-ID'))
1406
1407        when = datetime(2015, 1, 1, tzinfo=utc)
1408        ticket = insert_ticket(self.env, reporter='joeuser', summary='Summary',
1409                               when=when)
1410        notify_ticket_created(self.env, ticket)
1411        headers, body = parse_smtp_message(smtpd.get_message())
1412        validates(headers)
1413        self.assertEqual('http://localhost/trac/ticket/%d' % ticket.id,
1414                         headers.get('X-Trac-Ticket-URL'))
1415
1416        ticket.save_changes(comment='New comment 1',
1417                            when=when + timedelta(days=1))
1418        notify_ticket_changed(self.env, ticket)
1419        headers, body = parse_smtp_message(smtpd.get_message())
1420        validates(headers)
1421        self.assertEqual('http://localhost/trac/ticket/%d#comment:1' %
1422                         ticket.id, headers.get('X-Trac-Ticket-URL'))
1423
1424        ticket.save_changes(comment='Reply to comment:1', replyto='1',
1425                            when=when + timedelta(days=2))
1426        notify_ticket_changed(self.env, ticket)
1427        headers, body = parse_smtp_message(smtpd.get_message())
1428        validates(headers)
1429        self.assertEqual('http://localhost/trac/ticket/%d#comment:2' %
1430                         ticket.id, headers.get('X-Trac-Ticket-URL'))
1431
1432    def test_property_change_author_is_obfuscated(self):
1433        ticket = self._insert_ticket(owner='user1@d.com',
1434                                     reporter='user2@d.com',
1435                                     cc='user3@d.com, user4@d.com')
1436        ticket['owner'] = 'user2@d.com'
1437        ticket['reporter'] = 'user1@d.com'
1438        ticket['cc'] = 'user4@d.com'
1439        ticket.save_changes('user0@d.com', "The comment")
1440
1441        notify_ticket_changed(self.env, ticket)
1442        message = smtpd.get_message()
1443        body = parse_smtp_message(message)[1]
1444
1445        self.assertIn('Changes (by user0@…)', body)
1446        self.assertIn('* owner:  user1@… => user2@…\n', body)
1447        self.assertIn('* reporter:  user2@… => user1@…\n', body)
1448        self.assertIn('* cc: user3@… (removed)\n', body)
1449
1450    def test_comment_change_author_is_obfuscated(self):
1451        ticket = self._insert_ticket()
1452        ticket.save_changes('user@d.com', "The comment")
1453
1454        notify_ticket_changed(self.env, ticket)
1455        message = smtpd.get_message()
1456        body = parse_smtp_message(message)[1]
1457
1458        self.assertIn('Comment (by user@…)', body)
1459
1460    def test_property_change_author_is_not_obfuscated(self):
1461        self.env.config.set('trac', 'show_email_addresses', True)
1462        self.env.config.set('trac', 'show_full_names', False)
1463        ticket = self._insert_ticket(owner='user1@d.com',
1464                                     reporter='user2@d.com',
1465                                     cc='user3@d.com, user4@d.com')
1466        ticket['owner'] = 'user2@d.com'
1467        ticket['reporter'] = 'user1@d.com'
1468        ticket['cc'] = 'user4@d.com'
1469        ticket.save_changes('user0@d.com', "The comment")
1470
1471        notify_ticket_changed(self.env, ticket)
1472        message = smtpd.get_message()
1473        body = parse_smtp_message(message)[1]
1474
1475        self.assertIn('Changes (by user0@d.com)', body)
1476        self.assertIn('* owner:  user1@d.com => user2@d.com\n', body)
1477        self.assertIn('* reporter:  user2@d.com => user1@d.com\n', body)
1478        self.assertIn('* cc: user3@d.com (removed)\n', body)
1479
1480    def test_comment_author_is_not_obfuscated(self):
1481        self.env.config.set('trac', 'show_email_addresses', True)
1482        self.env.config.set('trac', 'show_full_names', False)
1483        ticket = self._insert_ticket()
1484        ticket.save_changes('user@d.com', "The comment")
1485
1486        notify_ticket_changed(self.env, ticket)
1487        message = smtpd.get_message()
1488        body = parse_smtp_message(message)[1]
1489
1490        self.assertIn('Comment (by user@d.com)', body)
1491
1492    def test_property_change_author_full_name(self):
1493        self.env.config.set('trac', 'show_email_addresses', True)
1494        self.env.insert_users([
1495            ('user0', 'Ußęr0', 'user0@d.org'),
1496            ('user1', 'Ußęr1', 'user1@d.org'),
1497            ('user2', 'Ußęr2', 'user2@d.org'),
1498            ('user3', 'Ußęr3', 'user3@d.org'),
1499            ('user4', 'Ußęr4', 'user4@d.org'),
1500        ])
1501        ticket = self._insert_ticket(owner='user1', reporter='user2',
1502                                     cc='user3, user4')
1503        ticket['owner'] = 'user2'
1504        ticket['reporter'] = 'user1'
1505        ticket['cc'] = 'user4'
1506        ticket.save_changes('user0', "The comment")
1507
1508        notify_ticket_changed(self.env, ticket)
1509        message = smtpd.get_message()
1510        body = parse_smtp_message(message)[1]
1511
1512        self.assertIn('Changes (by Ußęr0)', body)
1513        self.assertIn('* owner:  Ußęr1 => Ußęr2\n', body)
1514        self.assertIn('* reporter:  Ußęr2 => Ußęr1\n', body)
1515        self.assertIn('* cc: Ußęr3 (removed)\n', body)
1516
1517    def test_comment_author_full_name(self):
1518        self.env.config.set('trac', 'show_email_addresses', True)
1519        self.env.insert_users([
1520            ('user', 'Thę Ußęr', 'user@domain.org')
1521        ])
1522        ticket = self._insert_ticket()
1523        ticket.save_changes('user', "The comment")
1524
1525        notify_ticket_changed(self.env, ticket)
1526        message = smtpd.get_message()
1527        body = parse_smtp_message(message)[1]
1528
1529        self.assertIn('Comment (by Thę Ußęr)', body)
1530
1531
1532class FormatSubjectTestCase(unittest.TestCase):
1533
1534    custom_template = """\
1535${prefix} (${
1536   'new' if not changes
1537   else ticket.resolution if ticket.status == 'closed'
1538   else ticket.status if 'status' in changes.fields
1539   else 'commented' if 'comment' in changes.fields
1540                       and changes.fields['comment']['new']
1541   else 'updated'
1542}) #${ticket.id}: ${summary}"""
1543
1544
1545    def setUp(self):
1546        self.env = EnvironmentStub()
1547        TicketNotificationSystem(self.env).environment_created()
1548        self.env.config.set('project', 'name', 'TracTest')
1549        self.env.config.set('notification', 'smtp_port', str(SMTP_TEST_PORT))
1550        self.env.config.set('notification', 'smtp_server', 'localhost')
1551        self.env.config.set('notification', 'smtp_enabled', 'true')
1552
1553    def tearDown(self):
1554        smtpd.cleanup()
1555        self.env.reset_db()
1556
1557    def _insert_ticket(self):
1558        return insert_ticket(self.env, reporter='user@domain.com',
1559                             summary='The summary',
1560                             description='The description')
1561
1562    def test_format_subject_new_ticket(self):
1563        ticket = self._insert_ticket()
1564
1565        notify_ticket_created(self.env, ticket)
1566        message = smtpd.get_message()
1567        headers, body = parse_smtp_message(message)
1568
1569        self.assertEqual('[TracTest] #1: The summary', headers['Subject'])
1570
1571    def test_format_subject_ticket_change(self):
1572        ticket = self._insert_ticket()
1573        ticket['description'] = 'The changed description'
1574        ticket.save_changes(author='user@domain.com')
1575
1576        notify_ticket_changed(self.env, ticket)
1577        message = smtpd.get_message()
1578        headers, body = parse_smtp_message(message)
1579
1580        self.assertEqual('Re: [TracTest] #1: The summary', headers['Subject'])
1581
1582    def test_format_subject_ticket_summary_changed(self):
1583        ticket = self._insert_ticket()
1584        ticket['summary'] = 'The changed summary'
1585        ticket.save_changes(author='user@domain.com')
1586
1587        notify_ticket_changed(self.env, ticket)
1588        message = smtpd.get_message()
1589        headers, body = parse_smtp_message(message)
1590
1591        self.assertEqual('Re: [TracTest] #1: The changed summary '
1592                         '(was: The summary)', headers['Subject'])
1593
1594    def test_format_subject_custom_template_new_ticket(self):
1595        """Format subject with a custom template for a new ticket."""
1596        ticket = self._insert_ticket()
1597        self.env.config.set('notification', 'ticket_subject_template',
1598                            self.custom_template)
1599
1600        notify_ticket_created(self.env, ticket)
1601        message = smtpd.get_message()
1602        headers, body = parse_smtp_message(message)
1603
1604        self.assertEqual('[TracTest] (new) #1: The summary',
1605                         headers['Subject'])
1606
1607    def test_format_subject_custom_template_changed_ticket(self):
1608        """Format subject with a custom template for a ticket with
1609        a changed property.
1610        """
1611        ticket = self._insert_ticket()
1612        ticket['description'] = 'The changed description'
1613        ticket.save_changes(author='user@domain.com')
1614        self.env.config.set('notification', 'ticket_subject_template',
1615                            self.custom_template)
1616
1617        notify_ticket_changed(self.env, ticket)
1618        message = smtpd.get_message()
1619        headers, body = parse_smtp_message(message)
1620
1621        self.assertEqual('Re: [TracTest] (updated) #1: The summary',
1622                         headers['Subject'])
1623
1624    def test_format_subject_custom_template_commented_ticket(self):
1625        """Format subject with a custom template for a ticket with
1626        a changed property and a comment.
1627        """
1628        ticket = self._insert_ticket()
1629        ticket['description'] = 'The changed description'
1630        ticket.save_changes(author='user@domain.com', comment='the comment')
1631        self.env.config.set('notification', 'ticket_subject_template',
1632                            self.custom_template)
1633
1634        notify_ticket_changed(self.env, ticket)
1635        message = smtpd.get_message()
1636        headers, body = parse_smtp_message(message)
1637
1638        self.assertEqual('Re: [TracTest] (commented) #1: The summary',
1639                         headers['Subject'])
1640
1641    def test_format_subject_custom_template_status_changed_ticket(self):
1642        """Format subject with a custom template for a ticket with
1643        changed status.
1644        """
1645        ticket = self._insert_ticket()
1646        ticket['status'] = 'accepted'
1647        ticket.save_changes(author='user@domain.com')
1648        self.env.config.set('notification', 'ticket_subject_template',
1649                            self.custom_template)
1650
1651        notify_ticket_changed(self.env, ticket)
1652        message = smtpd.get_message()
1653        headers, body = parse_smtp_message(message)
1654
1655        self.assertEqual('Re: [TracTest] (accepted) #1: The summary',
1656                         headers['Subject'])
1657
1658    def test_format_subject_custom_template_closed_ticket(self):
1659        """Format subject with a custom template for a closed ticket."""
1660        ticket = self._insert_ticket()
1661        ticket['status'] = 'closed'
1662        ticket['resolution'] = 'worksforme'
1663        ticket.save_changes(author='user@domain.com')
1664        self.env.config.set('notification', 'ticket_subject_template',
1665                            self.custom_template)
1666
1667        notify_ticket_changed(self.env, ticket)
1668        message = smtpd.get_message()
1669        headers, body = parse_smtp_message(message)
1670
1671        self.assertEqual('Re: [TracTest] (worksforme) #1: The summary',
1672                         headers['Subject'])
1673
1674    def test_format_subject_custom_template_with_hash(self):
1675        """Format subject with a custom template with leading #."""
1676        ticket = self._insert_ticket()
1677        self.env.config.set('notification', 'ticket_subject_template',
1678                            '#${ticket.id}: ${summary}')
1679
1680        notify_ticket_created(self.env, ticket)
1681        message = smtpd.get_message()
1682        headers, body = parse_smtp_message(message)
1683
1684        self.assertEqual('#1: The summary', headers['Subject'])
1685
1686    def test_format_subject_custom_template_with_double_hash(self):
1687        """Format subject with a custom template with leading ##."""
1688        ticket = self._insert_ticket()
1689        self.env.config.set('notification', 'ticket_subject_template',
1690                            '##${prefix}## #${ticket.id}: ${summary}')
1691
1692        notify_ticket_created(self.env, ticket)
1693        message = smtpd.get_message()
1694        headers, body = parse_smtp_message(message)
1695
1696        self.assertEqual('##[TracTest]## #1: The summary', headers['Subject'])
1697
1698
1699class AttachmentNotificationTestCase(unittest.TestCase):
1700
1701    def setUp(self):
1702        self.env = EnvironmentStub(default_data=True, path=mkdtemp())
1703        config_smtp(self.env)
1704        config_subscriber(self.env, reporter=True)
1705
1706    def tearDown(self):
1707        """Signal the notification test suite that a test is over"""
1708        smtpd.cleanup()
1709        self.env.reset_db_and_disk()
1710
1711    def _insert_attachment(self, author):
1712        ticket = insert_ticket(self.env, summary='Ticket summary',
1713                               reporter=author)
1714        attachment = Attachment(self.env, 'ticket', ticket.id)
1715        attachment.description = "The attachment description"
1716        attachment.author = author
1717        attachment.insert('foo.txt', io.BytesIO(), 1)
1718        return attachment
1719
1720    def test_ticket_notify_attachment_enabled_attachment_added(self):
1721        self._insert_attachment('user@example.com')
1722
1723        message = smtpd.get_message()
1724        headers, body = parse_smtp_message(message)
1725
1726        self.assertIn("Re: [TracTest] #1: Ticket summary", headers['Subject'])
1727        self.assertIn(" * Attachment \"foo.txt\" added", body)
1728        self.assertIn("The attachment description", body)
1729
1730    def test_ticket_notify_attachment_enabled_attachment_removed(self):
1731        attachment = self._insert_attachment('user@example.com')
1732        attachment.delete()
1733
1734        message = smtpd.get_message()
1735        headers, body = parse_smtp_message(message)
1736
1737        self.assertIn("Re: [TracTest] #1: Ticket summary", headers['Subject'])
1738        self.assertIn(" * Attachment \"foo.txt\" removed", body)
1739        self.assertIn("The attachment description", body)
1740
1741    def test_author_is_obfuscated(self):
1742        self.env.config.set('trac', 'show_email_addresses', False)
1743        self.env.config.set('trac', 'show_full_names', False)
1744        self._insert_attachment('user@example.com')
1745
1746        message = smtpd.get_message()
1747        body = parse_smtp_message(message)[1]
1748
1749        self.assertIn('Changes (by user@…)', body)
1750
1751    def test_author_is_not_obfuscated(self):
1752        self.env.config.set('trac', 'show_email_addresses', True)
1753        self.env.config.set('trac', 'show_full_names', False)
1754        self._insert_attachment('user@example.com')
1755
1756        message = smtpd.get_message()
1757        body = parse_smtp_message(message)[1]
1758
1759        self.assertIn('Changes (by user@example.com)', body)
1760
1761    def test_author_full_name(self):
1762        self.env.config.set('trac', 'show_email_addresses', True)
1763        self.env.insert_users([
1764            ('user', 'Thę Ußęr', 'user@domain.org')
1765        ])
1766        self._insert_attachment('user')
1767
1768        message = smtpd.get_message()
1769        body = parse_smtp_message(message)[1]
1770
1771        self.assertIn('Changes (by Thę Ußęr)', body)
1772
1773
1774class BatchTicketNotificationTestCase(unittest.TestCase):
1775
1776    def setUp(self):
1777        self.env = EnvironmentStub(default_data=True, path=mkdtemp())
1778        config_smtp(self.env)
1779        self.env.config.set('project', 'url', 'http://localhost/project.url')
1780
1781        self.tktids = []
1782        with self.env.db_transaction as db:
1783            for n in range(2):
1784                for priority in ('', 'blah', 'blocker', 'critical', 'major',
1785                                 'minor', 'trivial'):
1786                    idx = len(self.tktids)
1787                    owner = 'owner@example.org' if idx == 0 else 'anonymous'
1788                    reporter = 'reporter@example.org' \
1789                               if idx == 1 else 'anonymous'
1790                    cc = 'cc1@example.org, cc2@example.org' if idx == 2 else ''
1791                    when = datetime(2001, 7, 12, 12, 34, idx, 0, utc)
1792                    ticket = insert_ticket(self.env, summary='Summary %s:%d'
1793                                                             % (priority, idx),
1794                                           priority=priority, owner=owner,
1795                                           reporter=reporter, cc=cc, when=when)
1796                    self.tktids.append(ticket.id)
1797        self.tktids.reverse()
1798        config_subscriber(self.env, updater=True, reporter=True)
1799
1800    def tearDown(self):
1801        smtpd.cleanup()
1802        self.env.reset_db_and_disk()
1803
1804    def _change_tickets(self, author, new_values, comment, when=None):
1805        if when is None:
1806            when = datetime(2016, 8, 21, 12, 34, 56, 987654, utc)
1807        with self.env.db_transaction:
1808            for tktid in self.tktids:
1809                t = Ticket(self.env, tktid)
1810                for name, value in new_values.items():
1811                    t[name] = value
1812                t.save_changes(author, comment, when=when)
1813        return BatchTicketChangeEvent(self.tktids, when, author, comment,
1814                                      new_values, 'leave')
1815    def _notify(self, event):
1816        smtpd.cleanup()
1817        NotificationSystem(self.env).notify(event)
1818        recipients = sorted(smtpd.get_recipients())
1819        sender = smtpd.get_sender()
1820        message = smtpd.get_message()
1821        headers, body = parse_smtp_message(message)
1822        body = body.splitlines()
1823        return recipients, sender, message, headers, body
1824
1825    def test_batchmod_notify(self):
1826        self.assertEqual(1, min(self.tktids))
1827        self.assertEqual(14, max(self.tktids))
1828        new_values = {'milestone': 'milestone1'}
1829        author = 'author@example.org'
1830        comment = 'batch-modify'
1831        when = datetime(2016, 8, 21, 12, 34, 56, 987654, utc)
1832        event = self._change_tickets(author, new_values, comment, when)
1833
1834        recipients, sender, message, headers, body = self._notify(event)
1835
1836        self.assertEqual(['author@example.org', 'cc1@example.org',
1837                          'cc2@example.org', 'reporter@example.org'],
1838                         recipients)
1839        self.assertEqual('trac@localhost', sender)
1840        self.assertIn('Date', headers)
1841        self.assertEqual('[TracTest] Batch modify: #3, #10, #4, #11, #5, #12, '
1842                         '#6, #13, #7, #14, ...', headers['Subject'])
1843        self.assertEqual('TracTest <trac@localhost>', headers['From'])
1844        self.assertEqual('<078.0b9de298f9080302285a0e333c75dd47@localhost>',
1845                         headers['Message-ID'])
1846        self.assertIn('Batch modification to #3, #10, #4, #11, #5, #12, #6, '
1847                      '#13, #7, #14, #1, #2, #8, #9 by author@example.org:',
1848                      body)
1849        self.assertIn('-- ', body)
1850        self.assertIn('Tickets URL: <http://example.org/trac.cgi/query?id=3'
1851                      '%2C10%2C4%2C11%2C5%2C12%2C6%2C13%2C7%2C14%2C1%2C2%2C8'
1852                      '%2C9>', body)
1853
1854    def test_format_subject_custom_template_with_hash(self):
1855        """Format subject with a custom template with leading #."""
1856        self.env.config.set('notification', 'batch_subject_template',
1857                            '#${prefix}# Batch modify')
1858
1859        event = self._change_tickets(
1860            author='author@example.org',
1861            new_values={'milestone': 'milestone1'},
1862            comment='batch-modify')
1863
1864        recipients, sender, message, headers, body = self._notify(event)
1865
1866        self.assertEqual('#[TracTest]# Batch modify', headers['Subject'])
1867
1868    def test_format_subject_custom_template_with_double_hash(self):
1869        """Format subject with a custom template with leading ##."""
1870        self.env.config.set('notification', 'batch_subject_template',
1871                            '##${prefix}## Batch modify')
1872
1873        event = self._change_tickets(
1874            author='author@example.org',
1875            new_values={'milestone': 'milestone1'},
1876            comment='batch-modify')
1877
1878        recipients, sender, message, headers, body = self._notify(event)
1879
1880        self.assertEqual('##[TracTest]## Batch modify', headers['Subject'])
1881
1882
1883def test_suite():
1884    suite = unittest.TestSuite()
1885    suite.addTest(unittest.makeSuite(RecipientTestCase))
1886    suite.addTest(unittest.makeSuite(NotificationTestCase))
1887    suite.addTest(unittest.makeSuite(FormatSubjectTestCase))
1888    suite.addTest(unittest.makeSuite(AttachmentNotificationTestCase))
1889    suite.addTest(unittest.makeSuite(BatchTicketNotificationTestCase))
1890    return suite
1891
1892
1893if __name__ == '__main__':
1894    unittest.main(defaultTest='test_suite')
1895