From 82e64bf66a6aedbf07c8fd7acf4fc637cf2c7468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Thu, 17 Sep 2020 17:18:02 +0200 Subject: [PATCH] Send client email via server issue9449 review292001002 --- CHANGELOG | 2 + trytond/ir/__init__.py | 5 + trytond/ir/action.py | 38 +- trytond/ir/email.xml | 91 ++++ trytond/ir/email_.py | 478 +++++++++++++++++++++ trytond/ir/message.xml | 8 +- trytond/ir/tryton.cfg | 1 + trytond/ir/view/action_report_form.xml | 2 - trytond/ir/view/action_wizard_form.xml | 2 - trytond/ir/view/email_form.xml | 19 + trytond/ir/view/email_list.xml | 13 + trytond/ir/view/email_template_form.xml | 20 + trytond/ir/view/email_template_list.xml | 7 + trytond/model/modelview.py | 5 + trytond/res/ir.xml | 15 + trytond/res/view/user_form.xml | 2 +- trytond/res/view/user_form_preferences.xml | 2 +- trytond/tests/test_ir.py | 118 +++++ 18 files changed, 785 insertions(+), 43 deletions(-) create mode 100644 trytond/ir/email.xml create mode 100644 trytond/ir/email_.py create mode 100644 trytond/ir/view/email_form.xml create mode 100644 trytond/ir/view/email_list.xml create mode 100644 trytond/ir/view/email_template_form.xml create mode 100644 trytond/ir/view/email_template_list.xml diff --git a/CHANGELOG b/CHANGELOG index 95617995eb..120570727e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Add e-mail template +* Send e-mail on behalf of user * Register mixins to generic Report class * Cache in memory the report template instances * Support Genshi's MsgDirective in report diff --git a/trytond/ir/__init__.py b/trytond/ir/__init__.py index cb003cb51d..bec2e0c672 100644 --- a/trytond/ir/__init__.py +++ b/trytond/ir/__init__.py @@ -21,6 +21,7 @@ from . import session from . import queue from . import calendar_ from . import message +from . import email_ from . import routes __all__ = ['register', 'routes'] @@ -94,6 +95,10 @@ def register(): calendar_.Month, calendar_.Day, message.Message, + email_.Email, + email_.EmailAddress, + email_.EmailTemplate, + email_.EmailTemplate_Report, module='ir', type_='model') Pool.register( translation.TranslationSet, diff --git a/trytond/ir/action.py b/trytond/ir/action.py index afc23ef73a..d708d08e72 100644 --- a/trytond/ir/action.py +++ b/trytond/ir/action.py @@ -107,7 +107,7 @@ class Action(DeactivableMixin, ModelSQL, ModelView): columns = [] columns += ['id', 'name', 'type', 'icon.rec_name'] if type_ == 'ir.action.report': - columns += ['report_name', 'direct_print', 'email'] + columns += ['report_name', 'direct_print'] elif type_ == 'ir.action.act_window': columns += [ 'views', 'domains', 'res_model', 'limit', @@ -115,7 +115,7 @@ class Action(DeactivableMixin, ModelSQL, ModelView): 'pyson_domain', 'pyson_context', 'pyson_order', 'pyson_search_value'] elif type_ == 'ir.action.wizard': - columns += ['wiz_name', 'window', 'email'] + columns += ['wiz_name', 'window'] elif type_ == 'ir.action.url': columns += ['url'] return Action.read(action_ids, columns) @@ -540,10 +540,6 @@ class ActionReport(ActionMixin, ModelSQL, ModelView): string='Extension', help='Leave empty for the same as template, ' 'see LibreOffice documentation for compatible format.') module = fields.Char('Module', readonly=True, select=True) - email = fields.Char('Email', - help='Python dictonary where keys define "to" "cc" "subject"\n' - "Example: {'to': 'test@example.com', 'cc': 'user@example.com'}") - pyson_email = fields.Function(fields.Char('PySON Email'), 'get_pyson') _template_cache = MemoryCache('ir.action.report.template', context=False) @classmethod @@ -596,31 +592,6 @@ class ActionReport(ActionMixin, ModelSQL, ModelView): def default_module(): return Transaction().context.get('module') or '' - @classmethod - def validate(cls, reports): - super(ActionReport, cls).validate(reports) - cls.check_email(reports) - - @classmethod - def check_email(cls, reports): - "Check email" - for report in reports: - if report.email: - try: - value = PYSONDecoder().decode(report.email) - except Exception: - value = None - if isinstance(value, dict): - inkeys = set(value) - if not inkeys <= EMAIL_REFKEYS: - raise EmailError( - gettext('ir.msg_report_invalid_email', - name=report.rec_name)) - else: - raise EmailError( - gettext('ir.msg_report_invalid_email', - name=report.rec_name)) - def get_is_custom(self, name): return bool(self.report_content_custom) @@ -670,9 +641,7 @@ class ActionReport(ActionMixin, ModelSQL, ModelView): def get_pyson(cls, reports, name): pysons = {} field = name[6:] - defaults = { - 'email': '{}', - } + defaults = {} for report in reports: pysons[report.id] = (getattr(report, field) or defaults.get(field, 'null')) @@ -1051,7 +1020,6 @@ class ActionWizard(ActionMixin, ModelSQL, ModelView): action = fields.Many2One('ir.action', 'Action', required=True, ondelete='CASCADE') model = fields.Char('Model') - email = fields.Char('Email') window = fields.Boolean('Window', help='Run wizard in a new window.') @staticmethod diff --git a/trytond/ir/email.xml b/trytond/ir/email.xml new file mode 100644 index 0000000000..dba0ddde5d --- /dev/null +++ b/trytond/ir/email.xml @@ -0,0 +1,91 @@ + + + + + + ir.email + form + email_form + + + ir.email + tree + email_list + + + + E-mails + ir.email + + + + + + + + + + + + + + + + + + + + + + + E-mails + ir.email + + + + + + + + + + + + + + form_relate + + + + + ir.email.template + form + email_template_form + + + ir.email.template + tree + email_template_list + + + + E-mail Templates + ir.email.template + + + + + + + + + + + + + + diff --git a/trytond/ir/email_.py b/trytond/ir/email_.py new file mode 100644 index 0000000000..b4a9b96449 --- /dev/null +++ b/trytond/ir/email_.py @@ -0,0 +1,478 @@ +# 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 heapq +import mimetypes +import re +from email.encoders import encode_base64 +from email.header import Header +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart +from email.mime.nonmultipart import MIMENonMultipart +from email.mime.text import MIMEText +from email.utils import formataddr, getaddresses + +try: + import html2text +except ImportError: + html2text = None +from genshi.template import TextTemplate + +from trytond.i18n import gettext +from trytond.config import config +from trytond.model import ModelSQL, ModelView, fields +from trytond.model.exceptions import ValidationError, AccessError +from trytond.pool import Pool +from trytond.pyson import Eval +from trytond.rpc import RPC +from trytond.sendmail import sendmail_transactional, SMTPDataManager +from trytond.tools import escape_wildcard +from trytond.tools.string_ import StringMatcher +from trytond.transaction import Transaction + +from .resource import ResourceAccessMixin + +HTML_EMAIL = """ + +%(subject)s +%(body)s
+

+%(signature)s +""" +specialsre = re.compile(r'[][\\()<>@,:;".]') +escapesre = re.compile(r'[\\"]') + + +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 + address.encode('ascii') + if name: + quotes = '' + if specialsre.search(name): + quotes = '"' + name = escapesre.sub(r'\\\g<0>', name) + return '%s%s%s <%s>' % (quotes, name, quotes, address) + return address + + +class Email(ResourceAccessMixin, ModelSQL, ModelView): + "Email" + __name__ = 'ir.email' + + user = fields.Function(fields.Char("User"), 'get_user') + at = fields.Function(fields.DateTime("At"), 'get_at') + recipients = fields.Char("Recipients") + recipients_secondary = fields.Char("Secondary Recipients") + recipients_hidden = fields.Char("Hidden Recipients") + addresses = fields.One2Many('ir.email.address', 'email', "Addresses") + subject = fields.Char("Subject") + body = fields.Char("Body") + + @classmethod + def __setup__(cls): + super().__setup__() + cls._order.insert(0, ('create_date', 'DESC')) + cls.__rpc__.update({ + 'send': RPC(readonly=False, result=int), + 'complete': RPC(), + }) + del cls.__rpc__['create'] + + def get_user(self, name): + return self.create_uid.rec_name + + def get_at(self, name): + return self.create_date.replace(microsecond=0) + + @classmethod + def send(cls, to='', cc='', bcc='', subject='', body='', + attachments=None, record=None, reports=None): + pool = Pool() + User = pool.get('res.user') + ActionReport = pool.get('ir.action.report') + Attachment = pool.get('ir.attachment') + transaction = Transaction() + user = User(transaction.user) + + Model = pool.get(record[0]) + record = Model(record[1]) + + body_html = HTML_EMAIL % { + 'subject': subject, + 'body': body, + 'signature': user.signature or '', + } + content = MIMEMultipart('alternative') + if html2text: + body_text = HTML_EMAIL % { + 'subject': subject, + 'body': body, + 'signature': '', + } + converter = html2text.HTML2Text() + body_text = converter.handle(body_text) + if user.signature: + body_text += '\n-- \n' + converter.handle(user.signature) + part = MIMEText(body_text, 'plain', _charset='utf-8') + content.attach(part) + part = MIMEText(body_html, 'html', _charset='utf-8') + content.attach(part) + if reports or attachments: + msg = MIMEMultipart('mixed') + msg.attach(content) + if attachments is None: + attachments = [] + else: + attachments = list(attachments) + + for report_id in (reports or []): + report = ActionReport(report_id) + Report = pool.get(report.report_name, type='report') + ext, content, _, title = Report.execute( + [record.id], { + 'action_id': report.id, + }) + name = '%s.%s' % (title, ext) + if isinstance(content, str): + content = content.encode('utf-8') + attachments.append((name, content)) + + for name, data in attachments: + mimetype, _ = mimetypes.guess_type(name) + if mimetype: + attachment = MIMENonMultipart(*mimetype.split('/')) + attachment.set_payload(data) + encode_base64(attachment) + else: + attachment = MIMEApplication(data) + attachment.add_header( + 'Content-Disposition', 'attachment', + filename=('utf-8', '', name)) + msg.attach(attachment) + else: + msg = content + msg['From'] = from_ = config.get('email', 'from') + if user.email: + if user.name: + user_email = formataddr((user.name, user.email)) + else: + user_email = user.email + msg['Behalf-Of'] = user_email + msg['Reply-To'] = user_email + msg['To'] = ', '.join(formataddr(a) for a in getaddresses([to])) + msg['Cc'] = ', '.join(formataddr(a) for a in getaddresses([cc])) + msg['Subject'] = Header(subject, 'utf-8') + + to_addrs = list(filter(None, map( + str.strip, + _get_emails(to) + _get_emails(cc) + _get_emails(bcc)))) + sendmail_transactional( + from_, to_addrs, msg, datamanager=SMTPDataManager(strict=True)) + + email = cls( + recipients=to, + recipients_secondary=cc, + recipients_hidden=bcc, + addresses=[{'address': a} for a in to_addrs], + subject=subject, + body=body, + resource=record) + email.save() + with Transaction().set_context(_check_access=False): + attachments_ = [] + for name, data in attachments: + attachments_.append( + Attachment(resource=email, name=name, data=data)) + Attachment.save(attachments_) + return email + + @classmethod + def complete(cls, text, limit): + limit = int(limit) + if not limit > 0: + raise ValueError('limit must be > 0: %r' % (limit,)) + emails = getaddresses([text]) + if not emails: + return [] + name, email = map(str.strip, emails[-1]) + if not name and not email: + return [] + s = StringMatcher() + s.set_seq2(_formataddr((name, email))) + + def generate(name, email): + for name, email in cls._match(name, email): + address = _formataddr((name, email)) + s.set_seq1(address) + yield ( + s.ratio(), address, + ', '.join(map(_formataddr, emails[:-1] + [(name, email)]))) + return heapq.nlargest(limit, generate(name, email)) + + @classmethod + def _match(cls, name, email): + pool = Pool() + User = pool.get('res.user') + domain = ['OR'] + for field in ['name', 'login', 'email']: + for value in [name, email]: + if value and len(value) >= 3: + domain.append( + (field, 'ilike', '%' + escape_wildcard(value) + '%')) + for user in User.search([ + ('email', '!=', ''), + domain, + ], order=[]): + yield user.name, user.email + + +class EmailAddress(ModelSQL): + "Email Address" + __name__ = 'ir.email.address' + + email = fields.Many2One( + 'ir.email', "E-mail", required=True, ondelete='CASCADE', select=True) + address = fields.Char("Address", required=True, select=True) + + +class EmailTemplate(ModelSQL, ModelView): + "Email Template" + __name__ = 'ir.email.template' + + model = fields.Many2One('ir.model', "Model", required=True) + name = fields.Char("Name", required=True, translate=True) + recipients = fields.Many2One( + 'ir.model.field', "Recipients", + domain=[ + ('model', '=', Eval('model')), + ], + depends=['model'], + help="The field that contains the recipient(s).") + recipients_secondary = fields.Many2One( + 'ir.model.field', "Secondary Recipients", + domain=[ + ('model', '=', Eval('model')), + ], + depends=['model'], + help="The field that contains the secondary recipient(s).") + recipients_hidden = fields.Many2One( + 'ir.model.field', "Hidden Recipients", + domain=[ + ('model', '=', Eval('model')), + ], + depends=['model'], + help="The field that contains the secondary recipient(s).") + subject = fields.Char("Subject", translate=True) + body = fields.Text("Body", translate=True) + reports = fields.Many2Many( + 'ir.email.template-ir.action.report', 'template', 'report', + "Reports", + domain=[ + ('model', '=', Eval('model_name')), + ], + depends=['model_name']) + + model_name = fields.Function( + fields.Char("Model Name"), 'on_change_with_model_name') + + @classmethod + def __setup__(cls): + super().__setup__() + for field in [ + 'recipients', + 'recipients_secondary', + 'recipients_hidden', + ]: + field = getattr(cls, field) + field.domain.append(['OR', + ('relation', 'in', cls.email_models()), + [ + ('model.model', 'in', cls.email_models()), + ('name', '=', 'id'), + ], + ]) + cls.__rpc__.update({ + 'get': RPC(instantiate=0), + 'get_default': RPC(), + }) + + @fields.depends('model') + def on_change_with_model_name(self, name=None): + if self.model: + return self.model.model + + @classmethod + def validate(cls, templates): + super().validate(templates) + for template in templates: + template.check_subject() + template.check_body() + + def check_subject(self): + if not self.subject: + return + try: + TextTemplate(self.subject) + except Exception as exception: + raise EmailTemplateError( + gettext('ir.msg_email_template_invalid_subject', + template=self.rec_name, + exception=exception)) from exception + + def check_body(self): + if not self.body: + return + try: + TextTemplate(self.body) + except Exception as exception: + raise EmailTemplateError( + gettext('ir.msg_email_template_invalid_body', + template=self.rec_name, + exception=exception)) from exception + + def get(self, record): + pool = Pool() + Model = pool.get(self.model.model) + record = Model(int(record)) + + values = {} + for field, key in [ + ('recipients', 'to'), + ('recipients_secondary', 'cc'), + ('recipients_hidden', 'bcc'), + ]: + field = getattr(self, field) + if not field: + continue + try: + if field.name == 'id': + value = record + else: + value = getattr(record, field.name, None) + if value: + values[key] = self.get_addresses(value) + except AccessError: + continue + + if self.subject: + try: + values['subject'] = (TextTemplate(self.subject) + .generate(record=record) + .render()) + except AccessError: + pass + if self.body: + try: + values['body'] = (TextTemplate(self.body) + .generate(record=record) + .render()) + except AccessError: + pass + if self.reports: + values['reports'] = [r.id for r in self.reports] + return values + + @classmethod + def _get_default_exclude(cls, record): + return ['create_uid', 'write_uid'] + + @classmethod + def get_default(cls, model, record): + pool = Pool() + Field = pool.get('ir.model.field') + Model = pool.get(model) + record = Model(int(record)) + values = {} + + fields = Field.search([ + ('model.model', '=', model), + ('name', 'not in', cls._get_default_exclude(record)), + ['OR', + ('relation', 'in', cls.email_models()), + [ + ('model.model', 'in', cls.email_models()), + ('name', '=', 'id'), + ], + ], + ]) + values['to'] = addresses = [] + for field in fields: + try: + if field.name == 'id': + value = record + else: + value = getattr(record, field.name) + addresses += cls.get_addresses(value) + except AccessError: + pass + + try: + values['subject'] = '%s: %s' % ( + Model.__names__()['model'], record.rec_name) + except AccessError: + pass + return values + + @classmethod + def email_models(cls): + return ['res.user'] + + @classmethod + def get_addresses(cls, value): + if isinstance(value, (list, tuple)): + addresses = (cls._get_address(v) for v in value) + else: + addresses = [cls._get_address(value)] + return [ + _formataddr((name, email)) + for name, email in filter(None, addresses) + if email] + + @classmethod + def _get_address(cls, record): + pool = Pool() + User = pool.get('res.user') + if isinstance(record, User) and record.email: + return (record.name, record.email) + + @classmethod + def get_languages(cls, value): + pool = Pool() + Configuration = pool.get('ir.configuration') + Lang = pool.get('ir.lang') + if isinstance(value, (list, tuple)): + languagues = {cls._get_language(v) for v in value} + else: + languagues = {cls._get_language(value)} + languagues = list(filter(None, languagues)) + if not languagues: + return Lang.search([ + ('code', '=', Configuration.get_language()), + ], limit=1) + return languagues + + @classmethod + def _get_language(cls, record): + pool = Pool() + User = pool.get('res.user') + if isinstance(record, User) and record.language: + return record.language + + +class EmailTemplate_Report(ModelSQL): + "Email Template - Report" + __name__ = 'ir.email.template-ir.action.report' + + template = fields.Many2One( + 'ir.email.template', "Template", required=True, ondelete='CASCADE') + report = fields.Many2One( + 'ir.action.report', "Report", required=True, ondelete='CASCADE') diff --git a/trytond/ir/message.xml b/trytond/ir/message.xml index 2407881b2a..3e7899c51f 100644 --- a/trytond/ir/message.xml +++ b/trytond/ir/message.xml @@ -311,13 +311,17 @@ this repository contains the full copyright notices and license terms. --> Notes - You are not allowed to execute wizard "%(wizard)s". You are not allowed to execute wizard "%(wizard)s" on "%(model)s". - + + Invalid subject in e-mail template "%(template)s" with exception "%(exception)s" + + + Invalid body in e-mail template "%(template)s" with exception "%(exception)s" + diff --git a/trytond/ir/tryton.cfg b/trytond/ir/tryton.cfg index 45e2e0454d..acadb72d70 100644 --- a/trytond/ir/tryton.cfg +++ b/trytond/ir/tryton.cfg @@ -20,3 +20,4 @@ xml: calendar_.xml message.xml queue.xml + email.xml diff --git a/trytond/ir/view/action_report_form.xml b/trytond/ir/view/action_report_form.xml index 6123dffcbc..0acb0a3bb6 100644 --- a/trytond/ir/view/action_report_form.xml +++ b/trytond/ir/view/action_report_form.xml @@ -34,8 +34,6 @@ this repository contains the full copyright notices and license terms. -->