From cd3395b233828403017c734448641716de80fe7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Wed, 21 Feb 2024 21:43:50 +0100 Subject: [PATCH] Add email constructor from message --- modules/account_dunning_email/account.py | 40 ++++--------- modules/account_payment_stripe/payment.py | 2 +- .../marketing_automation.py | 59 ++++++++----------- modules/notification_email/notification.py | 31 ++++------ .../notification_email/tests/test_module.py | 6 +- trytond/doc/ref/tools/email.rst | 8 +++ trytond/trytond/ir/email_.py | 46 ++++++--------- trytond/trytond/tools/email_.py | 13 +++- 8 files changed, 92 insertions(+), 113 deletions(-) diff --git a/modules/account_dunning_email/account.py b/modules/account_dunning_email/account.py index 29f58a5da2..69b0443385 100644 --- a/modules/account_dunning_email/account.py +++ b/modules/account_dunning_email/account.py @@ -1,7 +1,5 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. -from email.header import Header -from email.utils import formataddr, getaddresses from trytond.config import config from trytond.model import fields @@ -9,18 +7,11 @@ from trytond.pool import Pool, PoolMeta from trytond.pyson import Bool, Eval from trytond.report import get_email from trytond.sendmail import SMTPDataManager, send_message_transactional -from trytond.tools.email_ import convert_ascii_email, set_from_header +from trytond.tools.email_ import format_address, has_rcpt, set_from_header from trytond.transaction import Transaction from trytond.wizard import StateTransition -def _formataddr(pair): - name, address = pair - if name: - name = str(Header(name, 'utf-8')) - return formataddr((name, convert_ascii_email(address))) - - class Configuration(metaclass=PoolMeta): __name__ = 'account.configuration' @@ -126,11 +117,11 @@ class Dunning(metaclass=PoolMeta): 'email', usage=self.level.email_contact_mechanism) if contact and contact.email: name = contact.name or self.party.rec_name - to.append(_formataddr((name, contact.email))) + to.append(format_address(contact.email, name)) elif account_config.dunning_email_fallback: user = account_config.get_multivalue( 'dunning_email_fallback', company=self.company.id) - to.append(_formataddr((self.party.rec_name, user.email))) + to.append(format_address(user.email, self.party.rec_name)) cc = [] bcc = [] languages = set() @@ -142,20 +133,11 @@ class Dunning(metaclass=PoolMeta): ], limit=1) languages.add(lang) - msg, title = self._email(from_, to, cc, bcc, languages) - addr_fields = filter(None, (msg['To'], msg['Cc'], msg['Bcc'])) - to_addrs = [e for _, e in getaddresses(addr_fields)] - if to_addrs: + msg = self._email(from_, to, cc, bcc, languages) + if has_rcpt(msg): send_message_transactional(msg, datamanager=datamanager) - return Email( - recipients=msg['To'], - recipients_secondary=msg['Cc'], - recipients_hidden=msg['Bcc'], - addresses=[{'address': a} for a in to_addrs], - subject=title, - resource=self, - dunning_level=self.level, - ) + return Email.from_message( + msg, resource=self, dunning_level=self.level) def _email(self, sender, to, cc, bcc, languages): # TODO order languages to get default as last one for title @@ -167,9 +149,9 @@ class Dunning(metaclass=PoolMeta): if dunning.level.email_from: from_ = dunning.level.email_from set_from_header(msg, sender, from_) - msg['To'] = ', '.join(to) - msg['Cc'] = ', '.join(cc) - msg['Bcc'] = ', '.join(bcc) + msg['To'] = to + msg['Cc'] = cc + msg['Bcc'] = bcc msg['Subject'] = title msg['Auto-Submitted'] = 'auto-generated' - return msg, title + return msg diff --git a/modules/account_payment_stripe/payment.py b/modules/account_payment_stripe/payment.py index f83686ad5b..0c936fc997 100644 --- a/modules/account_payment_stripe/payment.py +++ b/modules/account_payment_stripe/payment.py @@ -389,7 +389,7 @@ class Payment(StripeCustomerMethodMixin, CheckoutMixin, metaclass=PoolMeta): msg, title = get_email( 'account.payment.stripe.email_checkout', self, languages) set_from_header(msg, from_cfg, from_ or from_cfg) - msg['To'] = ','.join(emails) + msg['To'] = emails msg['Subject'] = title send_message_transactional(msg) diff --git a/modules/marketing_automation/marketing_automation.py b/modules/marketing_automation/marketing_automation.py index 4614f7b92b..aae2b96390 100644 --- a/modules/marketing_automation/marketing_automation.py +++ b/modules/marketing_automation/marketing_automation.py @@ -5,9 +5,7 @@ import logging import time import uuid from collections import defaultdict -from email.headerregistry import Address from email.message import EmailMessage -from email.utils import getaddresses from functools import partial from urllib.parse import ( parse_qsl, quote, urlencode, urljoin, urlsplit, urlunsplit) @@ -39,7 +37,7 @@ from trytond.pyson import Eval, If, PYSONDecoder, TimeDelta from trytond.report import Report from trytond.sendmail import SMTPDataManager, send_message_transactional from trytond.tools import grouped_slice, pairwise_longest, reduce_ids -from trytond.tools.email_ import convert_ascii_email, set_from_header +from trytond.tools.email_ import format_address, has_rcpt, set_from_header from trytond.transaction import Transaction from trytond.url import http_host from trytond.wsgi import Base64Converter @@ -652,9 +650,8 @@ class Activity( party = record.marketing_party contact = party.contact_mechanism_get('email') if contact and contact.email: - return Address( - contact.name or party.rec_name, - addr_spec=contact.email) + return format_address( + contact.email, contact.name or party.rec_name) def execute_send_email( self, record_activity, smtpd_datamanager=None, **kwargs): @@ -668,8 +665,6 @@ class Activity( translated = self.__class__(self.id) to = self._email_recipient(record.record) - if not to: - return def unsubscribe(redirect): parts = urlsplit(urljoin( @@ -727,35 +722,14 @@ class Activity( .filter(convert_href) .render()) - from_ = (config.get('marketing', 'email_from') - or config.get('email', 'from')) - msg = EmailMessage() - set_from_header(msg, from_, translated.email_from or from_) - msg['To'] = to - msg['Subject'] = title - if html2text: - converter = html2text.HTML2Text() - content_text = converter.handle(content) - msg.add_alternative(content_text, subtype='plain') - if msg.is_multipart(): - msg.add_alternative(content, subtype='html') - else: - msg.set_content(content, subtype='html') + msg = self._email(translated.email_from, to, title, content) - to_addrs = [convert_ascii_email(a) for _, a in getaddresses([str(to)])] - if to_addrs: + if has_rcpt(msg): send_message_transactional(msg, datamanager=smtpd_datamanager) - - email = Email( - recipients=msg['To'], - recipients_secondary=msg['Cc'], - recipients_hidden=msg['Bcc'], - addresses=[{'address': a} for a in to_addrs], - subject=title, - resource=record.record, + email = Email.from_message( + msg, resource=record.record, marketing_automation_activity=self, - marketing_automation_record=record, - ) + marketing_automation_record=record) email.save() def email_context(self, record): @@ -769,6 +743,23 @@ class Activity( 'datetime': datetime, } + def _email(self, sender, to, subject, content): + msg = EmailMessage() + from_ = (config.get('marketing', 'email_from') + or config.get('email', 'from')) + set_from_header(msg, from_, sender or from_) + msg['To'] = to + msg['Subject'] = subject + if html2text: + converter = html2text.HTML2Text() + content_text = converter.handle(content) + msg.add_alternative(content_text, subtype='plain') + if msg.is_multipart(): + msg.add_alternative(content, subtype='html') + else: + msg.set_content(content, subtype='html') + return msg + class Record(ModelSQL, ModelView): "Marketing Record" diff --git a/modules/notification_email/notification.py b/modules/notification_email/notification.py index e679337f73..8a6040a428 100644 --- a/modules/notification_email/notification.py +++ b/modules/notification_email/notification.py @@ -1,7 +1,6 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. import mimetypes -from email.headerregistry import Address from email.utils import getaddresses from genshi.template import TextTemplate @@ -13,7 +12,7 @@ from trytond.pool import Pool from trytond.pyson import Eval, TimeDelta from trytond.report import get_email from trytond.sendmail import SMTPDataManager, send_message_transactional -from trytond.tools.email_ import set_from_header +from trytond.tools.email_ import format_address, has_rcpt, set_from_header from trytond.transaction import Transaction from .exceptions import TemplateError @@ -157,7 +156,10 @@ class Email(ModelSQL, ModelView): pool = Pool() EmailTemplate = pool.get('ir.email.template') with Transaction().set_context(usage=self.contact_mechanism): - return EmailTemplate.get_addresses(value) + # reformat addresses with encoding + return [ + format_address(e, n) + for n, e in getaddresses(EmailTemplate.get_addresses(value))] def _get_languages(self, value): pool = Pool() @@ -199,12 +201,12 @@ class Email(ModelSQL, ModelView): filename=('utf-8', '', name)) set_from_header(msg, sender, from_) - msg['To'] = [Address(n, addr_spec=a) for n, a in getaddresses(to)] - msg['Cc'] = [Address(n, addr_spec=a) for n, a in getaddresses(cc)] - msg['Bcc'] = [Address(n, addr_spec=a) for n, a in getaddresses(bcc)] + msg['To'] = to + msg['Cc'] = cc + msg['Bcc'] = bcc msg['Subject'] = title msg['Auto-Submitted'] = 'auto-generated' - return msg, title + return msg @classmethod def trigger(cls, records, trigger): @@ -243,18 +245,11 @@ class Email(ModelSQL, ModelView): cc, cc_languages = self._get_cc(record) bcc, bcc_languages = self._get_bcc(record) languages = to_languages | cc_languages | bcc_languages - msg, title = self.get_email(record, from_, to, cc, bcc, languages) - to_addrs = [e for _, e in getaddresses( - filter(None, (msg['To'], msg['Cc'], msg['Bcc'])))] - if to_addrs: + msg = self.get_email(record, from_, to, cc, bcc, languages) + if has_rcpt(msg): send_message_transactional(msg, datamanager=datamanager) - emails.append(Email( - recipients=msg['To'], - recipients_secondary=msg['Cc'], - recipients_hidden=msg['Bcc'], - addresses=[{'address': a} for a in to_addrs], - subject=title, - resource=record, + emails.append(Email.from_message( + msg, resource=record, notification_email=trigger.notification_email, notification_trigger=trigger)) Email.save(emails) diff --git a/modules/notification_email/tests/test_module.py b/modules/notification_email/tests/test_module.py index 89ec08d20d..bab5b59224 100644 --- a/modules/notification_email/tests/test_module.py +++ b/modules/notification_email/tests/test_module.py @@ -237,7 +237,7 @@ class NotificationEmailTestCase(CompanyTestMixin, ModuleTestCase): user, = User.create([{'name': "Michael Scott", 'login': "msc"}]) - msg, title = notification_email.get_email( + msg = notification_email.get_email( user, FROM, ['Administrator '], [], [], [en]) self.assertEqual(msg['From'], FROM) @@ -275,7 +275,7 @@ class NotificationEmailTestCase(CompanyTestMixin, ModuleTestCase): user, = User.create([{'name': "Michael Scott", 'login': "msc"}]) - msg, title = notification_email.get_email( + msg = notification_email.get_email( user, FROM, ['Administrator '], [], [], [en]) self.assertEqual(msg['Subject'], 'Notification for Michael Scott') @@ -311,7 +311,7 @@ class NotificationEmailTestCase(CompanyTestMixin, ModuleTestCase): 'language': es.id, }]) - msg, title = notification_email.get_email( + msg = notification_email.get_email( user, FROM, ['Administrator '], [], [], [es]) self.assertEqual(msg['Subject'], 'Notificación para Michael Scott') diff --git a/trytond/doc/ref/tools/email.rst b/trytond/doc/ref/tools/email.rst index 97b3b9e29d..cffdc054bd 100644 --- a/trytond/doc/ref/tools/email.rst +++ b/trytond/doc/ref/tools/email.rst @@ -8,6 +8,14 @@ Email Fill email headers to appear at best from the address. +.. function:: has_rcpt(message) + + Return if the :py:class:`~email.message.Message` has any recipient. + +.. function:: format_address(email[, name]) + + Return a string suitable for an RFC 2822 From, To or Cc header. + .. function:: validate_email(email) Validate the email address. diff --git a/trytond/trytond/ir/email_.py b/trytond/trytond/ir/email_.py index 91b2059933..7ca6036e94 100644 --- a/trytond/trytond/ir/email_.py +++ b/trytond/trytond/ir/email_.py @@ -4,7 +4,7 @@ import heapq import mimetypes import re from email.message import EmailMessage -from email.utils import formataddr, getaddresses +from email.utils import getaddresses try: import html2text @@ -22,7 +22,8 @@ from trytond.report import Report from trytond.rpc import RPC from trytond.sendmail import send_message_transactional from trytond.tools import escape_wildcard -from trytond.tools.email_ import convert_ascii_email, set_from_header +from trytond.tools.email_ import ( + convert_ascii_email, format_address, set_from_header) from trytond.tools.string_ import StringMatcher from trytond.transaction import Transaction @@ -43,11 +44,6 @@ class EmailTemplateError(ValidationError): pass -def _get_emails(value): - "Return list of email from the comma separated list" - return [e for n, e in getaddresses([value]) if e] - - def _formataddr(pair): "Format address without encoding" name, address = pair @@ -158,30 +154,14 @@ class Email(ResourceAccessMixin, ModelSQL, ModelView): filename=('utf-8', '', name)) from_ = config.get('email', 'from') set_from_header(msg, from_, user.email or from_) - msg['To'] = ', '.join( - formataddr((n, convert_ascii_email(a))) - for n, a in getaddresses([to])) - msg['Cc'] = ', '.join( - formataddr((n, convert_ascii_email(a))) - for n, a in getaddresses([cc])) - msg['Bcc'] = ', '.join( - formataddr((n, convert_ascii_email(a))) - for n, a in getaddresses([bcc])) + msg['To'] = [format_address(a, n) for n, a in getaddresses([to])] + msg['Cc'] = [format_address(a, n) for n, a in getaddresses([cc])] + msg['Bcc'] = [format_address(a, n) for n, a in getaddresses([bcc])] msg['Subject'] = subject - to_addrs = list(filter(None, map( - str.strip, - _get_emails(to) + _get_emails(cc) + _get_emails(bcc)))) send_message_transactional(msg, strict=True) - email = cls( - recipients=msg['To'], - recipients_secondary=msg['Cc'], - recipients_hidden=msg['Bcc'], - addresses=[{'address': a} for a in to_addrs], - subject=subject, - body=body, - resource=record) + email = cls.from_message(msg, body=body, resource=record) email.save() if files: attachments_ = [] @@ -236,6 +216,18 @@ class Email(ResourceAccessMixin, ModelSQL, ModelView): ], order=[]): yield user.name, user.email + @classmethod + def from_message(cls, msg, **values): + to_addrs = [e for _, e in getaddresses( + filter(None, (msg['To'], msg['Cc'], msg['Bcc'])))] + return cls( + recipients=msg['To'], + recipients_secondary=msg['Cc'], + recipients_hidden=msg['Bcc'], + addresses=[{'address': a} for a in to_addrs], + subject=msg['Subject'], + **values) + class EmailAddress(ModelSQL): "Email Address" diff --git a/trytond/trytond/tools/email_.py b/trytond/trytond/tools/email_.py index b5a1570918..c21366873f 100644 --- a/trytond/trytond/tools/email_.py +++ b/trytond/trytond/tools/email_.py @@ -1,6 +1,7 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. -from email.utils import parseaddr +from email.header import Header +from email.utils import formataddr, parseaddr from trytond.pool import Pool @@ -29,6 +30,10 @@ def set_from_header(message, sender, from_): message['From'] = from_ +def has_rcpt(msg): + return any((msg['To'], msg['Cc'], msg['Bcc'])) + + try: from dns.exception import DNSException from email_validator import EmailNotValidError, caching_resolver @@ -80,3 +85,9 @@ except ImportError: class EmailNotValidError(Exception): pass + + +def format_address(email, name=None): + if name: + name = str(Header(name, 'utf-8')) + return formataddr((name, convert_ascii_email(email)))