[FIX] tools: Comments in e-mail templates are removed before mako/jinja2 processing...
[odoo/odoo.git] / openerp / tools / mail.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Business Applications
5 #    Copyright (C) 2012-TODAY OpenERP S.A. (<http://openerp.com>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21
22 from lxml import etree
23 import cgi
24 import logging
25 import lxml.html
26 import lxml.html.clean as clean
27 import random
28 import re
29 import socket
30 import threading
31 import time
32 from email.utils import getaddresses
33
34 import openerp
35 from openerp.loglevels import ustr
36
37 _logger = logging.getLogger(__name__)
38
39
40 #----------------------------------------------------------
41 # HTML Sanitizer
42 #----------------------------------------------------------
43
44 tags_to_kill = ["script", "head", "meta", "title", "link", "style", "frame", "iframe", "base", "object", "embed"]
45 tags_to_remove = ['html', 'body', 'font']
46
47 # allow new semantic HTML5 tags
48 allowed_tags = clean.defs.tags | frozenset('article section header footer hgroup nav aside figure main'.split() + [etree.Comment])
49 safe_attrs = clean.defs.safe_attrs | frozenset(
50     ['style',
51      'data-oe-model', 'data-oe-id', 'data-oe-field', 'data-oe-type', 'data-oe-expression', 'data-oe-translate', 'data-oe-nodeid',
52      'data-snippet-id', 'data-publish', 'data-id', 'data-res_id', 'data-member_id', 'data-view-id'
53      ])
54
55
56 def html_sanitize(src, silent=True, strict=False):
57     if not src:
58         return src
59     src = ustr(src, errors='replace')
60
61     logger = logging.getLogger(__name__ + '.html_sanitize')
62
63     # html encode email tags
64     part = re.compile(r"(<(([^a<>]|a[^<>\s])[^<>]*)@[^<>]+>)", re.IGNORECASE | re.DOTALL)
65     src = part.sub(lambda m: cgi.escape(m.group(1)), src)
66
67     kwargs = {
68         'page_structure': True,
69         'style': False,             # do not remove style attributes
70         'forms': True,              # remove form tags
71         'remove_unknown_tags': False,
72         'allow_tags': allowed_tags,
73         'comments': False,
74         'processing_instructions' : False
75     }
76     if etree.LXML_VERSION >= (2, 3, 1):
77         # kill_tags attribute has been added in version 2.3.1
78         kwargs.update({
79             'kill_tags': tags_to_kill,
80             'remove_tags': tags_to_remove,
81         })
82     else:
83         kwargs['remove_tags'] = tags_to_kill + tags_to_remove
84
85     if strict:
86         if etree.LXML_VERSION >= (3, 1, 0):
87             # lxml < 3.1.0 does not allow to specify safe_attrs. We keep all attributes in order to keep "style"
88             kwargs.update({
89                 'safe_attrs_only': True,
90                 'safe_attrs': safe_attrs,
91             })
92     else:
93         kwargs['safe_attrs_only'] = False    # keep oe-data attributes + style
94         kwargs['frames'] = False,            # do not remove frames (embbed video in CMS blogs)
95
96     try:
97         # some corner cases make the parser crash (such as <SCRIPT/XSS SRC=\"http://ha.ckers.org/xss.js\"></SCRIPT> in test_mail)
98         cleaner = clean.Cleaner(**kwargs)
99         cleaned = cleaner.clean_html(src)
100         # MAKO compatibility: $, { and } inside quotes are escaped, preventing correct mako execution
101         cleaned = cleaned.replace('%24', '$')
102         cleaned = cleaned.replace('%7B', '{')
103         cleaned = cleaned.replace('%7D', '}')
104         cleaned = cleaned.replace('%20', ' ')
105         cleaned = cleaned.replace('%5B', '[')
106         cleaned = cleaned.replace('%5D', ']')
107     except etree.ParserError, e:
108         if 'empty' in str(e):
109             return ""
110         if not silent:
111             raise
112         logger.warning('ParserError obtained when sanitizing %r', src, exc_info=True)
113         cleaned = '<p>ParserError when sanitizing</p>'
114     except Exception:
115         if not silent:
116             raise
117         logger.warning('unknown error obtained when sanitizing %r', src, exc_info=True)
118         cleaned = '<p>Unknown error when sanitizing</p>'
119
120     # this is ugly, but lxml/etree tostring want to put everything in a 'div' that breaks the editor -> remove that
121     if cleaned.startswith('<div>') and cleaned.endswith('</div>'):
122         cleaned = cleaned[5:-6]
123
124     return cleaned
125
126
127 #----------------------------------------------------------
128 # HTML Cleaner
129 #----------------------------------------------------------
130
131 def html_email_clean(html, remove=False, shorten=False, max_length=300, expand_options=None,
132                      protect_sections=False):
133     """ html_email_clean: clean the html by doing the following steps:
134
135      - try to strip email quotes, by removing blockquotes or having some client-
136        specific heuristics
137      - try to strip signatures
138      - shorten the html to a maximum number of characters if requested
139
140     Some specific use case:
141
142      - MsOffice: ``div.style = border-top:solid;`` delimitates the beginning of
143        a quote; detecting by finding WordSection1 of MsoNormal
144      - Hotmail: ``hr.stopSpelling`` delimitates the beginning of a quote; detect
145        Hotmail by funding ``SkyDrivePlaceholder``
146
147     :param string html: sanitized html; tags like html or head should not
148                         be present in the html string. This method therefore
149                         takes as input html code coming from a sanitized source,
150                         like fields.html.
151     :param boolean remove: remove the html code that is unwanted; otherwise it
152                            is only flagged and tagged
153     :param boolean shorten: shorten the html; every excessing content will
154                             be flagged as to remove
155     :param int max_length: if shortening, maximum number of characters before
156                            shortening
157     :param dict expand_options: options for the read more link when shortening
158                                 the content.The used keys are the following:
159
160                                  - oe_expand_container_tag: class applied to the
161                                    container of the whole read more link
162                                  - oe_expand_container_class: class applied to the
163                                    link container (default: oe_mail_expand)
164                                  - oe_expand_container_content: content of the
165                                    container (default: ...)
166                                  - oe_expand_separator_node: optional separator, like
167                                    adding ... <br /><br /> <a ...>read more</a> (default: void)
168                                  - oe_expand_a_href: href of the read more link itself
169                                    (default: #)
170                                  - oe_expand_a_class: class applied to the <a> containing
171                                    the link itself (default: oe_mail_expand)
172                                  - oe_expand_a_content: content of the <a> (default: read more)
173
174                                 The formatted read more link is the following:
175                                 <cont_tag class="oe_expand_container_class">
176                                     oe_expand_container_content
177                                     if expand_options.get('oe_expand_separator_node'):
178                                         <oe_expand_separator_node/>
179                                     <a href="oe_expand_a_href" class="oe_expand_a_class">
180                                         oe_expand_a_content
181                                     </a>
182                                 </span>
183     """
184     def _replace_matching_regex(regex, source, replace=''):
185         """ Replace all matching expressions in source by replace """
186         if not source:
187             return source
188         dest = ''
189         idx = 0
190         for item in re.finditer(regex, source):
191             dest += source[idx:item.start()] + replace
192             idx = item.end()
193         dest += source[idx:]
194         return dest
195
196     def _create_node(tag, text, tail=None, attrs={}):
197         new_node = etree.Element(tag)
198         new_node.text = text
199         new_node.tail = tail
200         for key, val in attrs.iteritems():
201             new_node.set(key, val)
202         return new_node
203
204     def _insert_new_node(node, index, new_node_tag, new_node_text, new_node_tail=None, new_node_attrs={}):
205         new_node = _create_node(new_node_tag, new_node_text, new_node_tail, new_node_attrs)
206         node.insert(index, new_node)
207         return new_node
208
209     def _tag_matching_regex_in_text(regex, node, new_node_tag='span', new_node_attrs={}):
210         text = node.text or ''
211         if not re.search(regex, text):
212             return
213
214         cur_node = node
215         node.text = ''
216         idx, iteration = 0, 0
217         for item in re.finditer(regex, text):
218             if iteration == 0:
219                 cur_node.text = text[idx:item.start()]
220             else:
221                 _insert_new_node(node, (iteration - 1) * 2 + 1, new_node_tag, text[idx:item.start()])
222             new_node = _insert_new_node(node, iteration * 2, new_node_tag, text[item.start():item.end()], None, new_node_attrs)
223
224             cur_node = new_node
225             idx = item.end()
226             iteration += 1
227         new_node = _insert_new_node(node, -1, new_node_tag, text[idx:] + (cur_node.tail or ''), None, {})
228
229     def _truncate_node(node, position, simplify_whitespaces=True):
230         """ Truncate a node text at a given position. This algorithm will shorten
231         at the end of the word whose ending character exceeds position.
232
233             :param bool simplify_whitespaces: whether to try to count all successive
234                                               whitespaces as one character. This
235                                               option should not be True when trying
236                                               to keep 'pre' consistency.
237         """
238         if node.text is None:
239             node.text = ''
240
241         truncate_idx = -1
242         if simplify_whitespaces:
243             cur_char_nbr = 0
244             word = None
245             node_words = node.text.strip(' \t\r\n').split()
246             for word in node_words:
247                 cur_char_nbr += len(word)
248                 if cur_char_nbr >= position:
249                     break
250             if word:
251                 truncate_idx = node.text.find(word) + len(word)
252         else:
253             truncate_idx = position
254         if truncate_idx == -1 or truncate_idx > len(node.text):
255             truncate_idx = len(node.text)
256
257         # compose new text bits
258         innertext = node.text[0:truncate_idx]
259         outertext = node.text[truncate_idx:]
260         node.text = innertext
261
262         # create <span> ... <a href="#">read more</a></span> node
263         read_more_node = _create_node(
264             expand_options.get('oe_expand_container_tag', 'span'),
265             expand_options.get('oe_expand_container_content', ' ... '),
266             None,
267             {'class': expand_options.get('oe_expand_container_class', 'oe_mail_expand')}
268         )
269         if expand_options.get('oe_expand_separator_node'):
270             read_more_separator_node = _create_node(
271                 expand_options.get('oe_expand_separator_node'),
272                 '',
273                 None,
274                 {}
275             )
276             read_more_node.append(read_more_separator_node)
277         read_more_link_node = _create_node(
278             'a',
279             expand_options.get('oe_expand_a_content', 'read more'),
280             None,
281             {
282                 'href': expand_options.get('oe_expand_a_href', '#'),
283                 'class': expand_options.get('oe_expand_a_class', 'oe_mail_expand'),
284             }
285         )
286         read_more_node.append(read_more_link_node)
287         # create outertext node
288         overtext_node = _create_node('span', outertext)
289         # tag node
290         overtext_node.set('in_overlength', '1')
291         # add newly created nodes in dom
292         node.append(read_more_node)
293         node.append(overtext_node)
294
295     if expand_options is None:
296         expand_options = {}
297
298     if not html or not isinstance(html, basestring):
299         return html
300     html = ustr(html)
301
302     # Pre processing
303     # ------------------------------------------------------------
304     # TDE TODO: --- MAIL ORIGINAL ---: '[\-]{4,}([^\-]*)[\-]{4,}'
305
306     # html: remove encoding attribute inside tags
307     doctype = re.compile(r'(<[^>]*\s)(encoding=(["\'][^"\']*?["\']|[^\s\n\r>]+)(\s[^>]*|/)?>)', re.IGNORECASE | re.DOTALL)
308     html = doctype.sub(r"", html)
309
310     # html: ClEditor seems to love using <div><br /><div> -> replace with <br />
311     br_div_tags = re.compile(r'(<div>\s*<br\s*\/>\s*<\/div>)', re.IGNORECASE)
312     html = _replace_matching_regex(br_div_tags, html, '<br />')
313
314     # form a tree
315     root = lxml.html.fromstring(html)
316     if not len(root) and root.text is None and root.tail is None:
317         html = '<div>%s</div>' % html
318         root = lxml.html.fromstring(html)
319
320     quote_tags = re.compile(r'(\n(>)+[^\n\r]*)')
321     signature = re.compile(r'([-]{2,}[\s]?[\r\n]{1,2}[\s\S]+)')
322     for node in root.iter():
323         # remove all tails and replace them by a span element, because managing text and tails can be a pain in the ass
324         if node.tail:
325             tail_node = _create_node('span', node.tail)
326             node.tail = None
327             node.addnext(tail_node)
328
329         # form node and tag text-based quotes and signature
330         _tag_matching_regex_in_text(quote_tags, node, 'span', {'text_quote': '1'})
331         _tag_matching_regex_in_text(signature, node, 'span', {'text_signature': '1'})
332
333     # Processing
334     # ------------------------------------------------------------
335
336     # tree: tag nodes
337     # signature_begin = False  # try dynamic signature recognition
338     quote_begin = False
339     overlength = False
340     overlength_section_id = None
341     overlength_section_count = 0
342     cur_char_nbr = 0
343     for node in root.iter():
344         # comments do not need processing
345         if node.tag == etree.Comment:
346             continue
347         # do not take into account multiple spaces that are displayed as max 1 space in html
348         node_text = ' '.join((node.text and node.text.strip(' \t\r\n') or '').split())
349
350         # root: try to tag the client used to write the html
351         # note: bug in node.get(value, default) for HtmlComments, default never returned
352         if 'WordSection1' in node.get('class', '') or 'MsoNormal' in node.get('class', ''):
353             root.set('msoffice', '1')
354         if 'SkyDrivePlaceholder' in node.get('class', '') or 'SkyDrivePlaceholder' in node.get('id', ''):
355             root.set('hotmail', '1')
356
357         # protect sections by tagging section limits and blocks contained inside sections, using an increasing id to re-find them later
358         if node.tag == 'section':
359             overlength_section_count += 1
360             node.set('section_closure', str(overlength_section_count))
361         if node.getparent() is not None and (node.getparent().get('section_closure') or node.getparent().get('section_inner')):
362             node.set('section_inner', str(overlength_section_count))
363
364         # state of the parsing: flag quotes and tails to remove
365         if quote_begin:
366             node.set('in_quote', '1')
367             node.set('tail_remove', '1')
368         # state of the parsing: flag when being in over-length content, depending on section content if defined (only when having protect_sections)
369         if overlength:
370             if not overlength_section_id or int(node.get('section_inner', overlength_section_count + 1)) > overlength_section_count:
371                 node.set('in_overlength', '1')
372                 node.set('tail_remove', '1')
373
374         # find quote in msoffice / hotmail / blockquote / text quote and signatures
375         if root.get('msoffice') and node.tag == 'div' and 'border-top:solid' in node.get('style', ''):
376             quote_begin = True
377             node.set('in_quote', '1')
378             node.set('tail_remove', '1')
379         if root.get('hotmail') and node.tag == 'hr' and ('stopSpelling' in node.get('class', '') or 'stopSpelling' in node.get('id', '')):
380             quote_begin = True
381             node.set('in_quote', '1')
382             node.set('tail_remove', '1')
383         if node.tag == 'blockquote' or node.get('text_quote') or node.get('text_signature'):
384             node.set('in_quote', '1')
385
386         # shorten:
387         # if protect section:
388         #   1/ find the first parent not being inside a section
389         #   2/ add the read more link
390         # else:
391         #   1/ truncate the text at the next available space
392         #   2/ create a 'read more' node, next to current node
393         #   3/ add the truncated text in a new node, next to 'read more' node
394         node_text = (node.text or '').strip().strip('\n').strip()
395         if shorten and not overlength and cur_char_nbr + len(node_text) > max_length:
396             node_to_truncate = node
397             while node_to_truncate.getparent() is not None:
398                 if node_to_truncate.get('in_quote'):
399                     node_to_truncate = node_to_truncate.getparent()
400                 elif protect_sections and (node_to_truncate.getparent().get('section_inner') or node_to_truncate.getparent().get('section_closure')):
401                     node_to_truncate = node_to_truncate.getparent()
402                     overlength_section_id = node_to_truncate.get('section_closure')
403                 else:
404                     break
405
406             overlength = True
407             node_to_truncate.set('truncate', '1')
408             if node_to_truncate == node:
409                 node_to_truncate.set('truncate_position', str(max_length - cur_char_nbr))
410             else:
411                 node_to_truncate.set('truncate_position', str(len(node.text or '')))
412         cur_char_nbr += len(node_text)
413
414     # Tree modification
415     # ------------------------------------------------------------
416
417     for node in root.iter():
418         if node.get('truncate'):
419             _truncate_node(node, int(node.get('truncate_position', '0')), node.tag != 'pre')
420
421     # Post processing
422     # ------------------------------------------------------------
423
424     to_remove = []
425     for node in root.iter():
426         if node.get('in_quote') or node.get('in_overlength'):
427             # copy the node tail into parent text
428             if node.tail and not node.get('tail_remove'):
429                 parent = node.getparent()
430                 parent.tail = node.tail + (parent.tail or '')
431             to_remove.append(node)
432         if node.get('tail_remove'):
433             node.tail = ''
434         # clean node
435         for attribute_name in ['in_quote', 'tail_remove', 'in_overlength', 'msoffice', 'hotmail', 'truncate', 'truncate_position']:
436             node.attrib.pop(attribute_name, None)
437     for node in to_remove:
438         if remove:
439             node.getparent().remove(node)
440         else:
441             if not expand_options.get('oe_expand_a_class', 'oe_mail_expand') in node.get('class', ''):  # trick: read more link should be displayed even if it's in overlength
442                 node_class = node.get('class', '') + ' oe_mail_cleaned'
443                 node.set('class', node_class)
444
445     # html: \n that were tail of elements have been encapsulated into <span> -> back to \n
446     html = etree.tostring(root, pretty_print=False)
447     linebreaks = re.compile(r'<span[^>]*>([\s]*[\r\n]+[\s]*)<\/span>', re.IGNORECASE | re.DOTALL)
448     html = _replace_matching_regex(linebreaks, html, '\n')
449
450     return html
451
452
453 #----------------------------------------------------------
454 # HTML/Text management
455 #----------------------------------------------------------
456
457 def html2plaintext(html, body_id=None, encoding='utf-8'):
458     """ From an HTML text, convert the HTML to plain text.
459     If @param body_id is provided then this is the tag where the
460     body (not necessarily <body>) starts.
461     """
462     ## (c) Fry-IT, www.fry-it.com, 2007
463     ## <peter@fry-it.com>
464     ## download here: http://www.peterbe.com/plog/html2plaintext
465
466     html = ustr(html)
467     tree = etree.fromstring(html, parser=etree.HTMLParser())
468
469     if body_id is not None:
470         source = tree.xpath('//*[@id=%s]' % (body_id,))
471     else:
472         source = tree.xpath('//body')
473     if len(source):
474         tree = source[0]
475
476     url_index = []
477     i = 0
478     for link in tree.findall('.//a'):
479         url = link.get('href')
480         if url:
481             i += 1
482             link.tag = 'span'
483             link.text = '%s [%s]' % (link.text, i)
484             url_index.append(url)
485
486     html = ustr(etree.tostring(tree, encoding=encoding))
487     # \r char is converted into &#13;, must remove it
488     html = html.replace('&#13;', '')
489
490     html = html.replace('<strong>', '*').replace('</strong>', '*')
491     html = html.replace('<b>', '*').replace('</b>', '*')
492     html = html.replace('<h3>', '*').replace('</h3>', '*')
493     html = html.replace('<h2>', '**').replace('</h2>', '**')
494     html = html.replace('<h1>', '**').replace('</h1>', '**')
495     html = html.replace('<em>', '/').replace('</em>', '/')
496     html = html.replace('<tr>', '\n')
497     html = html.replace('</p>', '\n')
498     html = re.sub('<br\s*/?>', '\n', html)
499     html = re.sub('<.*?>', ' ', html)
500     html = html.replace(' ' * 2, ' ')
501
502     # strip all lines
503     html = '\n'.join([x.strip() for x in html.splitlines()])
504     html = html.replace('\n' * 2, '\n')
505
506     for i, url in enumerate(url_index):
507         if i == 0:
508             html += '\n\n'
509         html += ustr('[%s] %s\n') % (i + 1, url)
510
511     return html
512
513 def plaintext2html(text, container_tag=False):
514     """ Convert plaintext into html. Content of the text is escaped to manage
515         html entities, using cgi.escape().
516         - all \n,\r are replaced by <br />
517         - enclose content into <p>
518         - 2 or more consecutive <br /> are considered as paragraph breaks
519
520         :param string container_tag: container of the html; by default the
521             content is embedded into a <div>
522     """
523     text = cgi.escape(ustr(text))
524
525     # 1. replace \n and \r
526     text = text.replace('\n', '<br/>')
527     text = text.replace('\r', '<br/>')
528
529     # 2-3: form paragraphs
530     idx = 0
531     final = '<p>'
532     br_tags = re.compile(r'(([<]\s*[bB][rR]\s*\/?[>]\s*){2,})')
533     for item in re.finditer(br_tags, text):
534         final += text[idx:item.start()] + '</p><p>'
535         idx = item.end()
536     final += text[idx:] + '</p>'
537
538     # 4. container
539     if container_tag:
540         final = '<%s>%s</%s>' % (container_tag, final, container_tag)
541     return ustr(final)
542
543 def append_content_to_html(html, content, plaintext=True, preserve=False, container_tag=False):
544     """ Append extra content at the end of an HTML snippet, trying
545         to locate the end of the HTML document (</body>, </html>, or
546         EOF), and converting the provided content in html unless ``plaintext``
547         is False.
548         Content conversion can be done in two ways:
549         - wrapping it into a pre (preserve=True)
550         - use plaintext2html (preserve=False, using container_tag to wrap the
551             whole content)
552         A side-effect of this method is to coerce all HTML tags to
553         lowercase in ``html``, and strip enclosing <html> or <body> tags in
554         content if ``plaintext`` is False.
555
556         :param str html: html tagsoup (doesn't have to be XHTML)
557         :param str content: extra content to append
558         :param bool plaintext: whether content is plaintext and should
559             be wrapped in a <pre/> tag.
560         :param bool preserve: if content is plaintext, wrap it into a <pre>
561             instead of converting it into html
562     """
563     html = ustr(html)
564     if plaintext and preserve:
565         content = u'\n<pre>%s</pre>\n' % ustr(content)
566     elif plaintext:
567         content = '\n%s\n' % plaintext2html(content, container_tag)
568     else:
569         content = re.sub(r'(?i)(</?html.*>|</?body.*>|<!\W*DOCTYPE.*>)', '', content)
570         content = u'\n%s\n' % ustr(content)
571     # Force all tags to lowercase
572     html = re.sub(r'(</?)\W*(\w+)([ >])',
573         lambda m: '%s%s%s' % (m.group(1), m.group(2).lower(), m.group(3)), html)
574     insert_location = html.find('</body>')
575     if insert_location == -1:
576         insert_location = html.find('</html>')
577     if insert_location == -1:
578         return '%s%s' % (html, content)
579     return '%s%s%s' % (html[:insert_location], content, html[insert_location:])
580
581 #----------------------------------------------------------
582 # Emails
583 #----------------------------------------------------------
584
585 # matches any email in a body of text
586 email_re = re.compile(r"""([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6})""", re.VERBOSE) 
587
588 # matches a string containing only one email
589 single_email_re = re.compile(r"""^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$""", re.VERBOSE)
590
591 res_re = re.compile(r"\[([0-9]+)\]", re.UNICODE)
592 command_re = re.compile("^Set-([a-z]+) *: *(.+)$", re.I + re.UNICODE)
593
594 # Updated in 7.0 to match the model name as well
595 # Typical form of references is <timestamp-openerp-record_id-model_name@domain>
596 # group(1) = the record ID ; group(2) = the model (if any) ; group(3) = the domain
597 reference_re = re.compile("<.*-open(?:object|erp)-(\\d+)(?:-([\w.]+))?.*@(.*)>", re.UNICODE)
598
599 # Bounce regex
600 # Typical form of bounce is bounce-128-crm.lead-34@domain
601 # group(1) = the mail ID; group(2) = the model (if any); group(3) = the record ID
602 bounce_re = re.compile("[\w]+-(\d+)-?([\w.]+)?-?(\d+)?", re.UNICODE)
603
604 def generate_tracking_message_id(res_id):
605     """Returns a string that can be used in the Message-ID RFC822 header field
606
607        Used to track the replies related to a given object thanks to the "In-Reply-To"
608        or "References" fields that Mail User Agents will set.
609     """
610     try:
611         rnd = random.SystemRandom().random()
612     except NotImplementedError:
613         rnd = random.random()
614     rndstr = ("%.15f" % rnd)[2:]
615     return "<%.15f.%s-openerp-%s@%s>" % (time.time(), rndstr, res_id, socket.gethostname())
616
617 def email_send(email_from, email_to, subject, body, email_cc=None, email_bcc=None, reply_to=False,
618                attachments=None, message_id=None, references=None, openobject_id=False, debug=False, subtype='plain', headers=None,
619                smtp_server=None, smtp_port=None, ssl=False, smtp_user=None, smtp_password=None, cr=None, uid=None):
620     """Low-level function for sending an email (deprecated).
621
622     :deprecate: since OpenERP 6.1, please use ir.mail_server.send_email() instead.
623     :param email_from: A string used to fill the `From` header, if falsy,
624                        config['email_from'] is used instead.  Also used for
625                        the `Reply-To` header if `reply_to` is not provided
626     :param email_to: a sequence of addresses to send the mail to.
627     """
628
629     # If not cr, get cr from current thread database
630     local_cr = None
631     if not cr:
632         db_name = getattr(threading.currentThread(), 'dbname', None)
633         if db_name:
634             local_cr = cr = openerp.registry(db_name).db.cursor()
635         else:
636             raise Exception("No database cursor found, please pass one explicitly")
637
638     # Send Email
639     try:
640         mail_server_pool = openerp.registry(cr.dbname)['ir.mail_server']
641         res = False
642         # Pack Message into MIME Object
643         email_msg = mail_server_pool.build_email(email_from, email_to, subject, body, email_cc, email_bcc, reply_to,
644                    attachments, message_id, references, openobject_id, subtype, headers=headers)
645
646         res = mail_server_pool.send_email(cr, uid or 1, email_msg, mail_server_id=None,
647                        smtp_server=smtp_server, smtp_port=smtp_port, smtp_user=smtp_user, smtp_password=smtp_password,
648                        smtp_encryption=('ssl' if ssl else None), smtp_debug=debug)
649     except Exception:
650         _logger.exception("tools.email_send failed to deliver email")
651         return False
652     finally:
653         if local_cr:
654             cr.close()
655     return res
656
657 def email_split(text):
658     """ Return a list of the email addresses found in ``text`` """
659     if not text:
660         return []
661     return [addr[1] for addr in getaddresses([text])
662                 # getaddresses() returns '' when email parsing fails, and
663                 # sometimes returns emails without at least '@'. The '@'
664                 # is strictly required in RFC2822's `addr-spec`.
665                 if addr[1]
666                 if '@' in addr[1]]