From 7b5909a7675e91e46d0c75688c890089612e7764 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Tue, 21 Jan 2020 09:55:20 +0100
Subject: [PATCH] workflows: add mail template management (#25378)

---
 tests/test_mail_templates.py                  | 208 ++++++++++++++++++
 wcs/admin/mail_templates.py                   | 170 ++++++++++++++
 wcs/admin/settings.py                         |   7 +-
 wcs/admin/workflows.py                        |   7 +-
 wcs/mail_templates.py                         |  85 +++++++
 wcs/publisher.py                              |   2 +-
 wcs/qommon/static/css/dc2/admin.css           |   5 +
 wcs/templates/wcs/backoffice/base.html        |   1 +
 .../wcs/backoffice/mail-template.html         |  25 +++
 .../wcs/backoffice/mail-templates.html        |  22 ++
 wcs/workflows.py                              |  52 ++++-
 11 files changed, 571 insertions(+), 13 deletions(-)
 create mode 100644 tests/test_mail_templates.py
 create mode 100644 wcs/admin/mail_templates.py
 create mode 100644 wcs/mail_templates.py
 create mode 100644 wcs/templates/wcs/backoffice/mail-template.html
 create mode 100644 wcs/templates/wcs/backoffice/mail-templates.html

diff --git a/tests/test_mail_templates.py b/tests/test_mail_templates.py
new file mode 100644
index 000000000..cf901403d
--- /dev/null
+++ b/tests/test_mail_templates.py
@@ -0,0 +1,208 @@
+# -*- coding: utf-8 -*-
+
+import base64
+import os
+import pytest
+
+from django.utils import six
+from django.utils.encoding import force_bytes
+from quixote import cleanup
+from wcs.formdef import FormDef
+from wcs.mail_templates import MailTemplate
+from wcs.workflows import Workflow, SendmailWorkflowStatusItem
+from wcs.qommon.http_request import HTTPRequest
+from wcs.qommon.ident.password_accounts import PasswordAccount
+
+from utilities import (get_app, login, create_temporary_pub, clean_temporary_pub)
+
+
+def setup_module(module):
+    cleanup()
+
+
+def teardown_module(module):
+    clean_temporary_pub()
+
+
+@pytest.fixture
+def pub(request):
+    pub = create_temporary_pub()
+    req = HTTPRequest(None, {'SCRIPT_NAME': '/', 'SERVER_NAME': 'example.net'})
+    pub.set_app_dir(req)
+    pub._set_request(req)
+    pub.cfg['identification'] = {'methods': ['password']}
+    pub.write_cfg()
+    return pub
+
+
+@pytest.fixture
+def superuser(pub):
+    if pub.user_class.select(lambda x: x.name == 'admin'):
+        user1 = pub.user_class.select(lambda x: x.name == 'admin')[0]
+        user1.is_admin = True
+        user1.store()
+        return user1
+
+    user1 = pub.user_class(name='admin')
+    user1.is_admin = True
+    user1.store()
+
+    account1 = PasswordAccount(id='admin')
+    account1.set_password('admin')
+    account1.user_id = user1.id
+    account1.store()
+
+    return user1
+
+
+@pytest.fixture
+def mail_templates_option(pub):
+    if not pub.site_options.has_section('options'):
+        pub.site_options.add_section('options')
+    pub.site_options.set('options', 'mail-templates', 'true')
+    with open(os.path.join(pub.app_dir, 'site-options.cfg'), 'w') as fd:
+        pub.site_options.write(fd)
+    return pub
+
+
+def test_mail_templates_disabled(pub, superuser):
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/workflows/')
+    assert 'Mail Templates' not in resp
+
+
+def test_mail_templates_basics(pub, superuser, mail_templates_option):
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/workflows/')
+    assert 'Mail Templates' in resp
+    resp = resp.click('Mail Templates')
+    assert 'There are no mail templates defined.' in resp
+
+    resp = resp.click('New')
+    resp.form['name'] = 'first mail template'
+    resp = resp.form.submit('cancel').follow()
+    assert 'There are no mail templates defined.' in resp
+
+    resp = resp.click('New')
+    resp.form['name'] = 'first mail template'
+    resp = resp.form.submit('submit').follow()
+    resp.form['subject'] = 'mail subject'
+    resp.form['body'] = 'mail body'
+    resp = resp.form.submit('submit').follow()
+
+    resp = resp.click('Edit')
+    resp.form['subject'] = 'edited mail subject'
+    resp.form['body'] = 'edited mail body'
+    resp = resp.form.submit('submit').follow()
+
+    resp = resp.click('Delete')
+    resp = resp.form.submit('cancel').follow()
+    assert 'first mail template' in resp
+
+    resp = resp.click('Delete')
+    resp = resp.form.submit('submit').follow()
+    assert 'first mail template' not in resp
+    assert 'There are no mail templates defined.' in resp
+
+    resp = resp.click('New')
+    resp.form['name'] = 'first mail template'
+    resp = resp.form.submit('submit').follow()
+    resp.form['subject'] = 'mail subject'
+    resp.form['body'] = 'mail body'
+    resp = resp.form.submit('submit').follow()
+
+    resp = app.get('/backoffice/workflows/')
+    resp = resp.click('Mail Templates')
+    assert 'first mail template' in resp
+
+
+def test_mail_template_in_use(pub, mail_templates_option):
+    Workflow.wipe()
+    MailTemplate.wipe()
+    workflow = Workflow(name='test mail template')
+    st1 = workflow.add_status('Status1')
+    item = SendmailWorkflowStatusItem()
+    item.to = ['_receiver']
+    item.subject = 'Foobar'
+    item.body = 'Hello'
+    st1.items.append(item)
+    item.parent = st1
+    workflow.store()
+
+    mail_template = MailTemplate(name='test mail template')
+    mail_template.subject = 'test subject'
+    mail_template.body = 'test body'
+    mail_template.store()
+    assert mail_template.is_in_use() is False
+
+    item.mail_template = mail_template.slug
+    workflow.store()
+    assert mail_template.is_in_use() is True
+
+
+def test_admin_workflow_edit(pub, superuser, mail_templates_option):
+    Workflow.wipe()
+    MailTemplate.wipe()
+    mail_template = MailTemplate(name='test mail template')
+    mail_template.subject = 'test subject'
+    mail_template.body = 'test body'
+    mail_template.store()
+
+    workflow = Workflow(name='test mail template')
+    st1 = workflow.add_status('Status1')
+    item = SendmailWorkflowStatusItem()
+    item.to = ['_receiver']
+    item.subject = 'Foobar'
+    item.body = 'Hello'
+    st1.items.append(item)
+    item.parent = st1
+    workflow.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/workflows/%s/' % workflow.id)
+    resp = resp.click('Status1')
+    resp = resp.click('Email')
+    resp.form['mail_template'] = 'test-mail-template'
+    resp = resp.form.submit('submit')
+
+    workflow = Workflow.get(workflow.id)
+    assert workflow.possible_status[0].items[0].mail_template == 'test-mail-template'
+
+
+def test_workflow_send_mail_template(pub, superuser, mail_templates_option, emails):
+    Workflow.wipe()
+    MailTemplate.wipe()
+
+    mail_template = MailTemplate(name='test mail template')
+    mail_template.subject = 'test subject'
+    mail_template.body = 'test body'
+    mail_template.store()
+
+    workflow = Workflow(name='test mail template')
+    st1 = workflow.add_status('Status1')
+    item = SendmailWorkflowStatusItem()
+    item.to = 'xyz@localhost'
+    item.subject = 'Foobar'
+    item.body = 'Hello'
+    item.mail_template = mail_template.slug
+    st1.items.append(item)
+    item.parent = st1
+    workflow.store()
+
+    formdef = FormDef()
+    formdef.name = 'baz'
+    formdef.fields = []
+    formdef.store()
+
+    formdata = formdef.data_class()()
+    formdata.just_created()
+    formdata.store()
+
+    item.perform(formdata)
+    pub.get_request().response.process_after_jobs()
+    assert emails.count() == 1
+    assert emails.get('test subject')['email_rcpt'] == ['xyz@localhost']
+    if six.PY2:
+        assert 'test body' in emails.get('test subject')['msg'].get_payload(0).get_payload()
+    else:
+        assert b'test body' in base64.decodestring(force_bytes(emails.get('test subject')['msg'].get_payload(0).get_payload()))
diff --git a/wcs/admin/mail_templates.py b/wcs/admin/mail_templates.py
new file mode 100644
index 000000000..70716599a
--- /dev/null
+++ b/wcs/admin/mail_templates.py
@@ -0,0 +1,170 @@
+# w.c.s. - web application for online forms
+# Copyright (C) 2005-2020  Entr'ouvert
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, see <http://www.gnu.org/licenses/>.
+
+from quixote import get_response, redirect
+from quixote.directory import Directory
+from quixote.html import TemplateIO, htmltext
+
+from wcs.qommon import _, errors, template
+from wcs.qommon.backoffice.menu import html_top
+from wcs.qommon.form import Form, HtmlWidget, StringWidget, TextWidget, ComputedExpressionWidget
+from wcs.mail_templates import MailTemplate
+
+
+class MailTemplatesDirectory(Directory):
+    _q_exports = ['', 'new']
+    do_not_call_in_templates = True
+
+    def _q_traverse(self, path):
+        get_response().breadcrumb.append(('workflows/', _('Workflows')))
+        get_response().breadcrumb.append(('mail-templates/', _('Mail Templates')))
+        return super(MailTemplatesDirectory, self)._q_traverse(path)
+
+    def _q_lookup(self, component):
+        return MailTemplatePage(component)
+
+    def _q_index(self):
+        html_top('mail_templates', title=_('Mail Templates'))
+        return template.QommonTemplateResponse(
+                templates=['wcs/backoffice/mail-templates.html'],
+                context={'view': self, 'mail_templates': MailTemplate.select(order_by='name')})
+
+    def new(self):
+        form = Form(enctype='multipart/form-data')
+        form.add(StringWidget, 'name', title=_('Name'), required=True, size=50)
+        form.add_submit('submit', _('Add'))
+        form.add_submit('cancel', _('Cancel'))
+        if form.get_widget('cancel').parse():
+            return redirect('.')
+
+        if form.is_submitted() and not form.has_errors():
+            mail_template = MailTemplate(name=form.get_widget('name').parse())
+            mail_template.store()
+            return redirect('%s/edit' % mail_template.id)
+
+        get_response().breadcrumb.append(('new', _('New Mail Template')))
+        html_top('mail_templates', title=_('New Mail Template'))
+        r = TemplateIO(html=True)
+        r += htmltext('<h2>%s</h2>') % _('New Mail Template')
+        r += form.render()
+        return r.getvalue()
+
+
+class MailTemplatePage(Directory):
+    _q_exports = ['', 'edit', 'delete']
+    do_not_call_in_templates = True
+
+    def __init__(self, mail_template_id):
+        try:
+            self.mail_template = MailTemplate.get(mail_template_id)
+        except KeyError:
+            raise errors.TraversalError()
+        get_response().breadcrumb.append((mail_template_id + '/', self.mail_template.name))
+
+    def _q_index(self):
+        html_top('mail_templates', title=self.mail_template.name)
+        return template.QommonTemplateResponse(
+                templates=['wcs/backoffice/mail-template.html'],
+                context={'view': self, 'mail_template': self.mail_template})
+
+    def get_form(self):
+        form = Form(enctype='multipart/form-data', advanced_label=_('Additional options'))
+        form.add(StringWidget, 'name', title=_('Name'), required=True, size=30,
+                value=self.mail_template.name)
+        form.add(TextWidget, 'description', title=_('Description'),
+                cols=80, rows=3,
+                value=self.mail_template.description)
+        form.add(StringWidget, 'subject', title=_('Subject'), required=True, size=40,
+                value=self.mail_template.subject,
+                 validation_function=ComputedExpressionWidget.validate_template)
+        form.add(TextWidget, 'body', title=_('Body'),
+                 value=self.mail_template.body, cols=80, rows=15, require=True,
+                 validation_function=ComputedExpressionWidget.validate_template)
+
+        if self.mail_template.slug and not self.mail_template.is_in_use():
+            form.add(StringWidget, 'slug',
+                    value=self.mail_template.slug,
+                    title=_('Identifier'),
+                    required=True, advanced=True,
+                    )
+        form.add_submit('submit', _('Submit'))
+        form.add_submit('cancel', _('Cancel'))
+        return form
+
+    def submit_form(self, form):
+        name = form.get_widget('name').parse()
+        slug_widget = form.get_widget('slug')
+        if slug_widget:
+            slug = form.get_widget('slug').parse()
+
+        for mail_template in MailTemplate.select():
+            if mail_template.id == self.mail_template.id:
+                continue
+            if slug_widget and slug == mail_template.slug:
+                slug_widget.set_error(_('This value is already used.'))
+        if form.has_errors():
+            raise ValueError()
+
+        self.mail_template.name = name
+        self.mail_template.description = form.get_widget('description').parse()
+        self.mail_template.subject = form.get_widget('subject').parse()
+        self.mail_template.body = form.get_widget('body').parse()
+        if slug_widget:
+            self.mail_template.slug = slug
+        self.mail_template.store()
+
+    def edit(self):
+        form = self.get_form()
+        if form.get_submit() == 'cancel':
+            return redirect('.')
+
+        if form.get_submit() == 'submit' and not form.has_errors():
+            try:
+                self.submit_form(form)
+            except ValueError:
+                pass
+            else:
+                return redirect('.')
+
+        get_response().breadcrumb.append(('edit', _('Edit')))
+        html_top('mail_templates', title=_('Edit Mail Template'))
+        r = TemplateIO(html=True)
+        r += htmltext('<h2>%s</h2>') % _('Edit Mail Template')
+        r += form.render()
+        return r.getvalue()
+
+    def delete(self):
+        form = Form(enctype='multipart/form-data')
+        if not self.mail_template.is_in_use():
+            form.widgets.append(HtmlWidget('<p>%s</p>' % _(
+                        'You are about to irrevocably delete this mail template.')))
+            form.add_submit('delete', _('Submit'))
+        else:
+            form.widgets.append(HtmlWidget('<p>%s</p>' % _(
+                        'This mail template is still used, it cannot be deleted.')))
+        form.add_submit('cancel', _('Cancel'))
+        if form.get_widget('cancel').parse():
+            return redirect('.')
+        if not form.is_submitted() or form.has_errors():
+            get_response().breadcrumb.append(('delete', _('Delete')))
+            html_top('mail_templates', title=_('Delete Mail Template'))
+            r = TemplateIO(html=True)
+            r += htmltext('<h2>%s %s</h2>') % (_('Deleting Mail Template:'), self.mail_template.name)
+            r += form.render()
+            return r.getvalue()
+        else:
+            self.mail_template.remove_self()
+            return redirect('..')
diff --git a/wcs/admin/settings.py b/wcs/admin/settings.py
index 06071ace4..31d598135 100644
--- a/wcs/admin/settings.py
+++ b/wcs/admin/settings.py
@@ -861,6 +861,7 @@ class SettingsDirectory(QommonSettingsDirectory):
         form.add(CheckboxWidget, 'categories', title = _('Categories'), value = True)
         form.add(CheckboxWidget, 'settings', title = _('Settings'), value = False)
         form.add(CheckboxWidget, 'datasources', title=_('Data sources'), value=True)
+        form.add(CheckboxWidget, 'mail-templates', title=_('Mail templates'), value=True)
         form.add(CheckboxWidget, 'wscalls', title=_('Webservice calls'), value=True)
         form.add_submit('submit', _('Submit'))
         form.add_submit('cancel', _('Cancel'))
@@ -885,7 +886,7 @@ class SettingsDirectory(QommonSettingsDirectory):
                 c = BytesIO()
                 z = zipfile.ZipFile(c, 'w')
                 for d in self.dirs:
-                    if d not in ('roles', 'categories', 'datasources', 'wscalls'):
+                    if d not in ('roles', 'categories', 'datasources', 'wscalls', 'mail-templates'):
                         continue
                     path = os.path.join(self.app_dir, d)
                     if not os.path.exists(path):
@@ -928,7 +929,7 @@ class SettingsDirectory(QommonSettingsDirectory):
 
         dirs = []
         for w in ('formdefs', 'carddefs', 'workflows', 'roles', 'categories',
-                'datasources', 'wscalls'):
+                'datasources', 'wscalls', 'mail-templates'):
             if form.get_widget(w) and form.get_widget(w).parse():
                 dirs.append(w)
         if not dirs and not form.get_widget('settings').parse():
@@ -1017,6 +1018,8 @@ class SettingsDirectory(QommonSettingsDirectory):
                     r += htmltext('<li>%s</li>') % _('Settings')
                 if results['datasources']:
                     r += htmltext('<li>%d %s</li>') % (results['datasources'], _('data sources'))
+                if results['mail-templates']:
+                    r += htmltext('<li>%d %s</li>') % (results['mail-templates'], _('mail templates'))
                 if results['wscalls']:
                     r += htmltext('<li>%d %s</li>') % (results['wscalls'], _('webservice calls'))
                 r += htmltext('</ul>')
diff --git a/wcs/admin/workflows.py b/wcs/admin/workflows.py
index 9fe571b12..5cbe2d9d7 100644
--- a/wcs/admin/workflows.py
+++ b/wcs/admin/workflows.py
@@ -46,6 +46,7 @@ from wcs.formdata import Evolution
 from .fields import FieldDefPage, FieldsDirectory
 from .data_sources import NamedDataSourcesDirectory
 from .logged_errors import LoggedErrorsDirectory
+from .mail_templates import MailTemplatesDirectory
 from wcs.backoffice.studio import StudioDirectory
 
 
@@ -1814,9 +1815,11 @@ class NamedDataSourcesDirectoryInWorkflows(NamedDataSourcesDirectory):
 
 class WorkflowsDirectory(Directory):
     _q_exports = ['', 'new', ('import', 'p_import'),
-            ('data-sources', 'data_sources')]
+            ('data-sources', 'data_sources'),
+            ('mail-templates', 'mail_templates')]
 
     data_sources = NamedDataSourcesDirectoryInWorkflows()
+    mail_templates = MailTemplatesDirectory()
 
     def html_top(self, title):
         return html_top('workflows', title)
@@ -1829,6 +1832,8 @@ class WorkflowsDirectory(Directory):
         r += htmltext('<div id="appbar">')
         r += htmltext('<h2>%s</h2>') % _('Workflows')
         r += htmltext('<span class="actions">')
+        if get_publisher().has_site_option('mail-templates'):
+            r += htmltext('<a href="mail-templates/">%s</a>') % _('Mail Templates')
         r += htmltext('<a href="data-sources/">%s</a>') % _('Data sources')
         r += htmltext('<a href="import" rel="popup">%s</a>') % _('Import')
         r += htmltext('<a class="new-item" rel="popup" href="new">%s</a>') % _('New Workflow')
diff --git a/wcs/mail_templates.py b/wcs/mail_templates.py
new file mode 100644
index 000000000..e593f06f4
--- /dev/null
+++ b/wcs/mail_templates.py
@@ -0,0 +1,85 @@
+# w.c.s. - web application for online forms
+# Copyright (C) 2005-2020  Entr'ouvert
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, see <http://www.gnu.org/licenses/>.
+
+from wcs.qommon import misc, get_logger
+from wcs.qommon.xml_storage import XmlStorableObject
+
+
+class MailTemplate(XmlStorableObject):
+    _names = 'mail-templates'
+    _xml_tagname = 'mail-template'
+
+    name = None
+    slug = None
+    description = None
+    subject = None
+    body = None
+
+    # declarations for serialization
+    XML_NODES = [
+            ('name', 'str'),
+            ('slug', 'str'),
+            ('description', 'str'),
+            ('subject', 'str'),
+            ('body', 'str'),
+    ]
+
+    def __init__(self, name=None):
+        XmlStorableObject.__init__(self)
+        self.name = name
+
+    def store(self):
+        if self.slug is None:
+            # set slug if it's not yet there
+            self.slug = self.get_new_slug()
+        super(MailTemplate, self).store()
+
+    def get_new_slug(self):
+        new_slug = misc.simplify(self.name, space='-')
+        base_new_slug = new_slug
+        suffix_no = 0
+        known_slugs = {x.slug: x.id for x in self.select(ignore_migration=True, ignore_errors=True)}
+        while True:
+            if not new_slug in known_slugs:
+                break
+            suffix_no += 1
+            new_slug = '%s-%s' % (base_new_slug, suffix_no)
+        return new_slug
+
+    def is_in_use(self):
+        from wcs.workflows import Workflow
+        for workflow in Workflow.select(ignore_errors=True, ignore_migration=True):
+            for item in workflow.get_all_items():
+                if item.key != 'sendmail':
+                    continue
+                if item.mail_template == self.slug:
+                    return True
+        return False
+
+    @classmethod
+    def get_as_options_list(cls):
+        options = []
+        for mail_template in cls.select(order_by='name'):
+            options.append((mail_template.slug, mail_template.name, mail_template.slug))
+        return options
+
+    @classmethod
+    def get_by_slug(cls, slug):
+        objects = [x for x in cls.select() if x.slug == slug]
+        if objects:
+            return objects[0]
+        get_logger().warn("mail template '%s' does not exist" % slug)
+        return None
diff --git a/wcs/publisher.py b/wcs/publisher.py
index d33a7ff0f..8ef473ec3 100644
--- a/wcs/publisher.py
+++ b/wcs/publisher.py
@@ -160,7 +160,7 @@ class WcsPublisher(StubWcsPublisher):
     def import_zip(self, fd):
         z = zipfile.ZipFile(fd)
         results = {'formdefs': 0, 'carddefs': 0, 'workflows': 0, 'categories': 0, 'roles': 0,
-                'settings': 0, 'datasources': 0, 'wscalls': 0}
+                'settings': 0, 'datasources': 0, 'wscalls': 0, 'mail-templates': 0}
 
         def _decode_list(data):
             rv = []
diff --git a/wcs/qommon/static/css/dc2/admin.css b/wcs/qommon/static/css/dc2/admin.css
index 3d340e311..0dae52a8a 100644
--- a/wcs/qommon/static/css/dc2/admin.css
+++ b/wcs/qommon/static/css/dc2/admin.css
@@ -1859,3 +1859,8 @@ a.button.button-paragraph:hover p {
 table#listing tr.checked td {
 	background: #ddf;
 }
+
+div.mail-body {
+	margin-top: 1em;
+	white-space: pre-line;
+}
diff --git a/wcs/templates/wcs/backoffice/base.html b/wcs/templates/wcs/backoffice/base.html
index ea74d7f37..5603f3dea 100644
--- a/wcs/templates/wcs/backoffice/base.html
+++ b/wcs/templates/wcs/backoffice/base.html
@@ -1,6 +1,7 @@
 {% block appbar %}
 <div id="appbar">
 <h2>{% block appbar-title %}{% endblock %}</h2>
+<span class="actions">{% block appbar-actions %}{% endblock %}</span>
 </div>
 {% endblock %}
 
diff --git a/wcs/templates/wcs/backoffice/mail-template.html b/wcs/templates/wcs/backoffice/mail-template.html
new file mode 100644
index 000000000..888601db3
--- /dev/null
+++ b/wcs/templates/wcs/backoffice/mail-template.html
@@ -0,0 +1,25 @@
+{% extends "wcs/backoffice/base.html" %}
+{% load i18n %}
+
+{% block appbar-title %}{% trans "Mail Template" %} - {{ mail_template.name }}{% endblock %}
+
+{% block appbar-actions %}
+<a href="delete">{% trans "Delete" %}</a>
+<a href="edit">{% trans "Edit" %}</a>
+{% endblock %}
+
+{% block content %}
+{% if mail_template.description %}
+<div class="bo-block">{{ mail_template.description }}</div>
+{% endif %}
+
+{% if mail_template.subject and mail_template.body %}
+<div class="bo-block">
+<div class="mail-subject"><strong>{% trans "Subject:" %} </strong>{{ mail_template.subject }}</div>
+<div class="mail-body">{{ mail_template.body }}</div>
+</div>
+{% else %}
+<div class="infonotice">{% trans "This mail template still needs to be configured." %}</div>
+{% endif %}
+
+{% endblock %}
diff --git a/wcs/templates/wcs/backoffice/mail-templates.html b/wcs/templates/wcs/backoffice/mail-templates.html
new file mode 100644
index 000000000..cbbbed2fd
--- /dev/null
+++ b/wcs/templates/wcs/backoffice/mail-templates.html
@@ -0,0 +1,22 @@
+{% extends "wcs/backoffice/base.html" %}
+{% load i18n %}
+
+{% block appbar-title %}{% trans "Mail Templates" %}{% endblock %}
+
+{% block appbar-actions %}
+<a rel="popup" href="new">{% trans "New mail template" %}</a>
+{% endblock %}
+
+{% block content %}
+{% if mail_templates %}
+<ul class="objects-list single-links">
+  {% for mail_template in mail_templates %}
+  <li><a href="{{ mail_template.id }}/">{{ mail_template.name }}</a></li>
+  {% endfor %}
+</ul>
+{% else %}
+<div class="infonotice">
+{% trans "There are no mail templates defined." %}
+</div>
+{% endif %}
+{% endblock %}
diff --git a/wcs/workflows.py b/wcs/workflows.py
index 43ac79562..18e4d5de7 100644
--- a/wcs/workflows.py
+++ b/wcs/workflows.py
@@ -46,6 +46,7 @@ from .roles import Role, logged_users_role, get_user_roles
 from .fields import FileField
 from .formdef import FormDef
 from .formdata import Evolution
+from .mail_templates import MailTemplate
 
 if not __name__.startswith('wcs.') and not __name__ == "__main__":
     raise ImportError('Import of workflows module must be absolute (import wcs.workflows)')
@@ -422,6 +423,14 @@ class Workflow(StorableObject):
             return self.backoffice_fields_formdef.fields or []
         return []
 
+    def get_all_items(self):
+        for status in self.possible_status or []:
+            for item in status.items or []:
+                yield item
+        for action in self.global_actions or []:
+            for item in action.items or []:
+                yield item
+
     def add_global_action(self, name, id=None):
         if [x for x in self.global_actions if x.name == name]:
             raise DuplicateGlobalActionNameError()
@@ -2445,6 +2454,7 @@ class SendmailWorkflowStatusItem(WorkflowStatusItem):
 
     to = []
     subject = None
+    mail_template = None
     body = None
     custom_from = None
     attachments = None
@@ -2487,11 +2497,19 @@ class SendmailWorkflowStatusItem(WorkflowStatusItem):
             return _('not completed')
 
     def get_parameters(self):
-        return ('to', 'subject', 'body', 'attachments', 'custom_from', 'condition')
+        return ('to', 'mail_template', 'subject', 'body', 'attachments', 'custom_from', 'condition')
 
     def add_parameters_widgets(self, form, parameters, prefix='', formdef=None):
         super(SendmailWorkflowStatusItem, self).add_parameters_widgets(
                 form, parameters, prefix=prefix, formdef=formdef)
+        subject_body_attrs = {}
+        if 'subject' in parameters or 'body' in parameters:
+            if get_publisher().has_site_option('mail-templates') and MailTemplate.count():
+                subject_body_attrs = {
+                    'data-dynamic-display-value': '',
+                    'data-dynamic-display-child-of': '%smail_template' % prefix,
+                }
+
         if 'to' in parameters:
             form.add(WidgetList, '%sto' % prefix, title=_('To'),
                      element_type=SingleSelectWidgetWithOther,
@@ -2503,11 +2521,21 @@ class SendmailWorkflowStatusItem(WorkflowStatusItem):
         if 'subject' in parameters:
             form.add(StringWidget, '%ssubject' % prefix, title=_('Subject'),
                      validation_function=ComputedExpressionWidget.validate_template,
-                     value=self.subject, size=40)
+                     value=self.subject, size=40,
+                     attrs=subject_body_attrs)
+        if 'mail_template' in parameters and get_publisher().has_site_option('mail-templates') and MailTemplate.count():
+            form.add(SingleSelectWidget,
+                    '%smail_template' % prefix,
+                    title=_('Mail Template'),
+                    value=self.mail_template,
+                    options=[(None, '', '')] + MailTemplate.get_as_options_list(),
+                    attrs={'data-dynamic-display-parent': 'true'}
+            )
         if 'body' in parameters:
             form.add(TextWidget, '%sbody' % prefix, title=_('Body'),
                      value=self.body, cols=80, rows=10,
-                     validation_function=ComputedExpressionWidget.validate_template)
+                     validation_function=ComputedExpressionWidget.validate_template,
+                     attrs=subject_body_attrs)
 
         if 'custom_from' in parameters:
             form.add(ComputedExpressionWidget, '%scustom_from' % prefix,
@@ -2520,16 +2548,22 @@ class SendmailWorkflowStatusItem(WorkflowStatusItem):
     def perform(self, formdata):
         if not self.to:
             return
-        if not self.subject:
-            return
-        if not self.body:
+        if not (self.subject and self.body) and not self.mail_template:
             return
 
         url = formdata.get_url()
+        body = self.body
+        subject = self.subject
+        if self.mail_template:
+            mail_template = MailTemplate.get_by_slug(self.mail_template)
+            if mail_template:
+                body = mail_template.body
+                subject = mail_template.subject
+
         try:
             mail_body = template_on_formdata(formdata,
-                    self.compute(self.body, render=False),
-                    autoescape=self.body.startswith('<'))
+                    self.compute(body, render=False),
+                    autoescape=body.startswith('<'))
         except TemplateError as e:
             get_logger().error('error in template for email body [%s], '
                                'mail could not be generated: %s' % (url, str(e)))
@@ -2537,7 +2571,7 @@ class SendmailWorkflowStatusItem(WorkflowStatusItem):
 
         try:
             mail_subject = template_on_formdata(formdata,
-                    self.compute(self.subject, render=False),
+                    self.compute(subject, render=False),
                     autoescape=False)
         except TemplateError as e:
             get_logger().error('error in template for email subject [%s], '
-- 
2.25.0

