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'<%', u'<%') 231 cleaned = cleaned.replace(u'%>', u'%>') 232 # html considerations so real html content match database value 233 cleaned.replace(u'\xa0', u' ') 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 , must remove it 332 html = html.replace(' ', '') 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('>', '>') 346 html = html.replace('<', '<') 347 html = html.replace('&', '&') 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