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