1# -*- coding: utf-8 -*- 2# Part of Odoo. See LICENSE file for full copyright and licensing details. 3 4 5from unittest.mock import patch 6import email.policy 7import email.message 8import re 9import threading 10 11from odoo.tests.common import BaseCase, SavepointCase, TransactionCase 12from odoo.tools import ( 13 is_html_empty, html_sanitize, append_content_to_html, plaintext2html, 14 email_split, 15 misc, formataddr, 16 prepend_html_content, 17) 18 19from . import test_mail_examples 20 21 22class TestSanitizer(BaseCase): 23 """ Test the html sanitizer that filters html to remove unwanted attributes """ 24 25 def test_basic_sanitizer(self): 26 cases = [ 27 ("yop", "<p>yop</p>"), # simple 28 ("lala<p>yop</p>xxx", "<p>lala</p><p>yop</p>xxx"), # trailing text 29 ("Merci à l'intérêt pour notre produit.nous vous contacterons bientôt. Merci", 30 u"<p>Merci à l'intérêt pour notre produit.nous vous contacterons bientôt. Merci</p>"), # unicode 31 ] 32 for content, expected in cases: 33 html = html_sanitize(content) 34 self.assertEqual(html, expected, 'html_sanitize is broken') 35 36 def test_mako(self): 37 cases = [ 38 ('''<p>Some text</p> 39<% set signup_url = object.get_signup_url() %> 40% if signup_url: 41<p> 42 You can access this document and pay online via our Customer Portal: 43</p>''', '''<p>Some text</p> 44<% set signup_url = object.get_signup_url() %> 45% if signup_url: 46<p> 47 You can access this document and pay online via our Customer Portal: 48</p>''') 49 ] 50 for content, expected in cases: 51 html = html_sanitize(content, silent=False) 52 self.assertEqual(html, expected, 'html_sanitize: broken mako management') 53 54 def test_evil_malicious_code(self): 55 # taken from https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Tests 56 cases = [ 57 ("<IMG SRC=javascript:alert('XSS')>"), # no quotes and semicolons 58 ("<IMG SRC=javascript:alert('XSS')>"), # UTF-8 Unicode encoding 59 ("<IMG SRC=javascript:alert('XSS')>"), # hex encoding 60 ("<IMG SRC=\"jav
ascript:alert('XSS');\">"), # embedded carriage return 61 ("<IMG SRC=\"jav
ascript:alert('XSS');\">"), # embedded newline 62 ("<IMG SRC=\"jav ascript:alert('XSS');\">"), # embedded tab 63 ("<IMG SRC=\"jav	ascript:alert('XSS');\">"), # embedded encoded tab 64 ("<IMG SRC=\"  javascript:alert('XSS');\">"), # spaces and meta-characters 65 ("<IMG SRC=\"javascript:alert('XSS')\""), # half-open html 66 ("<IMG \"\"\"><SCRIPT>alert(\"XSS\")</SCRIPT>\">"), # malformed tag 67 ("<SCRIPT/XSS SRC=\"http://ha.ckers.org/xss.js\"></SCRIPT>"), # non-alpha-non-digits 68 ("<SCRIPT/SRC=\"http://ha.ckers.org/xss.js\"></SCRIPT>"), # non-alpha-non-digits 69 ("<<SCRIPT>alert(\"XSS\");//<</SCRIPT>"), # extraneous open brackets 70 ("<SCRIPT SRC=http://ha.ckers.org/xss.js?< B >"), # non-closing script tags 71 ("<INPUT TYPE=\"IMAGE\" SRC=\"javascript:alert('XSS');\">"), # input image 72 ("<BODY BACKGROUND=\"javascript:alert('XSS')\">"), # body image 73 ("<IMG DYNSRC=\"javascript:alert('XSS')\">"), # img dynsrc 74 ("<IMG LOWSRC=\"javascript:alert('XSS')\">"), # img lowsrc 75 ("<TABLE BACKGROUND=\"javascript:alert('XSS')\">"), # table 76 ("<TABLE><TD BACKGROUND=\"javascript:alert('XSS')\">"), # td 77 ("<DIV STYLE=\"background-image: url(javascript:alert('XSS'))\">"), # div background 78 ("<DIV STYLE=\"background-image:\0075\0072\006C\0028'\006a\0061\0076\0061\0073\0063\0072\0069\0070\0074\003a\0061\006c\0065\0072\0074\0028.1027\0058.1053\0053\0027\0029'\0029\">"), # div background with unicoded exploit 79 ("<DIV STYLE=\"background-image: url(javascript:alert('XSS'))\">"), # div background + extra characters 80 ("<IMG SRC='vbscript:msgbox(\"XSS\")'>"), # VBscrip in an image 81 ("<BODY ONLOAD=alert('XSS')>"), # event handler 82 ("<BR SIZE=\"&{alert('XSS')}\>"), # & javascript includes 83 ("<LINK REL=\"stylesheet\" HREF=\"javascript:alert('XSS');\">"), # style sheet 84 ("<LINK REL=\"stylesheet\" HREF=\"http://ha.ckers.org/xss.css\">"), # remote style sheet 85 ("<STYLE>@import'http://ha.ckers.org/xss.css';</STYLE>"), # remote style sheet 2 86 ("<META HTTP-EQUIV=\"Link\" Content=\"<http://ha.ckers.org/xss.css>; REL=stylesheet\">"), # remote style sheet 3 87 ("<STYLE>BODY{-moz-binding:url(\"http://ha.ckers.org/xssmoz.xml#xss\")}</STYLE>"), # remote style sheet 4 88 ("<IMG STYLE=\"xss:expr/*XSS*/ession(alert('XSS'))\">"), # style attribute using a comment to break up expression 89 ] 90 for content in cases: 91 html = html_sanitize(content) 92 self.assertNotIn('javascript', html, 'html_sanitize did not remove a malicious javascript') 93 self.assertTrue('ha.ckers.org' not in html or 'http://ha.ckers.org/xss.css' in html, 'html_sanitize did not remove a malicious code in %s (%s)' % (content, html)) 94 95 content = "<!--[if gte IE 4]><SCRIPT>alert('XSS');</SCRIPT><![endif]-->" # down-level hidden block 96 self.assertEqual(html_sanitize(content, silent=False), '') 97 98 def test_html(self): 99 sanitized_html = html_sanitize(test_mail_examples.MISC_HTML_SOURCE) 100 for tag in ['<div', '<b', '<i', '<u', '<strike', '<li', '<blockquote', '<a href']: 101 self.assertIn(tag, sanitized_html, 'html_sanitize stripped too much of original html') 102 for attr in ['javascript']: 103 self.assertNotIn(attr, sanitized_html, 'html_sanitize did not remove enough unwanted attributes') 104 105 def test_sanitize_unescape_emails(self): 106 not_emails = [ 107 '<blockquote cite="mid:CAEJSRZvWvud8c6Qp=wfNG6O1+wK3i_jb33qVrF7XyrgPNjnyUA@mail.gmail.com" type="cite">cat</blockquote>', 108 '<img alt="@github-login" class="avatar" src="/web/image/pi" height="36" width="36">'] 109 for not_email in not_emails: 110 sanitized = html_sanitize(not_email) 111 left_part = not_email.split('>')[0] # take only left part, as the sanitizer could add data information on node 112 self.assertNotIn(misc.html_escape(not_email), sanitized, 'html_sanitize stripped emails of original html') 113 self.assertIn(left_part, sanitized) 114 115 def test_style_parsing(self): 116 test_data = [ 117 ( 118 '<span style="position: fixed; top: 0px; left: 50px; width: 40%; height: 50%; background-color: red;">Coin coin </span>', 119 ['background-color:red', 'Coin coin'], 120 ['position', 'top', 'left'] 121 ), ( 122 """<div style='before: "Email Address; coincoin cheval: lapin"; 123 font-size: 30px; max-width: 100%; after: "Not sure 124 125 this; means: anything ?#ùµ" 126 ; some-property: 2px; top: 3'>youplaboum</div>""", 127 ['font-size:30px', 'youplaboum'], 128 ['some-property', 'top', 'cheval'] 129 ), ( 130 '<span style="width">Coincoin</span>', 131 [], 132 ['width'] 133 ) 134 ] 135 136 for test, in_lst, out_lst in test_data: 137 new_html = html_sanitize(test, sanitize_attributes=False, sanitize_style=True, strip_style=False, strip_classes=False) 138 for text in in_lst: 139 self.assertIn(text, new_html) 140 for text in out_lst: 141 self.assertNotIn(text, new_html) 142 143 # style should not be sanitized if removed 144 new_html = html_sanitize(test_data[0][0], sanitize_attributes=False, strip_style=True, strip_classes=False) 145 self.assertEqual(new_html, u'<span>Coin coin </span>') 146 147 def test_style_class(self): 148 html = html_sanitize(test_mail_examples.REMOVE_CLASS, sanitize_attributes=True, sanitize_style=True, strip_classes=True) 149 for ext in test_mail_examples.REMOVE_CLASS_IN: 150 self.assertIn(ext, html) 151 for ext in test_mail_examples.REMOVE_CLASS_OUT: 152 self.assertNotIn(ext, html,) 153 154 def test_style_class_only(self): 155 html = html_sanitize(test_mail_examples.REMOVE_CLASS, sanitize_attributes=False, sanitize_style=True, strip_classes=True) 156 for ext in test_mail_examples.REMOVE_CLASS_IN: 157 self.assertIn(ext, html) 158 for ext in test_mail_examples.REMOVE_CLASS_OUT: 159 self.assertNotIn(ext, html,) 160 161 def test_edi_source(self): 162 html = html_sanitize(test_mail_examples.EDI_LIKE_HTML_SOURCE) 163 self.assertIn( 164 'font-family: \'Lucida Grande\', Ubuntu, Arial, Verdana, sans-serif;', html, 165 'html_sanitize removed valid styling') 166 self.assertIn( 167 'src="https://www.paypal.com/en_US/i/btn/btn_paynowCC_LG.gif"', html, 168 'html_sanitize removed valid img') 169 self.assertNotIn('</body></html>', html, 'html_sanitize did not remove extra closing tags') 170 171 def test_quote_blockquote(self): 172 html = html_sanitize(test_mail_examples.QUOTE_BLOCKQUOTE) 173 for ext in test_mail_examples.QUOTE_BLOCKQUOTE_IN: 174 self.assertIn(ext, html) 175 for ext in test_mail_examples.QUOTE_BLOCKQUOTE_OUT: 176 self.assertIn(u'<span data-o-mail-quote="1">%s' % misc.html_escape(ext), html) 177 178 def test_quote_thunderbird(self): 179 html = html_sanitize(test_mail_examples.QUOTE_THUNDERBIRD_1) 180 for ext in test_mail_examples.QUOTE_THUNDERBIRD_1_IN: 181 self.assertIn(ext, html) 182 for ext in test_mail_examples.QUOTE_THUNDERBIRD_1_OUT: 183 self.assertIn(u'<span data-o-mail-quote="1">%s</span>' % misc.html_escape(ext), html) 184 185 def test_quote_hotmail_html(self): 186 html = html_sanitize(test_mail_examples.QUOTE_HOTMAIL_HTML) 187 for ext in test_mail_examples.QUOTE_HOTMAIL_HTML_IN: 188 self.assertIn(ext, html) 189 for ext in test_mail_examples.QUOTE_HOTMAIL_HTML_OUT: 190 self.assertIn(ext, html) 191 192 html = html_sanitize(test_mail_examples.HOTMAIL_1) 193 for ext in test_mail_examples.HOTMAIL_1_IN: 194 self.assertIn(ext, html) 195 for ext in test_mail_examples.HOTMAIL_1_OUT: 196 self.assertIn(ext, html) 197 198 def test_quote_thunderbird_html(self): 199 html = html_sanitize(test_mail_examples.QUOTE_THUNDERBIRD_HTML) 200 for ext in test_mail_examples.QUOTE_THUNDERBIRD_HTML_IN: 201 self.assertIn(ext, html) 202 for ext in test_mail_examples.QUOTE_THUNDERBIRD_HTML_OUT: 203 self.assertIn(ext, html) 204 205 def test_quote_basic_text(self): 206 test_data = [ 207 ( 208 """This is Sparta!\n--\nAdministrator\n+9988776655""", 209 ['This is Sparta!'], 210 ['\n--\nAdministrator\n+9988776655'] 211 ), ( 212 """<p>This is Sparta!\n--\nAdministrator</p>""", 213 [], 214 ['\n--\nAdministrator'] 215 ), ( 216 """<p>This is Sparta!<br/>--<br>Administrator</p>""", 217 ['This is Sparta!'], 218 [] 219 ), ( 220 """This is Sparta!\n>Ah bon ?\nCertes\n> Chouette !\nClair""", 221 ['This is Sparta!', 'Certes', 'Clair'], 222 ['\n>Ah bon ?', '\n> Chouette !'] 223 ) 224 ] 225 for test, in_lst, out_lst in test_data: 226 new_html = html_sanitize(test) 227 for text in in_lst: 228 self.assertIn(text, new_html) 229 for text in out_lst: 230 self.assertIn(u'<span data-o-mail-quote="1">%s</span>' % misc.html_escape(text), new_html) 231 232 def test_quote_signature(self): 233 test_data = [ 234 ( 235 """<div>Hello<pre>--<br />Administrator</pre></div>""", 236 ["<pre data-o-mail-quote=\"1\">--", "<br data-o-mail-quote=\"1\">"], 237 ) 238 ] 239 for test, in_lst in test_data: 240 new_html = html_sanitize(test) 241 for text in in_lst: 242 self.assertIn(text, new_html) 243 244 def test_quote_gmail(self): 245 html = html_sanitize(test_mail_examples.GMAIL_1) 246 for ext in test_mail_examples.GMAIL_1_IN: 247 self.assertIn(ext, html) 248 for ext in test_mail_examples.GMAIL_1_OUT: 249 self.assertIn(u'<span data-o-mail-quote="1">%s</span>' % misc.html_escape(ext), html) 250 251 def test_quote_text(self): 252 html = html_sanitize(test_mail_examples.TEXT_1) 253 for ext in test_mail_examples.TEXT_1_IN: 254 self.assertIn(ext, html) 255 for ext in test_mail_examples.TEXT_1_OUT: 256 self.assertIn(u'<span data-o-mail-quote="1">%s</span>' % misc.html_escape(ext), html) 257 258 html = html_sanitize(test_mail_examples.TEXT_2) 259 for ext in test_mail_examples.TEXT_2_IN: 260 self.assertIn(ext, html) 261 for ext in test_mail_examples.TEXT_2_OUT: 262 self.assertIn(u'<span data-o-mail-quote="1">%s</span>' % misc.html_escape(ext), html) 263 264 def test_quote_bugs(self): 265 html = html_sanitize(test_mail_examples.BUG1) 266 for ext in test_mail_examples.BUG_1_IN: 267 self.assertIn(ext, html) 268 for ext in test_mail_examples.BUG_1_OUT: 269 self.assertIn(u'<span data-o-mail-quote="1">%s</span>' % misc.html_escape(ext), html) 270 271 def test_misc(self): 272 # False / void should not crash 273 html = html_sanitize('') 274 self.assertEqual(html, '') 275 html = html_sanitize(False) 276 self.assertEqual(html, False) 277 278 # Message with xml and doctype tags don't crash 279 html = html_sanitize(u'<?xml version="1.0" encoding="iso-8859-1"?>\n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"\n "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\n<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">\n <head>\n <title>404 - Not Found</title>\n </head>\n <body>\n <h1>404 - Not Found</h1>\n </body>\n</html>\n') 280 self.assertNotIn('encoding', html) 281 self.assertNotIn('<title>404 - Not Found</title>', html) 282 self.assertIn('<h1>404 - Not Found</h1>', html) 283 284 def test_cid_with_at(self): 285 img_tag = '<img src="@">' 286 sanitized = html_sanitize(img_tag, sanitize_tags=False, strip_classes=True) 287 self.assertEqual(img_tag, sanitized, "img with can have cid containing @ and shouldn't be escaped") 288 289 # ms office is currently not supported, have to find a way to support it 290 # def test_30_email_msoffice(self): 291 # new_html = html_sanitize(test_mail_examples.MSOFFICE_1, remove=True) 292 # for ext in test_mail_examples.MSOFFICE_1_IN: 293 # self.assertIn(ext, new_html) 294 # for ext in test_mail_examples.MSOFFICE_1_OUT: 295 # self.assertNotIn(ext, new_html) 296 297 298class TestHtmlTools(BaseCase): 299 """ Test some of our generic utility functions about html """ 300 301 def test_plaintext2html(self): 302 cases = [ 303 ("First \nSecond \nThird\n \nParagraph\n\r--\nSignature paragraph", 'div', 304 "<div><p>First <br/>Second <br/>Third</p><p>Paragraph</p><p>--<br/>Signature paragraph</p></div>"), 305 ("First<p>It should be escaped</p>\nSignature", False, 306 "<p>First<p>It should be escaped</p><br/>Signature</p>") 307 ] 308 for content, container_tag, expected in cases: 309 html = plaintext2html(content, container_tag) 310 self.assertEqual(html, expected, 'plaintext2html is broken') 311 312 def test_append_to_html(self): 313 test_samples = [ 314 ('<!DOCTYPE...><HTML encoding="blah">some <b>content</b></HtMl>', '--\nYours truly', True, True, False, 315 '<!DOCTYPE...><html encoding="blah">some <b>content</b>\n<pre>--\nYours truly</pre>\n</html>'), 316 ('<!DOCTYPE...><HTML encoding="blah">some <b>content</b></HtMl>', '--\nYours truly', True, False, False, 317 '<!DOCTYPE...><html encoding="blah">some <b>content</b>\n<p>--<br/>Yours truly</p>\n</html>'), 318 ('<html><body>some <b>content</b></body></html>', '--\nYours & <truly>', True, True, False, 319 '<html><body>some <b>content</b>\n<pre>--\nYours & <truly></pre>\n</body></html>'), 320 ('<html><body>some <b>content</b></body></html>', '<!DOCTYPE...>\n<html><body>\n<p>--</p>\n<p>Yours truly</p>\n</body>\n</html>', False, False, False, 321 '<html><body>some <b>content</b>\n\n\n<p>--</p>\n<p>Yours truly</p>\n\n\n</body></html>'), 322 ] 323 for html, content, plaintext_flag, preserve_flag, container_tag, expected in test_samples: 324 self.assertEqual(append_content_to_html(html, content, plaintext_flag, preserve_flag, container_tag), expected, 'append_content_to_html is broken') 325 326 def test_is_html_empty(self): 327 void_strings_samples = ['', False, ' '] 328 for content in void_strings_samples: 329 self.assertTrue(is_html_empty(content)) 330 331 void_html_samples = ['<p><br></p>', '<p><br> </p>', '<p><br /></p >'] 332 for content in void_html_samples: 333 self.assertTrue(is_html_empty(content), 'Failed with %s' % content) 334 335 valid_html_samples = ['<p><br>1</p>', '<p>1<br > </p>'] 336 for content in valid_html_samples: 337 self.assertFalse(is_html_empty(content)) 338 339 def test_prepend_html_content(self): 340 body = """ 341 <html> 342 <body> 343 <div>test</div> 344 </body> 345 </html> 346 """ 347 348 content = "<span>content</span>" 349 350 result = prepend_html_content(body, content) 351 result = re.sub(r'[\s\t]', '', result) 352 self.assertEqual(result, "<html><body><span>content</span><div>test</div></body></html>") 353 354 body = "<div>test</div>" 355 content = "<span>content</span>" 356 357 result = prepend_html_content(body, content) 358 result = re.sub(r'[\s\t]', '', result) 359 self.assertEqual(result, "<span>content</span><div>test</div>") 360 361 body = """ 362 <body> 363 <div>test</div> 364 </body> 365 """ 366 367 result = prepend_html_content(body, content) 368 result = re.sub(r'[\s\t]', '', result) 369 self.assertEqual(result, "<body><span>content</span><div>test</div></body>") 370 371 body = """ 372 <html> 373 <body> 374 <div>test</div> 375 </body> 376 </html> 377 """ 378 379 content = """ 380 <html> 381 <body> 382 <div>test</div> 383 </body> 384 </html> 385 """ 386 result = prepend_html_content(body, content) 387 result = re.sub(r'[\s\t]', '', result) 388 self.assertEqual(result, "<html><body><div>test</div><div>test</div></body></html>") 389 390 391class TestEmailTools(BaseCase): 392 """ Test some of our generic utility functions for emails """ 393 394 def test_email_split(self): 395 cases = [ 396 ("John <12345@gmail.com>", ['12345@gmail.com']), # regular form 397 ("d@x; 1@2", ['d@x', '1@2']), # semi-colon + extra space 398 ("'(ss)' <123@gmail.com>, 'foo' <foo@bar>", ['123@gmail.com', 'foo@bar']), # comma + single-quoting 399 ('"john@gmail.com"<johnny@gmail.com>', ['johnny@gmail.com']), # double-quoting 400 ('"<jg>" <johnny@gmail.com>', ['johnny@gmail.com']), # double-quoting with brackets 401 ] 402 for text, expected in cases: 403 self.assertEqual(email_split(text), expected, 'email_split is broken') 404 405 def test_email_formataddr(self): 406 email = 'joe@example.com' 407 email_idna = 'joe@examplé.com' 408 cases = [ 409 # (name, address), charsets expected 410 (('', email), ['ascii', 'utf-8'], 'joe@example.com'), 411 (('joe', email), ['ascii', 'utf-8'], '"joe" <joe@example.com>'), 412 (('joe doe', email), ['ascii', 'utf-8'], '"joe doe" <joe@example.com>'), 413 (('joe"doe', email), ['ascii', 'utf-8'], '"joe\\"doe" <joe@example.com>'), 414 (('joé', email), ['ascii'], '=?utf-8?b?am/DqQ==?= <joe@example.com>'), 415 (('joé', email), ['utf-8'], '"joé" <joe@example.com>'), 416 (('', email_idna), ['ascii'], 'joe@xn--exampl-gva.com'), 417 (('', email_idna), ['utf-8'], 'joe@examplé.com'), 418 (('joé', email_idna), ['ascii'], '=?utf-8?b?am/DqQ==?= <joe@xn--exampl-gva.com>'), 419 (('joé', email_idna), ['utf-8'], '"joé" <joe@examplé.com>'), 420 (('', 'joé@example.com'), ['ascii', 'utf-8'], 'joé@example.com'), 421 ] 422 423 for pair, charsets, expected in cases: 424 for charset in charsets: 425 with self.subTest(pair=pair, charset=charset): 426 self.assertEqual(formataddr(pair, charset), expected) 427 428 429class EmailConfigCase(SavepointCase): 430 @patch.dict("odoo.tools.config.options", {"email_from": "settings@example.com"}) 431 def test_default_email_from(self, *args): 432 """Email from setting is respected.""" 433 # ICP setting is more important 434 ICP = self.env["ir.config_parameter"].sudo() 435 ICP.set_param("mail.catchall.domain", "example.org") 436 ICP.set_param("mail.default.from", "icp") 437 message = self.env["ir.mail_server"].build_email( 438 False, "recipient@example.com", "Subject", 439 "The body of an email", 440 ) 441 self.assertEqual(message["From"], "icp@example.org") 442 # Without ICP, the config file/CLI setting is used 443 ICP.set_param("mail.default.from", False) 444 message = self.env["ir.mail_server"].build_email( 445 False, "recipient@example.com", "Subject", 446 "The body of an email", 447 ) 448 self.assertEqual(message["From"], "settings@example.com") 449 450 def test_email_from_rewrite(self): 451 get_email_from = self.env['ir.mail_server']._get_email_from 452 set_param = self.env['ir.config_parameter'].set_param 453 454 # Standard case, no setting 455 set_param('mail.force.smtp.from', False) 456 set_param('mail.dynamic.smtp.from', False) 457 set_param('mail.catchall.domain', 'odoo.example.com') 458 459 email_from, return_path = get_email_from('admin@test.example.com') 460 self.assertEqual(email_from, 'admin@test.example.com') 461 self.assertFalse(return_path) 462 463 email_from, return_path = get_email_from('"Admin" <admin@test.example.com>') 464 self.assertEqual(email_from, '"Admin" <admin@test.example.com>') 465 self.assertFalse(return_path) 466 467 # We always force the email FROM 468 set_param('mail.force.smtp.from', 'email_force@domain.com') 469 set_param('mail.dynamic.smtp.from', False) 470 set_param('mail.catchall.domain', 'odoo.example.com') 471 472 email_from, return_path = get_email_from('admin@test.example.com') 473 self.assertEqual(email_from, '"admin@test.example.com" <email_force@domain.com>') 474 self.assertEqual(return_path, 'email_force@domain.com') 475 476 email_from, return_path = get_email_from('"Admin" <admin@test.example.com>') 477 self.assertEqual(email_from, '"Admin (admin@test.example.com)" <email_force@domain.com>') 478 self.assertEqual(return_path, 'email_force@domain.com') 479 480 # We always force the email FROM (notification email contains a name part) 481 set_param('mail.force.smtp.from', '"Your notification bot" <email_force@domain.com>') 482 set_param('mail.dynamic.smtp.from', False) 483 set_param('mail.catchall.domain', 'odoo.example.com') 484 485 email_from, return_path = get_email_from('"Admin" <admin@test.example.com>') 486 self.assertEqual(email_from, '"Admin (admin@test.example.com)" <email_force@domain.com>', 487 msg='Should drop the name part of the forced email') 488 self.assertEqual(return_path, 'email_force@domain.com') 489 490 # We dynamically force the email FROM 491 set_param('mail.force.smtp.from', False) 492 set_param('mail.dynamic.smtp.from', 'notification@odoo.example.com') 493 set_param('mail.catchall.domain', 'odoo.example.com') 494 495 email_from, return_path = get_email_from('"Admin" <admin@test.example.com>') 496 self.assertEqual(email_from, '"Admin (admin@test.example.com)" <notification@odoo.example.com>', 497 msg='Domain is not the same as the catchall domain, we should force the email FROM') 498 self.assertEqual(return_path, 'notification@odoo.example.com') 499 500 email_from, return_path = get_email_from('"Admin" <admin@odoo.example.com>') 501 self.assertEqual(email_from, '"Admin" <admin@odoo.example.com>', 502 msg='Domain is the same as the catchall domain, we should not force the email FROM') 503 self.assertFalse(return_path) 504 505 # We dynamically force the email FROM (notification email contains a name part) 506 set_param('mail.force.smtp.from', False) 507 set_param('mail.dynamic.smtp.from', '"Your notification bot" <notification@odoo.example.com>') 508 set_param('mail.catchall.domain', 'odoo.example.com') 509 510 email_from, return_path = get_email_from('"Admin" <admin@test.example.com>') 511 self.assertEqual(email_from, '"Admin (admin@test.example.com)" <notification@odoo.example.com>', 512 msg='Domain is not the same as the catchall domain, we should force the email FROM') 513 self.assertEqual(return_path, 'notification@odoo.example.com') 514 515 email_from, return_path = get_email_from('"Admin" <admin@odoo.example.com>') 516 self.assertEqual(email_from, '"Admin" <admin@odoo.example.com>', 517 msg='Domain is the same as the catchall domain, we should not force the email FROM') 518 self.assertFalse(return_path) 519 520 521class TestEmailMessage(TransactionCase): 522 def test_as_string(self): 523 """Ensure all email sent are bpo-34424 and bpo-35805 free""" 524 525 message_truth = ( 526 r'From: .+? <joe@example\.com>\r\n' 527 r'To: .+? <joe@example\.com>\r\n' 528 r'Message-Id: <[0-9a-z.-]+@[0-9a-z.-]+>\r\n' 529 r'References: (<[0-9a-z.-]+@[0-9a-z.-]+>\s*)+\r\n' 530 r'\r\n' 531 ) 532 533 class FakeSMTP: 534 """SMTP stub""" 535 def __init__(this): 536 this.email_sent = False 537 538 # Python 3 before 3.7.4 539 def sendmail(this, smtp_from, smtp_to_list, message_str, 540 mail_options=(), rcpt_options=()): 541 this.email_sent = True 542 self.assertRegex(message_str, message_truth) 543 544 # Python 3.7.4+ 545 def send_message(this, message, smtp_from, smtp_to_list, 546 mail_options=(), rcpt_options=()): 547 message_str = message.as_string() 548 this.email_sent = True 549 self.assertRegex(message_str, message_truth) 550 551 msg = email.message.EmailMessage(policy=email.policy.SMTP) 552 msg['From'] = '"Joé Doe" <joe@example.com>' 553 msg['To'] = '"Joé Doe" <joe@example.com>' 554 555 # Message-Id & References fields longer than 77 chars (bpo-35805) 556 msg['Message-Id'] = '<929227342217024.1596730490.324691772460938-example-30661-some.reference@test-123.example.com>' 557 msg['References'] = '<345227342212345.1596730777.324691772483620-example-30453-other.reference@test-123.example.com>' 558 559 smtp = FakeSMTP() 560 self.patch(threading.currentThread(), 'testing', False) 561 self.env['ir.mail_server'].send_email(msg, smtp_session=smtp) 562 self.assertTrue(smtp.email_sent) 563