1# -*- coding: utf-8 -*-
2# Part of Odoo. See LICENSE file for full copyright and licensing details.
3
4import base64
5import collections
6import logging
7from lxml.html import clean
8import random
9import re
10import socket
11import threading
12import time
13
14from email.utils import getaddresses
15from lxml import etree
16from werkzeug import urls
17import idna
18
19import odoo
20from odoo.loglevels import ustr
21from odoo.tools import misc
22
23_logger = logging.getLogger(__name__)
24
25#----------------------------------------------------------
26# HTML Sanitizer
27#----------------------------------------------------------
28
29tags_to_kill = ['base', 'embed', 'frame', 'head', 'iframe', 'link', 'meta',
30                'noscript', 'object', 'script', 'style', 'title']
31
32tags_to_remove = ['html', 'body']
33
34# allow new semantic HTML5 tags
35allowed_tags = clean.defs.tags | frozenset('article bdi section header footer hgroup nav aside figure main'.split() + [etree.Comment])
36safe_attrs = clean.defs.safe_attrs | frozenset(
37    ['style',
38     'data-o-mail-quote',  # quote detection
39     'data-oe-model', 'data-oe-id', 'data-oe-field', 'data-oe-type', 'data-oe-expression', 'data-oe-translation-id', 'data-oe-nodeid',
40     'data-publish', 'data-id', 'data-res_id', 'data-interval', 'data-member_id', 'data-scroll-background-ratio', 'data-view-id',
41     'data-class', 'data-mimetype', 'data-original-src', 'data-original-id', 'data-gl-filter', 'data-quality', 'data-resize-width',
42     ])
43
44
45class _Cleaner(clean.Cleaner):
46
47    _style_re = re.compile(r'''([\w-]+)\s*:\s*((?:[^;"']|"[^";]*"|'[^';]*')+)''')
48
49    _style_whitelist = [
50        'font-size', 'font-family', 'font-weight', 'background-color', 'color', 'text-align',
51        'line-height', 'letter-spacing', 'text-transform', 'text-decoration', 'opacity',
52        'float', 'vertical-align', 'display',
53        'padding', 'padding-top', 'padding-left', 'padding-bottom', 'padding-right',
54        'margin', 'margin-top', 'margin-left', 'margin-bottom', 'margin-right',
55        'white-space',
56        # box model
57        'border', 'border-color', 'border-radius', 'border-style', 'border-width', 'border-top', 'border-bottom',
58        'height', 'width', 'max-width', 'min-width', 'min-height',
59        # tables
60        'border-collapse', 'border-spacing', 'caption-side', 'empty-cells', 'table-layout']
61
62    _style_whitelist.extend(
63        ['border-%s-%s' % (position, attribute)
64            for position in ['top', 'bottom', 'left', 'right']
65            for attribute in ('style', 'color', 'width', 'left-radius', 'right-radius')]
66    )
67
68    strip_classes = False
69    sanitize_style = False
70
71    def __call__(self, doc):
72        # perform quote detection before cleaning and class removal
73        for el in doc.iter(tag=etree.Element):
74            self.tag_quote(el)
75
76        super(_Cleaner, self).__call__(doc)
77
78        # if we keep attributes but still remove classes
79        if not getattr(self, 'safe_attrs_only', False) and self.strip_classes:
80            for el in doc.iter(tag=etree.Element):
81                self.strip_class(el)
82
83        # if we keep style attribute, sanitize them
84        if not self.style and self.sanitize_style:
85            for el in doc.iter(tag=etree.Element):
86                self.parse_style(el)
87
88    def tag_quote(self, el):
89        def _create_new_node(tag, text, tail=None, attrs=None):
90            new_node = etree.Element(tag)
91            new_node.text = text
92            new_node.tail = tail
93            if attrs:
94                for key, val in attrs.items():
95                    new_node.set(key, val)
96            return new_node
97
98        def _tag_matching_regex_in_text(regex, node, tag='span', attrs=None):
99            text = node.text or ''
100            if not re.search(regex, text):
101                return
102
103            child_node = None
104            idx, node_idx = 0, 0
105            for item in re.finditer(regex, text):
106                new_node = _create_new_node(tag, text[item.start():item.end()], None, attrs)
107                if child_node is None:
108                    node.text = text[idx:item.start()]
109                    new_node.tail = text[item.end():]
110                    node.insert(node_idx, new_node)
111                else:
112                    child_node.tail = text[idx:item.start()]
113                    new_node.tail = text[item.end():]
114                    node.insert(node_idx, new_node)
115                child_node = new_node
116                idx = item.end()
117                node_idx = node_idx + 1
118
119        el_class = el.get('class', '') or ''
120        el_id = el.get('id', '') or ''
121
122        # gmail or yahoo // # outlook, html // # msoffice
123        if ('gmail_extra' in el_class or 'yahoo_quoted' in el_class) or \
124                (el.tag == 'hr' and ('stopSpelling' in el_class or 'stopSpelling' in el_id)) or \
125                ('SkyDrivePlaceholder' in el_class or 'SkyDrivePlaceholder' in el_class):
126            el.set('data-o-mail-quote', '1')
127            if el.getparent() is not None:
128                el.getparent().set('data-o-mail-quote-container', '1')
129
130        # html signature (-- <br />blah)
131        signature_begin = re.compile(r"((?:(?:^|\n)[-]{2}[\s]?$))")
132        if el.text and el.find('br') is not None and re.search(signature_begin, el.text):
133            el.set('data-o-mail-quote', '1')
134            if el.getparent() is not None:
135                el.getparent().set('data-o-mail-quote-container', '1')
136
137        # text-based quotes (>, >>) and signatures (-- Signature)
138        text_complete_regex = re.compile(r"((?:\n[>]+[^\n\r]*)+|(?:(?:^|\n)[-]{2}[\s]?[\r\n]{1,2}[\s\S]+))")
139        if not el.get('data-o-mail-quote'):
140            _tag_matching_regex_in_text(text_complete_regex, el, 'span', {'data-o-mail-quote': '1'})
141
142        if el.tag == 'blockquote':
143            # remove single node
144            el.set('data-o-mail-quote-node', '1')
145            el.set('data-o-mail-quote', '1')
146        if el.getparent() is not None and (el.getparent().get('data-o-mail-quote') or el.getparent().get('data-o-mail-quote-container')) and not el.getparent().get('data-o-mail-quote-node'):
147            el.set('data-o-mail-quote', '1')
148
149    def strip_class(self, el):
150        if el.attrib.get('class'):
151            del el.attrib['class']
152
153    def parse_style(self, el):
154        attributes = el.attrib
155        styling = attributes.get('style')
156        if styling:
157            valid_styles = collections.OrderedDict()
158            styles = self._style_re.findall(styling)
159            for style in styles:
160                if style[0].lower() in self._style_whitelist:
161                    valid_styles[style[0].lower()] = style[1]
162            if valid_styles:
163                el.attrib['style'] = '; '.join('%s:%s' % (key, val) for (key, val) in valid_styles.items())
164            else:
165                del el.attrib['style']
166
167
168def html_sanitize(src, silent=True, sanitize_tags=True, sanitize_attributes=False, sanitize_style=False, sanitize_form=True, strip_style=False, strip_classes=False):
169    if not src:
170        return src
171    src = ustr(src, errors='replace')
172    # html: remove encoding attribute inside tags
173    doctype = re.compile(r'(<[^>]*\s)(encoding=(["\'][^"\']*?["\']|[^\s\n\r>]+)(\s[^>]*|/)?>)', re.IGNORECASE | re.DOTALL)
174    src = doctype.sub(u"", src)
175
176    logger = logging.getLogger(__name__ + '.html_sanitize')
177
178    # html encode mako tags <% ... %> to decode them later and keep them alive, otherwise they are stripped by the cleaner
179    src = src.replace(u'<%', misc.html_escape(u'<%'))
180    src = src.replace(u'%>', misc.html_escape(u'%>'))
181
182    kwargs = {
183        'page_structure': True,
184        'style': strip_style,              # True = remove style tags/attrs
185        'sanitize_style': sanitize_style,  # True = sanitize styling
186        'forms': sanitize_form,            # True = remove form tags
187        'remove_unknown_tags': False,
188        'comments': False,
189        'processing_instructions': False
190    }
191    if sanitize_tags:
192        kwargs['allow_tags'] = allowed_tags
193        if etree.LXML_VERSION >= (2, 3, 1):
194            # kill_tags attribute has been added in version 2.3.1
195            kwargs.update({
196                'kill_tags': tags_to_kill,
197                'remove_tags': tags_to_remove,
198            })
199        else:
200            kwargs['remove_tags'] = tags_to_kill + tags_to_remove
201
202    if sanitize_attributes and etree.LXML_VERSION >= (3, 1, 0):  # lxml < 3.1.0 does not allow to specify safe_attrs. We keep all attributes in order to keep "style"
203        if strip_classes:
204            current_safe_attrs = safe_attrs - frozenset(['class'])
205        else:
206            current_safe_attrs = safe_attrs
207        kwargs.update({
208            'safe_attrs_only': True,
209            'safe_attrs': current_safe_attrs,
210        })
211    else:
212        kwargs.update({
213            'safe_attrs_only': False,  # keep oe-data attributes + style
214            'strip_classes': strip_classes,  # remove classes, even when keeping other attributes
215        })
216
217    try:
218        # some corner cases make the parser crash (such as <SCRIPT/XSS SRC=\"http://ha.ckers.org/xss.js\"></SCRIPT> in test_mail)
219        cleaner = _Cleaner(**kwargs)
220        cleaned = cleaner.clean_html(src)
221        assert isinstance(cleaned, str)
222        # MAKO compatibility: $, { and } inside quotes are escaped, preventing correct mako execution
223        cleaned = cleaned.replace(u'%24', u'$')
224        cleaned = cleaned.replace(u'%7B', u'{')
225        cleaned = cleaned.replace(u'%7D', u'}')
226        cleaned = cleaned.replace(u'%20', u' ')
227        cleaned = cleaned.replace(u'%5B', u'[')
228        cleaned = cleaned.replace(u'%5D', u']')
229        cleaned = cleaned.replace(u'%7C', u'|')
230        cleaned = cleaned.replace(u'&lt;%', u'<%')
231        cleaned = cleaned.replace(u'%&gt;', u'%>')
232        # html considerations so real html content match database value
233        cleaned.replace(u'\xa0', u'&nbsp;')
234    except etree.ParserError as e:
235        if 'empty' in str(e):
236            return u""
237        if not silent:
238            raise
239        logger.warning(u'ParserError obtained when sanitizing %r', src, exc_info=True)
240        cleaned = u'<p>ParserError when sanitizing</p>'
241    except Exception:
242        if not silent:
243            raise
244        logger.warning(u'unknown error obtained when sanitizing %r', src, exc_info=True)
245        cleaned = u'<p>Unknown error when sanitizing</p>'
246
247    # this is ugly, but lxml/etree tostring want to put everything in a 'div' that breaks the editor -> remove that
248    if cleaned.startswith(u'<div>') and cleaned.endswith(u'</div>'):
249        cleaned = cleaned[5:-6]
250
251    return cleaned
252
253# ----------------------------------------------------------
254# HTML/Text management
255# ----------------------------------------------------------
256
257URL_REGEX = r'(\bhref=[\'"](?!mailto:|tel:|sms:)([^\'"]+)[\'"])'
258TEXT_URL_REGEX = r'https?://[a-zA-Z0-9@:%._\+~#=/-]+(?:\?\S+)?'
259# retrieve inner content of the link
260HTML_TAG_URL_REGEX = URL_REGEX + r'([^<>]*>([^<>]+)<\/)?'
261
262
263def validate_url(url):
264    if urls.url_parse(url).scheme not in ('http', 'https', 'ftp', 'ftps'):
265        return 'http://' + url
266
267    return url
268
269
270def is_html_empty(html_content):
271    """Check if a html content is empty. If there are only formatting tags or
272    a void content return True. Famous use case if a '<p><br></p>' added by
273    some web editor.
274
275    :param str html_content: html content, coming from example from an HTML field
276    :returns: bool, True if no content found or if containing only void formatting tags
277    """
278    if not html_content:
279        return True
280    tag_re = re.compile(r'\<\s*\/?(?:p|div|span|br|b|i)\s*/?\s*\>')
281    return not bool(re.sub(tag_re, '', html_content).strip())
282
283
284def html_keep_url(text):
285    """ Transform the url into clickable link with <a/> tag """
286    idx = 0
287    final = ''
288    link_tags = re.compile(r"""(?<!["'])((ftp|http|https):\/\/(\w+:{0,1}\w*@)?([^\s<"']+)(:[0-9]+)?(\/|\/([^\s<"']))?)(?![^\s<"']*["']|[^\s<"']*</a>)""")
289    for item in re.finditer(link_tags, text):
290        final += text[idx:item.start()]
291        final += '<a href="%s" target="_blank" rel="noreferrer noopener">%s</a>' % (item.group(0), item.group(0))
292        idx = item.end()
293    final += text[idx:]
294    return final
295
296
297def html2plaintext(html, body_id=None, encoding='utf-8'):
298    """ From an HTML text, convert the HTML to plain text.
299    If @param body_id is provided then this is the tag where the
300    body (not necessarily <body>) starts.
301    """
302    ## (c) Fry-IT, www.fry-it.com, 2007
303    ## <peter@fry-it.com>
304    ## download here: http://www.peterbe.com/plog/html2plaintext
305
306    html = ustr(html)
307
308    if not html.strip():
309        return ''
310
311    tree = etree.fromstring(html, parser=etree.HTMLParser())
312
313    if body_id is not None:
314        source = tree.xpath('//*[@id=%s]' % (body_id,))
315    else:
316        source = tree.xpath('//body')
317    if len(source):
318        tree = source[0]
319
320    url_index = []
321    i = 0
322    for link in tree.findall('.//a'):
323        url = link.get('href')
324        if url:
325            i += 1
326            link.tag = 'span'
327            link.text = '%s [%s]' % (link.text, i)
328            url_index.append(url)
329
330    html = ustr(etree.tostring(tree, encoding=encoding))
331    # \r char is converted into &#13;, must remove it
332    html = html.replace('&#13;', '')
333
334    html = html.replace('<strong>', '*').replace('</strong>', '*')
335    html = html.replace('<b>', '*').replace('</b>', '*')
336    html = html.replace('<h3>', '*').replace('</h3>', '*')
337    html = html.replace('<h2>', '**').replace('</h2>', '**')
338    html = html.replace('<h1>', '**').replace('</h1>', '**')
339    html = html.replace('<em>', '/').replace('</em>', '/')
340    html = html.replace('<tr>', '\n')
341    html = html.replace('</p>', '\n')
342    html = re.sub('<br\s*/?>', '\n', html)
343    html = re.sub('<.*?>', ' ', html)
344    html = html.replace(' ' * 2, ' ')
345    html = html.replace('&gt;', '>')
346    html = html.replace('&lt;', '<')
347    html = html.replace('&amp;', '&')
348
349    # strip all lines
350    html = '\n'.join([x.strip() for x in html.splitlines()])
351    html = html.replace('\n' * 2, '\n')
352
353    for i, url in enumerate(url_index):
354        if i == 0:
355            html += '\n\n'
356        html += ustr('[%s] %s\n') % (i + 1, url)
357
358    return html.strip()
359
360def plaintext2html(text, container_tag=False):
361    """ Convert plaintext into html. Content of the text is escaped to manage
362        html entities, using misc.html_escape().
363        - all \n,\r are replaced by <br />
364        - enclose content into <p>
365        - convert url into clickable link
366        - 2 or more consecutive <br /> are considered as paragraph breaks
367
368        :param string container_tag: container of the html; by default the
369            content is embedded into a <div>
370    """
371    text = misc.html_escape(ustr(text))
372
373    # 1. replace \n and \r
374    text = text.replace('\n', '<br/>')
375    text = text.replace('\r', '<br/>')
376
377    # 2. clickable links
378    text = html_keep_url(text)
379
380    # 3-4: form paragraphs
381    idx = 0
382    final = '<p>'
383    br_tags = re.compile(r'(([<]\s*[bB][rR]\s*\/?[>]\s*){2,})')
384    for item in re.finditer(br_tags, text):
385        final += text[idx:item.start()] + '</p><p>'
386        idx = item.end()
387    final += text[idx:] + '</p>'
388
389    # 5. container
390    if container_tag:
391        final = '<%s>%s</%s>' % (container_tag, final, container_tag)
392    return ustr(final)
393
394def append_content_to_html(html, content, plaintext=True, preserve=False, container_tag=False):
395    """ Append extra content at the end of an HTML snippet, trying
396        to locate the end of the HTML document (</body>, </html>, or
397        EOF), and converting the provided content in html unless ``plaintext``
398        is False.
399        Content conversion can be done in two ways:
400        - wrapping it into a pre (preserve=True)
401        - use plaintext2html (preserve=False, using container_tag to wrap the
402            whole content)
403        A side-effect of this method is to coerce all HTML tags to
404        lowercase in ``html``, and strip enclosing <html> or <body> tags in
405        content if ``plaintext`` is False.
406
407        :param str html: html tagsoup (doesn't have to be XHTML)
408        :param str content: extra content to append
409        :param bool plaintext: whether content is plaintext and should
410            be wrapped in a <pre/> tag.
411        :param bool preserve: if content is plaintext, wrap it into a <pre>
412            instead of converting it into html
413    """
414    html = ustr(html)
415    if plaintext and preserve:
416        content = u'\n<pre>%s</pre>\n' % misc.html_escape(ustr(content))
417    elif plaintext:
418        content = '\n%s\n' % plaintext2html(content, container_tag)
419    else:
420        content = re.sub(r'(?i)(</?(?:html|body|head|!\s*DOCTYPE)[^>]*>)', '', content)
421        content = u'\n%s\n' % ustr(content)
422    # Force all tags to lowercase
423    html = re.sub(r'(</?)(\w+)([ >])',
424        lambda m: '%s%s%s' % (m.group(1), m.group(2).lower(), m.group(3)), html)
425    insert_location = html.find('</body>')
426    if insert_location == -1:
427        insert_location = html.find('</html>')
428    if insert_location == -1:
429        return '%s%s' % (html, content)
430    return '%s%s%s' % (html[:insert_location], content, html[insert_location:])
431
432
433def prepend_html_content(html_body, html_content):
434    """Prepend some HTML content at the beginning of an other HTML content."""
435    html_content = re.sub(r'(?i)(</?(?:html|body|head|!\s*DOCTYPE)[^>]*>)', '', html_content)
436    html_content = html_content.strip()
437
438    insert_index = next(re.finditer(r'<body[^>]*>', html_body), None)
439    if insert_index is None:
440        insert_index = next(re.finditer(r'<html[^>]*>', html_body), None)
441
442    insert_index = insert_index.end() if insert_index else 0
443
444    return html_body[:insert_index] + html_content + html_body[insert_index:]
445
446#----------------------------------------------------------
447# Emails
448#----------------------------------------------------------
449
450# matches any email in a body of text
451email_re = re.compile(r"""([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63})""", re.VERBOSE)
452
453# matches a string containing only one email
454single_email_re = re.compile(r"""^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$""", re.VERBOSE)
455
456mail_header_msgid_re = re.compile('<[^<>]+>')
457
458email_addr_escapes_re = re.compile(r'[\\"]')
459
460
461def generate_tracking_message_id(res_id):
462    """Returns a string that can be used in the Message-ID RFC822 header field
463
464       Used to track the replies related to a given object thanks to the "In-Reply-To"
465       or "References" fields that Mail User Agents will set.
466    """
467    try:
468        rnd = random.SystemRandom().random()
469    except NotImplementedError:
470        rnd = random.random()
471    rndstr = ("%.15f" % rnd)[2:]
472    return "<%s.%.15f-openerp-%s@%s>" % (rndstr, time.time(), res_id, socket.gethostname())
473
474def email_send(email_from, email_to, subject, body, email_cc=None, email_bcc=None, reply_to=False,
475               attachments=None, message_id=None, references=None, openobject_id=False, debug=False, subtype='plain', headers=None,
476               smtp_server=None, smtp_port=None, ssl=False, smtp_user=None, smtp_password=None, cr=None, uid=None):
477    """Low-level function for sending an email (deprecated).
478
479    :deprecate: since OpenERP 6.1, please use ir.mail_server.send_email() instead.
480    :param email_from: A string used to fill the `From` header, if falsy,
481                       config['email_from'] is used instead.  Also used for
482                       the `Reply-To` header if `reply_to` is not provided
483    :param email_to: a sequence of addresses to send the mail to.
484    """
485
486    # If not cr, get cr from current thread database
487    local_cr = None
488    if not cr:
489        db_name = getattr(threading.currentThread(), 'dbname', None)
490        if db_name:
491            local_cr = cr = odoo.registry(db_name).cursor()
492        else:
493            raise Exception("No database cursor found, please pass one explicitly")
494
495    # Send Email
496    try:
497        mail_server_pool = odoo.registry(cr.dbname)['ir.mail_server']
498        res = False
499        # Pack Message into MIME Object
500        email_msg = mail_server_pool.build_email(email_from, email_to, subject, body, email_cc, email_bcc, reply_to,
501                   attachments, message_id, references, openobject_id, subtype, headers=headers)
502
503        res = mail_server_pool.send_email(cr, uid or 1, email_msg, mail_server_id=None,
504                       smtp_server=smtp_server, smtp_port=smtp_port, smtp_user=smtp_user, smtp_password=smtp_password,
505                       smtp_encryption=('ssl' if ssl else None), smtp_debug=debug)
506    except Exception:
507        _logger.exception("tools.email_send failed to deliver email")
508        return False
509    finally:
510        if local_cr:
511            cr.close()
512    return res
513
514def email_split_tuples(text):
515    """ Return a list of (name, email) addresse tuples found in ``text``"""
516    if not text:
517        return []
518    return [(addr[0], addr[1]) for addr in getaddresses([text])
519                # getaddresses() returns '' when email parsing fails, and
520                # sometimes returns emails without at least '@'. The '@'
521                # is strictly required in RFC2822's `addr-spec`.
522                if addr[1]
523                if '@' in addr[1]]
524
525def email_split(text):
526    """ Return a list of the email addresses found in ``text`` """
527    if not text:
528        return []
529    return [email for (name, email) in email_split_tuples(text)]
530
531def email_split_and_format(text):
532    """ Return a list of email addresses found in ``text``, formatted using
533    formataddr. """
534    if not text:
535        return []
536    return [formataddr((name, email)) for (name, email) in email_split_tuples(text)]
537
538def email_normalize(text):
539    """ Sanitize and standardize email address entries.
540        A normalized email is considered as :
541        - having a left part + @ + a right part (the domain can be without '.something')
542        - being lower case
543        - having no name before the address. Typically, having no 'Name <>'
544        Ex:
545        - Possible Input Email : 'Name <NaMe@DoMaIn.CoM>'
546        - Normalized Output Email : 'name@domain.com'
547    """
548    emails = email_split(text)
549    if not emails or len(emails) != 1:
550        return False
551    return emails[0].lower()
552
553def email_escape_char(email_address):
554    """ Escape problematic characters in the given email address string"""
555    return email_address.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
556
557
558def email_domain_extract(email):
559    """Return the domain of the given email."""
560    if not email:
561        return
562
563    email_split = getaddresses([email])
564    if not email_split or not email_split[0]:
565        return
566
567    _, _, domain = email_split[0][1].rpartition('@')
568    return domain
569
570# was mail_thread.decode_header()
571def decode_message_header(message, header, separator=' '):
572    return separator.join(h for h in message.get_all(header, []) if h)
573
574def formataddr(pair, charset='utf-8'):
575    """Pretty format a 2-tuple of the form (realname, email_address).
576
577    If the first element of pair is falsy then only the email address
578    is returned.
579
580    Set the charset to ascii to get a RFC-2822 compliant email. The
581    realname will be base64 encoded (if necessary) and the domain part
582    of the email will be punycode encoded (if necessary). The local part
583    is left unchanged thus require the SMTPUTF8 extension when there are
584    non-ascii characters.
585
586    >>> formataddr(('John Doe', 'johndoe@example.com'))
587    '"John Doe" <johndoe@example.com>'
588
589    >>> formataddr(('', 'johndoe@example.com'))
590    'johndoe@example.com'
591    """
592    name, address = pair
593    local, _, domain = address.rpartition('@')
594
595    try:
596        domain.encode(charset)
597    except UnicodeEncodeError:
598        # rfc5890 - Internationalized Domain Names for Applications (IDNA)
599        domain = idna.encode(domain).decode('ascii')
600
601    if name:
602        try:
603            name.encode(charset)
604        except UnicodeEncodeError:
605            # charset mismatch, encode as utf-8/base64
606            # rfc2047 - MIME Message Header Extensions for Non-ASCII Text
607            name = base64.b64encode(name.encode('utf-8')).decode('ascii')
608            return f"=?utf-8?b?{name}?= <{local}@{domain}>"
609        else:
610            # ascii name, escape it if needed
611            # rfc2822 - Internet Message Format
612            #   #section-3.4 - Address Specification
613            name = email_addr_escapes_re.sub(r'\\\g<0>', name)
614            return f'"{name}" <{local}@{domain}>'
615    return f"{local}@{domain}"
616
617
618def encapsulate_email(old_email, new_email):
619    """Change the FROM of the message and use the old one as name.
620
621    e.g.
622    * Old From: "Admin" <admin@gmail.com>
623    * New From: notifications@odoo.com
624    * Output:   "Admin (admin@gmail.com)" <notifications@odoo.com>
625    """
626    old_email_split = getaddresses([old_email])
627    if not old_email_split or not old_email_split[0]:
628        return old_email
629
630    new_email_split = getaddresses([new_email])
631    if not new_email_split or not new_email_split[0]:
632        return
633
634    if old_email_split[0][0]:
635        name_part = '%s (%s)' % old_email_split[0]
636    else:
637        name_part = old_email_split[0][1]
638
639    return formataddr((
640        name_part,
641        new_email_split[0][1],
642    ))
643