From f97bd8bd6313db276452df1e4be176fa0d2ce292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Tue, 23 Apr 2013 16:49:03 +0200 Subject: [PATCH 001/161] [REF] html_email_clean: refactoring of the algorithm, now better taking into account hotmail / msoffice emails. Added tests. bzr revid: tde@openerp.com-20130423144903-he22jz9zs6nc6dsd --- openerp/tests/test_mail.py | 229 +++++------------- openerp/tests/test_mail_examples.py | 354 ++++++++++++++++++++++++++++ openerp/tools/mail.py | 140 +++++++---- 3 files changed, 516 insertions(+), 207 deletions(-) create mode 100644 openerp/tests/test_mail_examples.py diff --git a/openerp/tests/test_mail.py b/openerp/tests/test_mail.py index ddb8fc4a195..5ec7fe24436 100644 --- a/openerp/tests/test_mail.py +++ b/openerp/tests/test_mail.py @@ -23,146 +23,9 @@ ############################################################################## import unittest2 +from . import test_mail_examples from openerp.tools import html_sanitize, html_email_clean, append_content_to_html, plaintext2html -HTML_SOURCE = """ -test1 -
-test2
-test3
-test4
-test5
-test6
  1. test9 -
  2. test10
-
-test11
-
-test12

-google -test link -""" - -EDI_LIKE_HTML_SOURCE = """
-

Hello ${object.partner_id.name},

-

A new invoice is available for you:

-

-   REFERENCES
-   Invoice number: ${object.number}
-   Invoice total: ${object.amount_total} ${object.currency_id.name}
-   Invoice date: ${object.date_invoice}
-   Order reference: ${object.origin}
-   Your contact: ${object.user_id.name} -

-
-

It is also possible to directly pay with Paypal:

- - - -
-

If you have any question, do not hesitate to contact us.

-

Thank you for choosing ${object.company_id.name or 'us'}!

-
-
-
-

- ${object.company_id.name}

-
-
- - ${object.company_id.street}
- ${object.company_id.street2}
- ${object.company_id.zip} ${object.company_id.city}
- ${object.company_id.state_id and ('%s, ' % object.company_id.state_id.name) or ''} ${object.company_id.country_id.name or ''}
-
-
- Phone:  ${object.company_id.phone} -
-
- Web : ${object.company_id.website} -
-
-
""" - -TEXT_MAIL1 = """I contact you about our meeting for tomorrow. Here is the schedule I propose: -9 AM: brainstorming about our new amazing business app -9.45 AM: summary -10 AM: meeting with Fabien to present our app -Is everything ok for you ? --- -Administrator""" - -HTML_MAIL1 = """
-I contact you about our meeting for tomorrow. Here is the schedule I propose: -
-
-
Is everything ok for you ?
""" - -GMAIL_REPLY1_SAN = """Hello,

Ok for me. I am replying directly in gmail, without signature.

Kind regards,

Demo.

On Thu, Nov 8, 2012 at 5:29 PM, <dummy@example.com> wrote:
I contact you about our meeting for tomorrow. Here is the schedule I propose:
  • 9 AM: brainstorming about our new amazing business app</span></li>
  • -
  • 9.45 AM: summary
  • 10 AM: meeting with Fabien to present our app
Is everything ok for you ?
-

--
Administrator

- -

Log in our portal at: http://localhost:8069#action=login&db=mail_1&login=demo

-

""" - -THUNDERBIRD_16_REPLY1_SAN = """
On 11/08/2012 05:29 PM, - dummy@example.com wrote:
-
-
I contact you about our meeting for tomorrow. Here is the - schedule I propose:
-
-
  • 9 AM: brainstorming about our new amazing business - app</span></li>
  • -
  • 9.45 AM: summary
  • -
  • 10 AM: meeting with Fabien to present our app
  • -
-
Is everything ok for you ?
-
-

--
- Administrator

-
-
-

Log in our portal at: -http://localhost:8069#action=login&db=mail_1&token=rHdWcUART5PhEnJRaXjH

-
-
- Ok for me. I am replying directly below your mail, using - Thunderbird, with a signature.

- Did you receive my email about my new laptop, by the way ?

- Raoul.
-- 
-Raoul Grosbedonnée
-
""" - -TEXT_TPL = """Salut Raoul! -Le 28 oct. 2012 à 00:02, Raoul Grosbedon a écrit : - -> C'est sûr que je suis intéressé (quote)! - -Trouloulou pouet pouet. Je ne vais quand même pas écrire de vrais mails, non mais ho. - -> 2012/10/27 Bert Tartopoils : ->> Diantre, me disè-je en envoyant un message similaire à Martine, mais comment vas-tu (quote)? ->> ->> A la base le contenu était un vrai mail, mais je l'ai quand même réécrit pour ce test, histoire de dire que, quand même, on ne met pas n'importe quoi ici. (quote) ->> ->> Et sinon bon courage pour trouver tes clefs (quote). ->> ->> Bert TARTOPOILS ->> bert.tartopoils@miam.miam ->> -> -> -> -- -> Raoul Grosbedon - -Bert TARTOPOILS -bert.tartopoils@miam.miam -""" - class TestSanitizer(unittest2.TestCase): """ Test the html sanitizer that filters html to remove unwanted attributes """ @@ -223,22 +86,21 @@ class TestSanitizer(unittest2.TestCase): 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)) def test_html(self): - sanitized_html = html_sanitize(HTML_SOURCE) + sanitized_html = html_sanitize(test_mail_examples.MISC_HTML_SOURCE) for tag in ['", "Charles <charles.bidule@truc.fr>"), + emails = [("Charles ", "Charles <charles.bidule@truc.fr>"), ("Dupuis <'tr/-: ${dupuis#$'@truc.baz.fr>", "Dupuis <'tr/-: ${dupuis#$'@truc.baz.fr>"), ("Technical ", "Technical <service/technical+2@open.com>"), ("Div nico ", "Div nico <div-nico@open.com>")] for email in emails: self.assertIn(email[1], html_sanitize(email[0]), 'html_sanitize stripped emails of original html') - def test_edi_source(self): - html = html_sanitize(EDI_LIKE_HTML_SOURCE) + html = html_sanitize(test_mail_examples.EDI_LIKE_HTML_SOURCE) self.assertIn('div style="font-family: \'Lucica Grande\', Ubuntu, Arial, Verdana, sans-serif; font-size: 12px; color: rgb(34, 34, 34); background-color: #FFF;', html, 'html_sanitize removed valid style attribute') self.assertIn('', html, @@ -251,36 +113,73 @@ class TestSanitizer(unittest2.TestCase): class TestCleaner(unittest2.TestCase): """ Test the email cleaner function that filters the content of incoming emails """ - def test_html_email_clean(self): - # Test1: reply through gmail: quote in blockquote, signature --\nAdministrator - new_html = html_email_clean(GMAIL_REPLY1_SAN) - self.assertNotIn('blockquote', new_html, 'html_email_cleaner did not remove a blockquote') - self.assertNotIn('I contact you about our meeting', new_html, 'html_email_cleaner wrongly removed the quoted content') - self.assertNotIn('Administrator', new_html, 'html_email_cleaner did not erase the signature') - self.assertIn('Ok for me', new_html, 'html_email_cleaner erased too much content') + def test_00_html_email_clean_text(self): + """ html_email_clean test for text-based emails """ + new_html = html_email_clean(test_mail_examples.TEXT_1, remove_unwanted=True) + for ext in test_mail_examples.TEXT_1_IN: + self.assertIn(ext, new_html, 'html_email_cleaner wrongly removed not quoted content') + for ext in test_mail_examples.TEXT_1_OUT: + self.assertNotIn(ext, new_html, 'html_email_cleaner did not erase signature / quoted content') - # Test2: reply through Tunderbird 16.0.2 - new_html = html_email_clean(THUNDERBIRD_16_REPLY1_SAN) - self.assertNotIn('blockquote', new_html, 'html_email_cleaner did not remove a blockquote') - self.assertNotIn('I contact you about our meeting', new_html, 'html_email_cleaner wrongly removed the quoted content') - self.assertNotIn('Administrator', new_html, 'html_email_cleaner did not erase the signature') - self.assertNotIn('Grosbedonn', new_html, 'html_email_cleaner did not erase the signature') - self.assertIn('Ok for me', new_html, 'html_email_cleaner erased too much content') + new_html = html_email_clean(test_mail_examples.TEXT_2, remove_unwanted=True) + for ext in test_mail_examples.TEXT_2_IN: + self.assertIn(ext, new_html, 'html_email_cleaner wrongly removed not quoted content') + for ext in test_mail_examples.TEXT_2_OUT: + self.assertNotIn(ext, new_html, 'html_email_cleaner did not erase signature / quoted content') - # Test3: text email - new_html = html_email_clean(TEXT_MAIL1) - self.assertIn('I contact you about our meeting', new_html, 'html_email_cleaner wrongly removed the quoted content') - self.assertNotIn('Administrator', new_html, 'html_email_cleaner did not erase the signature') + def test_10_html_email_clean_html(self): + new_html = html_email_clean(test_mail_examples.HTML_1, remove_unwanted=True) + for ext in test_mail_examples.HTML_1_IN: + self.assertIn(ext, new_html, 'html_email_cleaner wrongly removed not quoted content') + for ext in test_mail_examples.HTML_1_OUT: + self.assertNotIn(ext, new_html, 'html_email_cleaner did not erase signature / quoted content') - # Test4: more complex text email - new_html = html_email_clean(TEXT_TPL) - self.assertNotIn('quote', new_html, 'html_email_cleaner did not remove correctly plaintext quotes') + new_html = html_email_clean(test_mail_examples.HTML_2, remove_unwanted=False) + for ext in test_mail_examples.HTML_2_IN: + self.assertIn(ext, new_html, 'html_email_cleaner wrongly removed not quoted content') + for ext in test_mail_examples.HTML_2_OUT: + self.assertNotIn(ext, new_html, 'html_email_cleaner did not erase signature / quoted content') - # Test5: False boolean for text must return empty string + new_html = html_email_clean(test_mail_examples.HTML_3, remove_unwanted=False) + for ext in test_mail_examples.HTML_3_IN: + self.assertIn(ext, new_html, 'html_email_cleaner wrongly removed not quoted content') + # for ext in test_mail_examples.HTML_3_OUT: + # self.assertNotIn(ext, new_html, 'html_email_cleaner did not erase signature / quoted content') + + def test_20_html_email_clean_msoffice(self): + new_html = html_email_clean(test_mail_examples.MSOFFICE_1, remove_unwanted=True) + for ext in test_mail_examples.MSOFFICE_1_IN: + self.assertIn(ext, new_html, 'html_email_cleaner wrongly removed not quoted content') + for ext in test_mail_examples.MSOFFICE_1_OUT: + self.assertNotIn(ext, new_html, 'html_email_cleaner did not erase signature / quoted content') + + def test_30_html_email_clean_hotmail(self): + new_html = html_email_clean(test_mail_examples.HOTMAIL_1, remove_unwanted=True) + for ext in test_mail_examples.HOTMAIL_1_IN: + self.assertIn(ext, new_html, 'html_email_cleaner wrongly removed not quoted content') + for ext in test_mail_examples.HOTMAIL_1_OUT: + self.assertNotIn(ext, new_html, 'html_email_cleaner did not erase signature / quoted content') + + def test_40_html_email_clean_gmail(self): + new_html = html_email_clean(test_mail_examples.GMAIL_1, remove_unwanted=True) + for ext in test_mail_examples.GMAIL_1_IN: + self.assertIn(ext, new_html, 'html_email_cleaner wrongly removed not quoted content') + for ext in test_mail_examples.GMAIL_1_OUT: + self.assertNotIn(ext, new_html, 'html_email_cleaner did not erase signature / quoted content') + + def test_50_html_email_clean_thunderbird(self): + new_html = html_email_clean(test_mail_examples.THUNDERBIRD_1, remove_unwanted=True) + for ext in test_mail_examples.THUNDERBIRD_1_IN: + self.assertIn(ext, new_html, 'html_email_cleaner wrongly removed not quoted content') + for ext in test_mail_examples.THUNDERBIRD_1_OUT: + self.assertNotIn(ext, new_html, 'html_email_cleaner did not erase signature / quoted content') + + def test_90_html_email_clean_misc(self): + # False boolean for text must return empty string new_html = html_email_clean(False) self.assertEqual(new_html, False, 'html_email_cleaner did change a False in an other value.') - # Test6: Message with xml and doctype tags don't crash + # Message with xml and doctype tags don't crash new_html = html_email_clean(u'\n\n\n \n 404 - Not Found\n \n \n

404 - Not Found

\n \n\n') self.assertNotIn('encoding', new_html, 'html_email_cleaner did not remove correctly encoding attributes') diff --git a/openerp/tests/test_mail_examples.py b/openerp/tests/test_mail_examples.py new file mode 100644 index 00000000000..b0a0faf97ca --- /dev/null +++ b/openerp/tests/test_mail_examples.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +MISC_HTML_SOURCE = """ +test1 +
+test2
+test3
+test4
+test5
+test6
  • test7
  • +test8
  1. test9 +
  2. test10
+
+test11
+
+test12

+google +test link +""" + +EDI_LIKE_HTML_SOURCE = """
+

Hello ${object.partner_id.name},

+

A new invoice is available for you:

+

+   REFERENCES
+   Invoice number: ${object.number}
+   Invoice total: ${object.amount_total} ${object.currency_id.name}
+   Invoice date: ${object.date_invoice}
+   Order reference: ${object.origin}
+   Your contact: ${object.user_id.name} +

+
+

It is also possible to directly pay with Paypal:

+ + + +
+

If you have any question, do not hesitate to contact us.

+

Thank you for choosing ${object.company_id.name or 'us'}!

+
+
+
+

+ ${object.company_id.name}

+
+
+ + ${object.company_id.street}
+ ${object.company_id.street2}
+ ${object.company_id.zip} ${object.company_id.city}
+ ${object.company_id.state_id and ('%s, ' % object.company_id.state_id.name) or ''} ${object.company_id.country_id.name or ''}
+
+
+ Phone:  ${object.company_id.phone} +
+ +
+
""" + +TEXT_1 = """I contact you about our meeting tomorrow. Here is the schedule I propose: +9 AM: brainstorming about our new amazing business app +9.45 AM: summary +10 AM: meeting with Ignasse to present our app +Is everything ok for you ? +-- +MySignature""" + +TEXT_1_IN = ["""I contact you about our meeting tomorrow. Here is the schedule I propose: +9 AM: brainstorming about our new amazing business app +9.45 AM: summary +10 AM: meeting with Ignasse to present our app +Is everything ok for you ?"""] +TEXT_1_OUT = ["""-- +MySignature"""] + +TEXT_2 = """Salut Raoul! +Le 28 oct. 2012 à 00:02, Raoul Grosbedon a écrit : + +> I contact you about our meeting tomorrow. Here is the schedule I propose: (quote) + +Of course. This seems viable. + +> 2012/10/27 Bert Tartopoils : +>> blahblahblah (quote)? +>> +>> blahblahblah (quote) +>> +>> Bert TARTOPOILS +>> bert.tartopoils@miam.miam +>> +> +> +> -- +> RaoulSignature + +Bert TARTOPOILS +bert.tartopoils@miam.miam +""" + +TEXT_2_IN = ["Salut Raoul!", "Of course. This seems viable."] +TEXT_2_OUT = ["I contact you about our meeting tomorrow. Here is the schedule I propose: (quote)", + """> 2012/10/27 Bert Tartopoils : +>> blahblahblah (quote)? +>> +>> blahblahblah (quote) +>> +>> Bert TARTOPOILS +>> bert.tartopoils@miam.miam +>> +> +> +> -- +> RaoulSignature"""] + +HTML_1 = """

I contact you about our meeting for tomorrow. Here is the schedule I propose: (keep) +9 AM: brainstorming about our new amazing business app +9.45 AM: summary +10 AM: meeting with Ignasse to present our app +Is everything ok for you ? +-- +MySignature

""" + +HTML_1_IN = ["""I contact you about our meeting for tomorrow. Here is the schedule I propose: (keep) +9 AM: brainstorming about our new amazing business app +9.45 AM: summary +10 AM: meeting with Ignasse to present our app +Is everything ok for you ?"""] +HTML_1_OUT = ["""-- +MySignature"""] + +HTML_2 = """
+ I contact you about our meeting for tomorrow. Here is the schedule I propose: +
+
+
    +
  • 9 AM: brainstorming about our new amazing business app
  • +
  • 9.45 AM: summary
  • +
  • 10 AM: meeting with Fabien to present our app
  • +
+
+
+ Is everything ok for you ? +
""" + +HTML_2_IN = ["I contact you about our meeting for tomorrow. Here is the schedule I propose:", + "
  • 9 AM: brainstorming about our new amazing business app
  • ", + "
  • 9.45 AM: summary
  • ", + "
  • 10 AM: meeting with Fabien to present our app
  • ", + "Is everything ok for you ?"] +HTML_2_OUT = [] + +HTML_3 = """
    This is an answer.
    +
    +Regards,
    +XXXXXX
    +----- Mail original -----
    + + +
    Hi, 
    +
    +
    +My CRM-related question.
    +
    +Regards, 
    +
    +XXXX
    """ + +HTML_3_IN = ["""
    This is an answer.
    +
    +Regards,
    +XXXXXX
    +----- Mail original -----
    """] +HTML_3_OUT = ["Hi,", "My CRM-related question.", + "Regards,"] + +GMAIL_1 = """Hello,

    Ok for me. I am replying directly in gmail, without signature.

    Kind regards,

    Demo.

    On Thu, Nov 8, 2012 at 5:29 PM, <dummy@example.com> wrote:
    I contact you about our meeting for tomorrow. Here is the schedule I propose:
    • 9 AM: brainstorming about our new amazing business app</span></li>
    • +
    • 9.45 AM: summary
    • 10 AM: meeting with Fabien to present our app
    Is everything ok for you ?
    +

    --
    Administrator

    + + +

    """ + +GMAIL_1_IN = ['Ok for me. I am replying directly in gmail, without signature.'] +GMAIL_1_OUT = ['Administrator', 'Log in our portal at:'] + +THUNDERBIRD_1 = """
    On 11/08/2012 05:29 PM, + dummy@example.com wrote:
    +
    +
    I contact you about our meeting for tomorrow. Here is the + schedule I propose:
    +
    +
    • 9 AM: brainstorming about our new amazing business + app</span></li>
    • +
    • 9.45 AM: summary
    • +
    • 10 AM: meeting with Fabien to present our app
    • +
    +
    Is everything ok for you ?
    +
    +

    --
    + Administrator

    +
    + +
    + Ok for me. I am replying directly below your mail, using Thunderbird, with a signature.

    + Did you receive my email about my new laptop, by the way ?

    + Raoul.
    -- 
    +Raoul Grosbedonnée
    +
    """ + +THUNDERBIRD_1_IN = ['Ok for me. I am replying directly below your mail, using Thunderbird, with a signature.'] +THUNDERBIRD_1_OUT = ['I contact you about our meeting for tomorrow.', 'Raoul Grosbedon'] + +HOTMAIL_1 = """
    +

      + I have an amazing company, i'm learning OpenERP, it is a small company yet, but plannig to grow up quickly. +
     
    Kindest regards,
    xxx
    +
    +
    +
    +
    + Subject: Re: your OpenERP.com registration
    From: xxx@xxx.xxx
    To: xxx@xxx.xxx
    Date: Wed, 27 Mar 2013 17:12:12 +0000 +

    + Hello xxx, +
    + I noticed you recently created an OpenERP.com account to access OpenERP Apps. +
    + You indicated that you wish to use OpenERP in your own company. + We would like to know more about your your business needs and requirements, and see how + we can help you. When would you be available to discuss your project ?
    + Best regards,
    +
    +                http://openerp.com
    +                Belgium: +32.81.81.37.00
    +                U.S.: +1 (650) 307-6736
    +                India: +91 (79) 40 500 100
    +            
    +
    +
    +
    """ + +HOTMAIL_1_IN = ["I have an amazing company, i'm learning OpenERP, it is a small company yet, but plannig to grow up quickly."] +HOTMAIL_1_OUT = ["Subject: Re: your OpenERP.com registration", " I noticed you recently created an OpenERP.com account to access OpenERP Apps.", + "We would like to know more about your your business needs and requirements", "Belgium: +32.81.81.37.00"] + +MSOFFICE_1 = """ +
    +
    +

    + + Our requirements are simple. Just looking to replace some spreadsheets for tracking quotes and possibly using the timecard module. + We are a company of 25 engineers providing product design services to clients. + +

    +

    +

    +

    + + I’ll install on a windows server and run a very limited trial to see how it works. + If we adopt OpenERP we will probably move to Linux or look for a hosted SaaS option. + +

    +

    +

    +

    + +
    + I am also evaluating Adempiere and maybe others. +
    +

    +

    +

    +

    + + +

    +

     

    +

    +

    + + I expect the trial will take 2-3 months as this is not a high priority for us. + +

    +

    +

    +

    + + +

    +

     

    +

    +

    + + Alan + +

    +

    +

    +

    + + +

    +

     

    +

    +
    +
    +

    + + From: + + + OpenERP Enterprise [mailto:sales@openerp.com] +
    Sent: Monday, 11 March, 2013 14:47
    To: Alan Widmer
    Subject: Re: your OpenERP.com registration +
    +

    +

    +

    +
    +
    +

    +

     

    +

    Hello Alan Widmer,

    +

    +

    I noticed you recently downloaded OpenERP.

    +

    +

    + Uou mentioned you wish to use OpenERP in your own company. Please let me more about your + business needs and requirements? When will you be available to discuss about your project? +

    +

    +

    Thanks for your interest in OpenERP,

    +

    +

    Feel free to contact me if you have any questions,

    +

    +

    Looking forward to hear from you soon.

    +

    +

     

    +
    --

    +
    Nicolas

    +
    http://openerp.com

    +
    Belgium: +32.81.81.37.00

    +
    U.S.: +1 (650) 307-6736

    +
    India: +91 (79) 40 500 100

    +
                           

    +
    +
    """ + +MSOFFICE_1_IN = ['Our requirements are simple. Just looking to replace some spreadsheets for tracking quotes and possibly using the timecard module.'] +MSOFFICE_1_OUT = ['I noticed you recently downloaded OpenERP.', 'Uou mentioned you wish to use OpenERP in your own company.'] diff --git a/openerp/tools/mail.py b/openerp/tools/mail.py index 5970ce47040..1b1081c813b 100644 --- a/openerp/tools/mail.py +++ b/openerp/tools/mail.py @@ -52,7 +52,7 @@ def html_sanitize(src): # html encode email tags part = re.compile(r"(<(([^a<>]|a[^<>\s])[^<>]*)@[^<>]+>)", re.IGNORECASE | re.DOTALL) src = part.sub(lambda m: cgi.escape(m.group(1)), src) - + # some corner cases make the parser crash (such as in test_mail) try: cleaner = clean.Cleaner(page_structure=True, style=False, safe_attrs_only=False, forms=False, kill_tags=tags_to_kill, remove_tags=tags_to_remove) @@ -60,11 +60,14 @@ def html_sanitize(src): except TypeError, e: # lxml.clean version < 2.3.1 does not have a kill_tags attribute # to remove in 2014 - cleaner = clean.Cleaner(page_structure=True, style=False, safe_attrs_only=False, forms=False, remove_tags=tags_to_kill+tags_to_remove) + cleaner = clean.Cleaner(page_structure=True, style=False, safe_attrs_only=False, forms=False, remove_tags=tags_to_kill + tags_to_remove) cleaned = cleaner.clean_html(src) - except: - _logger.warning('html_sanitize failed to parse %s' % (src)) - cleaned = '

    Impossible to parse

    ' + except etree.ParserError, e: + _logger.warning('html_sanitize: ParserError "%s" obtained when sanitizing "%s"' % (e, src)) + cleaned = '

    ParserError when sanitizing

    ' + except Exception, e: + _logger.warning('html_sanitize: unknown error "%s" obtained when sanitizing "%s"' % (e, src)) + cleaned = '

    Unknown error when sanitizing

    ' return cleaned @@ -72,7 +75,7 @@ def html_sanitize(src): # HTML Cleaner #---------------------------------------------------------- -def html_email_clean(html): +def html_email_clean(html, remove_unwanted=False, use_max_length=False, max_length=300): """ html_email_clean: clean the html to display in the web client. - strip email quotes (remove blockquote nodes) - strip signatures (remove --\n{\n)Blahblah), by replacing
    by @@ -83,6 +86,8 @@ def html_email_clean(html): html code coming from a sanitized source, like fields.html. """ def _replace_matching_regex(regex, source, replace=''): + if not source: + return source dest = '' idx = 0 for item in re.finditer(regex, source): @@ -91,63 +96,114 @@ def html_email_clean(html): dest += source[idx:] return dest + def _tag_matching_regex_in_text(regex, node, new_node_tag='span', new_node_attrs=None): + # print '\t_tag_matching_regex_in_text' + text = node.text or '' + node.text = '' + cur_node = node + idx = 0 + caca = 0 + for item in re.finditer(regex, text): + # print '\t\tfound', item.start(), item.end(), '-', text[item.start():item.end()], '-' + if caca == 0: + cur_node.text = text[idx:item.start()] + else: + cur_node.tail = text[idx:item.start()] + + # create element + new_node = etree.Element(new_node_tag) + new_node.text = text[item.start():item.end()] + for key, val in new_node_attrs.iteritems(): + new_node.set(key, val) + + # insert element in DOM + node.insert(caca, new_node) + cur_node = new_node + idx = item.end() + caca += 1 + if caca == 0: + cur_node.text = (cur_node.text or '') + text[idx:] + else: + cur_node.tail = text[idx:] + (cur_node.tail or '') + if not html or not isinstance(html, basestring): return html - html = ustr(html) - # 0. remove encoding attribute inside tags + # Pre processing + # ------------------------------------------------------------ + + # --- MAIL ORIGINAL ---: '[\-]{4,}([^\-]*)[\-]{4,}' + + # html: remove encoding attribute inside tags doctype = re.compile(r'(<[^>]*\s)(encoding=(["\'][^"\']*?["\']|[^\s\n\r>]+)(\s[^>]*|/)?>)', re.IGNORECASE | re.DOTALL) html = doctype.sub(r"", html) - # 1. -> \n, because otherwise the tree is obfuscated + # html: ClEditor seems to love using

    -> replace with
    + br_div_tags = re.compile(r'(
    \s*\s*<\/div>)') + html = _replace_matching_regex(br_div_tags, html, '
    ') + + # html: -> \n, to de-obfuscate the tree br_tags = re.compile(r'([<]\s*[bB][rR]\s*\/?[>])') html = _replace_matching_regex(br_tags, html, '__BR_TAG__') - # 2. form a tree, handle (currently ?) pure-text by enclosing them in a pre + # form a tree root = lxml.html.fromstring(html) if not len(root) and root.text is None and root.tail is None: html = '
    %s
    ' % html root = lxml.html.fromstring(html) - # 2.5 remove quoted text in nodes + # form node and tag text-based quotes and signature quote_tags = re.compile(r'(\n(>)+[^\n\r]*)') - for node in root.getiterator(): - if not node.text: - continue - node.text = _replace_matching_regex(quote_tags, node.text) - - # 3. remove blockquotes - quotes = [el for el in root.getiterator(tag='blockquote')] - for node in quotes: - # copy the node tail into parent text - if node.tail: - parent = node.getparent() - parent.text = parent.text or '' + node.tail - # remove the node - node.getparent().remove(node) - - # 4. strip signatures signature = re.compile(r'([-]{2}[\s]?[\r\n]{1,2}[^\z]+)') - for elem in root.getiterator(): - if elem.text: - match = re.search(signature, elem.text) - if match: - elem.text = elem.text[:match.start()] + elem.text[match.end():] - if elem.tail: - match = re.search(signature, elem.tail) - if match: - elem.tail = elem.tail[:match.start()] + elem.tail[match.end():] + for node in root.getiterator(): + _tag_matching_regex_in_text(quote_tags, node, 'span', {'text_quote': '1'}) + _tag_matching_regex_in_text(signature, node, 'span', {'text_signature': '1'}) - # 5. \n back to
    + # Processing + # ------------------------------------------------------------ + + # tree: tag nodes + quote_begin = False + for node in root.getiterator(): + if node.get('class') in ['WordSection1', 'MsoNormal']: + root.set('msoffice', '1') + if node.get('class') in ['SkyDrivePlaceholder'] or node.get('id') in ['SkyDrivePlaceholder']: + root.set('hotmail', '1') + + if quote_begin: + node.set('quote', '1') + + if root.get('msoffice') and node.tag == 'div' and 'border-top:solid' in node.get('style', ''): + quote_begin = True + if root.get('hotmail') and node.tag == 'hr' and ('stopSpelling' in node.get('class', '') or 'stopSpelling' in node.get('id', '')): + quote_begin = True + + if node.tag == 'blockquote' or node.get('text_quote') or node.get('text_signature'): + node.set('remove', '1') + if quote_begin: + node.set('remove', '1') + node.set('tail_remove', '1') + + # Post processing + # ------------------------------------------------------------ + + if remove_unwanted: + to_delete = [] + for node in root.getiterator(): + if node.get('remove'): + # copy the node tail into parent text + if node.tail and not node.get('tail_remove'): + parent = node.getparent() + parent.tail = node.tail + (parent.tail or '') + to_delete.append(node) + for node in to_delete: + node.getparent().remove(node) + + # html: \n back to
    html = etree.tostring(root, pretty_print=True) html = html.replace('__BR_TAG__', '
    ') - # 6. Misc cleaning : - # - ClEditor seems to love using

    -> replace with
    - br_div_tags = re.compile(r'(
    \s*\s*<\/div>)') - html = _replace_matching_regex(br_div_tags, html, '
    ') - return html From 85a807a5153d357e83a0f01d4940b5c6c2582403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Tue, 23 Apr 2013 17:36:45 +0200 Subject: [PATCH 002/161] [REF] Chatter: refactored system for read more / read less, now based on html_email_clean instead of the expander library. bzr revid: tde@openerp.com-20130423153645-k8vcb9hiuapzf7sg --- addons/mail/__openerp__.py | 1 - addons/mail/mail_message.py | 7 +- .../lib/jquery.expander/jquery.expander.js | 385 ------------------ addons/mail/static/src/js/mail.js | 16 +- addons/mail/static/src/xml/mail.xml | 8 +- 5 files changed, 17 insertions(+), 400 deletions(-) delete mode 100644 addons/mail/static/lib/jquery.expander/jquery.expander.js diff --git a/addons/mail/__openerp__.py b/addons/mail/__openerp__.py index 63cd0888986..db0d8c8098a 100644 --- a/addons/mail/__openerp__.py +++ b/addons/mail/__openerp__.py @@ -83,7 +83,6 @@ Main Features 'static/src/css/mail_group.css', ], 'js': [ - 'static/lib/jquery.expander/jquery.expander.js', 'static/src/js/mail.js', 'static/src/js/mail_followers.js', 'static/src/js/many2many_tags_email.js', diff --git a/addons/mail/mail_message.py b/addons/mail/mail_message.py index defc12a2f73..ec6a8db526e 100644 --- a/addons/mail/mail_message.py +++ b/addons/mail/mail_message.py @@ -362,15 +362,16 @@ class mail_message(osv.Model): has_voted = uid in [user.id for user in message.vote_user_ids] try: - body_html = html_email_clean(message.body) + body_short = html_email_clean(message.body, remove_unwanted=True, use_max_length=True, max_length=50) except Exception: - body_html = '

    Encoding Error :
    Unable to convert this message (id: %s).

    ' % message.id + body_short = '

    Encoding Error :
    Unable to convert this message (id: %s).

    ' % message.id _logger.exception(Exception) return {'id': message.id, 'type': message.type, 'subtype': message.subtype_id.name if message.subtype_id else False, - 'body': body_html, + 'body': message.body, + 'body_short': body_short, 'model': message.model, 'res_id': message.res_id, 'record_name': message.record_name, diff --git a/addons/mail/static/lib/jquery.expander/jquery.expander.js b/addons/mail/static/lib/jquery.expander/jquery.expander.js deleted file mode 100644 index 91cc03db918..00000000000 --- a/addons/mail/static/lib/jquery.expander/jquery.expander.js +++ /dev/null @@ -1,385 +0,0 @@ -/*! - * jQuery Expander Plugin v1.4.2 - * - * Date: Fri Mar 16 14:29:56 2012 EDT - * Requires: jQuery v1.3+ - * - * Copyright 2011, Karl Swedberg - * Dual licensed under the MIT and GPL licenses (just like jQuery): - * http://www.opensource.org/licenses/mit-license.php - * http://www.gnu.org/licenses/gpl.html - * - * - * - * -*/ - -(function($) { - $.expander = { - version: '1.4.2', - defaults: { - // the number of characters at which the contents will be sliced into two parts. - slicePoint: 100, - - // whether to keep the last word of the summary whole (true) or let it slice in the middle of a word (false) - preserveWords: true, - - // a threshold of sorts for whether to initially hide/collapse part of the element's contents. - // If after slicing the contents in two there are fewer words in the second part than - // the value set by widow, we won't bother hiding/collapsing anything. - widow: 4, - - // text displayed in a link instead of the hidden part of the element. - // clicking this will expand/show the hidden/collapsed text - expandText: 'read more', - expandPrefix: '… ', - - expandAfterSummary: false, - - // class names for summary element and detail element - summaryClass: 'summary', - detailClass: 'details', - - // class names for around "read-more" link and "read-less" link - moreClass: 'read-more', - lessClass: 'read-less', - - // number of milliseconds after text has been expanded at which to collapse the text again. - // when 0, no auto-collapsing - collapseTimer: 0, - - // effects for expanding and collapsing - expandEffect: 'fadeIn', - expandSpeed: 250, - collapseEffect: 'fadeOut', - collapseSpeed: 200, - - // allow the user to re-collapse the expanded text. - userCollapse: true, - - // text to use for the link to re-collapse the text - userCollapseText: 'read less', - userCollapsePrefix: ' ', - - - // all callback functions have the this keyword mapped to the element in the jQuery set when .expander() is called - - onSlice: null, // function() {} - beforeExpand: null, // function() {}, - afterExpand: null, // function() {}, - onCollapse: null // function(byUser) {} - } - }; - - $.fn.expander = function(options) { - var meth = 'init'; - - if (typeof options == 'string') { - meth = options; - options = {}; - } - - var opts = $.extend({}, $.expander.defaults, options), - rSelfClose = /^<(?:area|br|col|embed|hr|img|input|link|meta|param).*>$/i, - rAmpWordEnd = opts.wordEnd || /(&(?:[^;]+;)?|[a-zA-Z\u00C0-\u0100]+)$/, - rOpenCloseTag = /<\/?(\w+)[^>]*>/g, - rOpenTag = /<(\w+)[^>]*>/g, - rCloseTag = /<\/(\w+)>/g, - rLastCloseTag = /(<\/[^>]+>)\s*$/, - rTagPlus = /^<[^>]+>.?/, - delayedCollapse; - - var methods = { - init: function() { - this.each(function() { - var i, l, tmp, newChar, summTagless, summOpens, summCloses, - lastCloseTag, detailText, detailTagless, - $thisDetails, $readMore, - openTagsForDetails = [], - closeTagsForsummaryText = [], - defined = {}, - thisEl = this, - $this = $(this), - $summEl = $([]), - o = $.meta ? $.extend({}, opts, $this.data()) : opts, - hasDetails = !!$this.find('.' + o.detailClass).length, - hasBlocks = !!$this.find('*').filter(function() { - var display = $(this).css('display'); - return (/^block|table|list/).test(display); - }).length, - el = hasBlocks ? 'div' : 'span', - detailSelector = el + '.' + o.detailClass, - moreSelector = 'span.' + o.moreClass, - expandSpeed = o.expandSpeed || 0, - allHtml = $.trim( $this.html() ), - allText = $.trim( $this.text() ), - summaryText = allHtml.slice(0, o.slicePoint); - - // bail out if we've already set up the expander on this element - if ( $.data(this, 'expander') ) { - return; - } - - $.data(this, 'expander', true); - - // determine which callback functions are defined - $.each(['onSlice','beforeExpand', 'afterExpand', 'onCollapse'], function(index, val) { - defined[val] = $.isFunction(o[val]); - }); - - // back up if we're in the middle of a tag or word - summaryText = backup(summaryText); - - // summary text sans tags length - summTagless = summaryText.replace(rOpenCloseTag, '').length; - - // add more characters to the summary, one for each character in the tags - while (summTagless < o.slicePoint) { - newChar = allHtml.charAt(summaryText.length); - if (newChar == '<') { - newChar = allHtml.slice(summaryText.length).match(rTagPlus)[0]; - } - summaryText += newChar; - summTagless++; - } - - summaryText = backup(summaryText, o.preserveWords); - - // separate open tags from close tags and clean up the lists - summOpens = summaryText.match(rOpenTag) || []; - summCloses = summaryText.match(rCloseTag) || []; - - // filter out self-closing tags - tmp = []; - $.each(summOpens, function(index, val) { - if ( !rSelfClose.test(val) ) { - tmp.push(val); - } - }); - summOpens = tmp; - - // strip close tags to just the tag name - l = summCloses.length; - for (i = 0; i < l; i++) { - summCloses[i] = summCloses[i].replace(rCloseTag, '$1'); - } - - // tags that start in summary and end in detail need: - // a). close tag at end of summary - // b). open tag at beginning of detail - $.each(summOpens, function(index, val) { - var thisTagName = val.replace(rOpenTag, '$1'); - var closePosition = $.inArray(thisTagName, summCloses); - if (closePosition === -1) { - openTagsForDetails.push(val); - closeTagsForsummaryText.push(''); - - } else { - summCloses.splice(closePosition, 1); - } - }); - - // reverse the order of the close tags for the summary so they line up right - closeTagsForsummaryText.reverse(); - - // create necessary summary and detail elements if they don't already exist - if ( !hasDetails ) { - - // end script if there is no detail text or if detail has fewer words than widow option - detailText = allHtml.slice(summaryText.length); - detailTagless = $.trim( detailText.replace(rOpenCloseTag, '') ); - - if ( detailTagless === '' || detailTagless.split(/\s+/).length < o.widow ) { - return; - } - // otherwise, continue... - lastCloseTag = closeTagsForsummaryText.pop() || ''; - summaryText += closeTagsForsummaryText.join(''); - detailText = openTagsForDetails.join('') + detailText; - - } else { - // assume that even if there are details, we still need readMore/readLess/summary elements - // (we already bailed out earlier when readMore el was found) - // but we need to create els differently - - // remove the detail from the rest of the content - detailText = $this.find(detailSelector).remove().html(); - - // The summary is what's left - summaryText = $this.html(); - - // allHtml is the summary and detail combined (this is needed when content has block-level elements) - allHtml = summaryText + detailText; - - lastCloseTag = ''; - } - o.moreLabel = $this.find(moreSelector).length ? '' : buildMoreLabel(o); - - if (hasBlocks) { - detailText = allHtml; - } - summaryText += lastCloseTag; - - // onSlice callback - o.summary = summaryText; - o.details = detailText; - o.lastCloseTag = lastCloseTag; - - if (defined.onSlice) { - // user can choose to return a modified options object - // one last chance for user to change the options. sneaky, huh? - // but could be tricky so use at your own risk. - tmp = o.onSlice.call(thisEl, o); - - // so, if the returned value from the onSlice function is an object with a details property, we'll use that! - o = tmp && tmp.details ? tmp : o; - } - - // build the html with summary and detail and use it to replace old contents - var html = buildHTML(o, hasBlocks); - - $this.html( html ); - - // set up details and summary for expanding/collapsing - $thisDetails = $this.find(detailSelector); - $readMore = $this.find(moreSelector); - $thisDetails.hide(); - $readMore.find('a').unbind('click.expander').bind('click.expander', expand); - - $summEl = $this.find('div.' + o.summaryClass); - - if ( o.userCollapse && !$this.find('span.' + o.lessClass).length ) { - $this - .find(detailSelector) - .append('' + o.userCollapsePrefix + '' + o.userCollapseText + ''); - } - - $this - .find('span.' + o.lessClass + ' a') - .unbind('click.expander') - .bind('click.expander', function(event) { - event.preventDefault(); - clearTimeout(delayedCollapse); - var $detailsCollapsed = $(this).closest(detailSelector); - reCollapse(o, $detailsCollapsed); - if (defined.onCollapse) { - o.onCollapse.call(thisEl, true); - } - }); - - function expand(event) { - event.preventDefault(); - $readMore.hide(); - $summEl.hide(); - if (defined.beforeExpand) { - o.beforeExpand.call(thisEl); - } - - $thisDetails.stop(false, true)[o.expandEffect](expandSpeed, function() { - $thisDetails.css({zoom: ''}); - if (defined.afterExpand) {o.afterExpand.call(thisEl);} - delayCollapse(o, $thisDetails, thisEl); - }); - } - - }); // this.each - }, - destroy: function() { - if ( !this.data('expander') ) { - return; - } - this.removeData('expander'); - this.each(function() { - var $this = $(this), - o = $.meta ? $.extend({}, opts, $this.data()) : opts, - details = $this.find('.' + o.detailClass).contents(); - - $this.find('.' + o.moreClass).remove(); - $this.find('.' + o.summaryClass).remove(); - $this.find('.' + o.detailClass).after(details).remove(); - $this.find('.' + o.lessClass).remove(); - - }); - } - }; - - // run the methods (almost always "init") - if ( methods[meth] ) { - methods[ meth ].call(this); - } - - // utility functions - function buildHTML(o, blocks) { - var el = 'span', - summary = o.summary; - if ( blocks ) { - el = 'div'; - // if summary ends with a close tag, tuck the moreLabel inside it - if ( rLastCloseTag.test(summary) && !o.expandAfterSummary) { - summary = summary.replace(rLastCloseTag, o.moreLabel + '$1'); - } else { - // otherwise (e.g. if ends with self-closing tag) just add moreLabel after summary - // fixes #19 - summary += o.moreLabel; - } - - // and wrap it in a div - summary = '
    ' + summary + '
    '; - } else { - summary += o.moreLabel; - } - - return [ - summary, - '<', - el + ' class="' + o.detailClass + '"', - '>', - o.details, - '' - ].join(''); - } - - function buildMoreLabel(o) { - var ret = '' + o.expandPrefix; - ret += '' + o.expandText + ''; - return ret; - } - - function backup(txt, preserveWords) { - if ( txt.lastIndexOf('<') > txt.lastIndexOf('>') ) { - txt = txt.slice( 0, txt.lastIndexOf('<') ); - } - if (preserveWords) { - txt = txt.replace(rAmpWordEnd,''); - } - - return $.trim(txt); - } - - function reCollapse(o, el) { - el.stop(true, true)[o.collapseEffect](o.collapseSpeed, function() { - var prevMore = el.prev('span.' + o.moreClass).show(); - if (!prevMore.length) { - el.parent().children('div.' + o.summaryClass).show() - .find('span.' + o.moreClass).show(); - } - }); - } - - function delayCollapse(option, $collapseEl, thisEl) { - if (option.collapseTimer) { - delayedCollapse = setTimeout(function() { - reCollapse(option, $collapseEl); - if ( $.isFunction(option.onCollapse) ) { - option.onCollapse.call(thisEl, false); - } - }, option.collapseTimer); - } - } - - return this; - }; - - // plugin defaults - $.fn.expander.defaults = $.expander.defaults; -})(jQuery); diff --git a/addons/mail/static/src/js/mail.js b/addons/mail/static/src/js/mail.js index b1a579ba057..5ab6d2380a0 100644 --- a/addons/mail/static/src/js/mail.js +++ b/addons/mail/static/src/js/mail.js @@ -224,6 +224,7 @@ openerp.mail = function (session) { this.name = datasets.name || false, this.record_name = datasets.record_name || false, this.body = datasets.body || '', + this.body_short = datasets.body_short || '', this.vote_nb = datasets.vote_nb || 0, this.has_voted = datasets.has_voted || false, this.is_favorite = datasets.is_favorite || false, @@ -939,7 +940,6 @@ openerp.mail = function (session) { start: function () { this._super.apply(this, arguments); - this.expender(); this.bind_events(); if(this.thread_level < this.options.display_indented_thread) { this.create_thread(); @@ -962,6 +962,8 @@ openerp.mail = function (session) { this.$('.oe_reply').on('click', this.on_message_reply); this.$('.oe_star').on('click', this.on_star); this.$('.oe_msg_vote').on('click', this.on_vote); + this.$('.oe_mail_expand').on('click', this.on_expand); + this.$('.oe_mail_reduce').on('click', this.on_expand); }, /* Call the on_compose_message on the thread of this message. */ @@ -972,15 +974,9 @@ openerp.mail = function (session) { return false; }, - expender: function () { - this.$('.oe_msg_body:first').expander({ - slicePoint: this.options.truncate_limit, - expandText: 'read more', - userCollapseText: 'read less', - detailClass: 'oe_msg_tail', - moreClass: 'oe_mail_expand', - lessClass: 'oe_mail_reduce', - }); + on_expand: function (event) { + this.$('.oe_msg_body_short:first').toggle(); + this.$('.oe_msg_body_long:first').toggle(); }, /** diff --git a/addons/mail/static/src/xml/mail.xml b/addons/mail/static/src/xml/mail.xml index 7e7d772397a..9958cb5e595 100644 --- a/addons/mail/static/src/xml/mail.xml +++ b/addons/mail/static/src/xml/mail.xml @@ -254,7 +254,13 @@
    - + + + + + + +