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=&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;&#97;&#108;&#101;&#114;&#116;&#40;&#39;&#88;&#83;&#83;&#39;&#41;>"),  # UTF-8 Unicode encoding
59            ("<IMG SRC=&#x6A&#x61&#x76&#x61&#x73&#x63&#x72&#x69&#x70&#x74&#x3A&#x61&#x6C&#x65&#x72&#x74&#x28&#x27&#x58&#x53&#x53&#x27&#x29>"),  # hex encoding
60            ("<IMG SRC=\"jav&#x0D;ascript:alert('XSS');\">"),  # embedded carriage return
61            ("<IMG SRC=\"jav&#x0A;ascript:alert('XSS');\">"),  # embedded newline
62            ("<IMG SRC=\"jav   ascript:alert('XSS');\">"),  # embedded tab
63            ("<IMG SRC=\"jav&#x09;ascript:alert('XSS');\">"),  # embedded encoded tab
64            ("<IMG SRC=\" &#14;  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(&#1;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&lt;p&gt;It should be escaped&lt;/p&gt;<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 &amp; &lt;truly&gt;</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