changeset 4:567ba1b8cd49 5.2

Update to 5.2 series
author Cédric Krier <ced@b2ck.com>
date Wed, 23 Oct 2019 16:10:03 +0200
parents ddcd049e2b29
children a676f9084d1a
files .drone.yml COPYRIGHT INSTALL MANIFEST.in README.rst __init__.py doc/index.rst email_subscribe.html email_unsubscribe.html empty.gif ir.py marketing.py marketing.xml message.xml routes.py setup.py tests/test_marketing_email.py tox.ini tryton.cfg view/email_form.xml view/email_list.xml view/email_message_form.xml view/email_message_list.xml view/send_test_form.xml web.py
diffstat 25 files changed, 629 insertions(+), 191 deletions(-) [+]
line wrap: on
line diff
--- a/.drone.yml	Thu Jan 31 19:56:22 2019 +0100
+++ b/.drone.yml	Wed Oct 23 16:10:03 2019 +0200
@@ -25,27 +25,21 @@
 
 matrix:
     include:
-        - IMAGE: python:2.7
-          TOXENV: py27
-          DATABASE: sqlite
-        - IMAGE: python:2.7
-          TOXENV: py27
-          DATABASE: postgresql
-        - IMAGE: python:3.3
-          TOXENV: py33
-          DATABASE: sqlite
-        - IMAGE: python:3.3
-          TOXENV: py33
-          DATABASE: postgresql
-        - IMAGE: python:3.4
-          TOXENV: py34
-          DATABASE: sqlite
-        - IMAGE: python:3.4
-          TOXENV: py34
-          DATABASE: postgresql
         - IMAGE: python:3.5
           TOXENV: py35
           DATABASE: sqlite
         - IMAGE: python:3.5
           TOXENV: py35
           DATABASE: postgresql
+        - IMAGE: python:3.6
+          TOXENV: py36
+          DATABASE: sqlite
+        - IMAGE: python:3.6
+          TOXENV: py36
+          DATABASE: postgresql
+        - IMAGE: python:3.7
+          TOXENV: py37
+          DATABASE: sqlite
+        - IMAGE: python:3.7
+          TOXENV: py37
+          DATABASE: postgresql
--- a/COPYRIGHT	Thu Jan 31 19:56:22 2019 +0100
+++ b/COPYRIGHT	Wed Oct 23 16:10:03 2019 +0200
@@ -1,4 +1,6 @@
-Copyright (C) 2017 Cédric Krier
+Copyright (C) 2018 Sergi Almacellas Abellana
+Copyright (C) 2017-2019 Cédric Krier
+Copyright (C) 2017-2019 B2CK
 
 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
--- a/INSTALL	Thu Jan 31 19:56:22 2019 +0100
+++ b/INSTALL	Wed Oct 23 16:10:03 2019 +0200
@@ -4,7 +4,7 @@
 Prerequisites
 -------------
 
- * Python 2.7 or later (http://www.python.org/)
+ * Python 3.4 or later (http://www.python.org/)
  * trytond (http://www.tryton.org/)
 
 Installation
--- a/MANIFEST.in	Thu Jan 31 19:56:22 2019 +0100
+++ b/MANIFEST.in	Wed Oct 23 16:10:03 2019 +0200
@@ -1,13 +1,5 @@
-include INSTALL
-include README
+include CHANGELOG
 include COPYRIGHT
-include CHANGELOG
 include LICENSE
-include tryton.cfg
-include *.xml
-include view/*.xml
-include *.fodt
-include locale/*.po
+include README.rst
 include doc/*
-include icons/*
-include tests/*.rst
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/README.rst	Wed Oct 23 16:10:03 2019 +0200
@@ -0,0 +1,1 @@
+doc/index.rst
\ No newline at end of file
--- a/__init__.py	Thu Jan 31 19:56:22 2019 +0100
+++ b/__init__.py	Wed Oct 23 16:10:03 2019 +0200
@@ -3,16 +3,24 @@
 
 from trytond.pool import Pool
 from . import marketing
+from . import ir
+from . import web
+from . import routes
 
-__all__ = ['register']
+__all__ = ['register', 'routes']
 
 
 def register():
     Pool.register(
         marketing.Email,
         marketing.EmailList,
+        marketing.Message,
+        marketing.SendTestView,
+        ir.Cron,
+        web.ShortenedURL,
         module='marketing_email', type_='model')
     Pool.register(
+        marketing.SendTest,
         module='marketing_email', type_='wizard')
     Pool.register(
         marketing.EmailSubscribe,
--- a/doc/index.rst	Thu Jan 31 19:56:22 2019 +0100
+++ b/doc/index.rst	Wed Oct 23 16:10:03 2019 +0200
@@ -1,2 +1,56 @@
 Marketing Email Module
 ######################
+
+The marketing_email module manages mailing lists.
+
+Mailing List
+************
+
+A mailing list groups emails under a name and a language
+
+Email
+*****
+
+It stores emails for a mailing list and provides links to the related party or
+web user.
+
+Two actions are available:
+
+- *Request Subscribe* which sent an e-mail to confirm the subscription to a
+  list.
+
+- *Request Unsubscribe* which sent an e-mail to confirm the unsubscription an
+  email of a list.
+
+Message
+*******
+
+It stores a message to sent to all e-mails of a list. A message is defined by:
+
+    * From: the address from which the message is sent.
+    * List: the list of addresses to sent the message.
+    * Title
+    * Content
+    * State:
+
+        * Draft
+        * Sending
+        * Sent
+
+A wizard is available to send the message to a unique e-mail of the list for
+test purpose.
+
+Configuration
+*************
+
+The marketing_email module uses parameters from the section:
+
+- `[marketing]`:
+
+    - `email_from`: The default `From` for the email sent.
+
+    - `email_subscribe_url`: the URL to confirm the subscription to which the
+      parameter `token` will be added.
+
+    - `email_unsubscribe_url`: the URL to unsubscribe an email to which the
+      parameter `token` will be added.
--- a/email_subscribe.html	Thu Jan 31 19:56:22 2019 +0100
+++ b/email_subscribe.html	Wed Oct 23 16:10:03 2019 +0200
@@ -1,7 +1,7 @@
 <!DOCTYPE html>
 <html xmlns:py="http://genshi.edgewall.org/">
     <head>
-        <title>Subscribe Request</title>
+        <title>Subscription Request</title>
     </head>
     <body>
         <py:for each="record in records">
--- a/email_unsubscribe.html	Thu Jan 31 19:56:22 2019 +0100
+++ b/email_unsubscribe.html	Wed Oct 23 16:10:03 2019 +0200
@@ -1,7 +1,7 @@
 <!DOCTYPE html>
 <html xmlns:py="http://genshi.edgewall.org/">
     <head>
-        <title>Unsubscribe Request</title>
+        <title>Request to Unsubscribe</title>
     </head>
     <body>
         <py:for each="record in records">
Binary file empty.gif has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ir.py	Wed Oct 23 16:10:03 2019 +0200
@@ -0,0 +1,15 @@
+# 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 trytond.pool import PoolMeta
+
+
+class Cron(metaclass=PoolMeta):
+    __name__ = 'ir.cron'
+
+    @classmethod
+    def __setup__(cls):
+        super().__setup__()
+        cls.method.selection.extend([
+                ('marketing.email.message|process',
+                    "Send Marketing Messages"),
+                ])
--- a/marketing.py	Thu Jan 31 19:56:22 2019 +0100
+++ b/marketing.py	Wed Oct 23 16:10:03 2019 +0200
@@ -1,15 +1,17 @@
 # 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 binascii
-import os
 import random
 import time
-import urllib
-import urlparse
 from email.header import Header
 from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
+from email.utils import formataddr, getaddresses
+from functools import lru_cache
+from urllib.parse import (
+    parse_qs, parse_qsl, urlencode, urlsplit, urlunsplit, urljoin)
 
+from genshi.template import MarkupTemplate
+from genshi.core import START, END, QName, Attrs
 try:
     import html2text
 except ImportError:
@@ -17,71 +19,59 @@
 
 from trytond import backend
 from trytond.config import config
-from trytond.model import ModelSQL, ModelView, Unique, fields
+from trytond.ir.session import token_hex
+from trytond.model import (
+    DeactivableMixin, Workflow, ModelSQL, ModelView, Unique, fields)
 from trytond.pool import Pool
+from trytond.pyson import Eval
 from trytond.report import Report
-from trytond.sendmail import sendmail_transactional
+from trytond.report import get_email
+from trytond.sendmail import sendmail_transactional, SMTPDataManager
 from trytond.tools import grouped_slice
 from trytond.transaction import Transaction
-
-__all__ = ['Email', 'EmailSubscribe', 'EmailUnsubscribe', 'EmailList']
-
+from trytond.url import HOSTNAME
+from trytond.wizard import Wizard, StateView, StateTransition, Button
 
-def token_hex(nbytes=None):
-    if nbytes is None:
-        nbytes = 32
-    return binascii.hexlify(os.urandom(nbytes)).decode('ascii')
+if not config.get(
+        'html', 'plugins-marketing.email.message-content'):
+    config.set(
+        'html', 'plugins-marketing.email.message-content',
+        'fullpage')
+
+USE_SSL = bool(config.get('ssl', 'certificate'))
+URL_BASE = config.get('marketing', 'email_base',
+    default=urlunsplit(
+        ('http' + ('s' if USE_SSL else ''), HOSTNAME, '', '', '')))
+URL_OPEN = urljoin(URL_BASE, '/m/empty.gif')
 
 
-def get_email(report, record, languages):
-    "Return email.mime and title from the report execution"
-    pool = Pool()
-    Report = pool.get(report.report_name, type='report')
-    converter = None
-    msg = MIMEMultipart('alternative')
-    msg.add_header('Content-Language', ', '.join(l.code for l in languages))
-    for language in languages:
-        with Transaction().set_context(language=language.code):
-            ext, content, _, title = Report.execute(
-                [record.id], {
-                    'action_id': report.id,
-                    'language': language,
-                    })
-        if ext == 'html' and html2text:
-            if not converter:
-                converter = html2text.HTML2Text()
-            part = MIMEText(
-                converter.handle(content), 'plain', _charset='utf-8')
-            part.add_header('Content-Language', language.code)
-            msg.attach(part)
-        part = MIMEText(content, ext, _charset='utf-8')
-        part.add_header('Content-Language', language.code)
-        msg.attach(part)
-    return msg, title
+def _formataddr(name, email):
+    if name:
+        name = str(Header(name, 'utf-8'))
+    return formataddr((name, email))
 
 
 def _add_params(url, **params):
-    parts = urlparse.urlsplit(url)
-    query = urlparse.parse_qsl(parts.query)
+    parts = urlsplit(url)
+    query = parse_qsl(parts.query)
     for key, value in sorted(params.items()):
         query.append((key, value))
     parts = list(parts)
-    parts[3] = urllib.urlencode(query)
-    return urlparse.urlunsplit(parts)
+    parts[3] = urlencode(query)
+    return urlunsplit(parts)
 
 
 def _extract_params(url):
-    return urlparse.parse_qsl(urlparse.urlsplit(url).query)
+    return parse_qsl(urlsplit(url).query)
 
 
-class Email(ModelSQL, ModelView):
+class Email(DeactivableMixin, ModelSQL, ModelView):
     "Marketing E-mail"
     __name__ = 'marketing.email'
     _rec_name = 'email'
 
     email = fields.Char("E-mail", required=True)
     list_ = fields.Many2One('marketing.email.list', "List", required=True)
-    active = fields.Boolean("Active")
     email_token = fields.Char("E-mail Token", required=True)
     web_user = fields.Function(
         fields.Many2One('web.user', "Web User"), 'get_web_user')
@@ -90,29 +80,25 @@
 
     @classmethod
     def __setup__(cls):
-        super(Email, cls).__setup__()
+        super().__setup__()
 
         t = cls.__table__()
         cls._sql_constraints = [
             ('email_list_unique', Unique(t, t.email, t.list_),
-                "Email can subscribe only once to list."),
+                'marketing.msg_email_list_unique'),
             ]
 
     @classmethod
     def __register__(cls, module_name):
         TableHandler = backend.get('TableHandler')
 
-        super(Email, cls).__register__(module_name)
+        super().__register__(module_name)
 
         table = TableHandler(cls, module_name)
         table.index_action(['email', 'list_'], action='add')
         table.index_action(['list_', 'active'], action='add')
 
     @classmethod
-    def default_active(cls):
-        return True
-
-    @classmethod
     def default_email_token(cls, nbytes=None):
         return token_hex(nbytes)
 
@@ -120,11 +106,11 @@
     def get_web_user(cls, records, name):
         pool = Pool()
         WebUser = pool.get('web.user')
-        result = dict.fromkeys(map(int, records))
+        result = dict.fromkeys(list(map(int, records)))
         for sub_records in grouped_slice(records):
             email2id = {r.email: r.id for r in sub_records}
             web_users = WebUser.search([
-                    ('email', 'in', email2id.keys()),
+                    ('email', 'in', list(email2id.keys())),
                     ])
             result.update((email2id[u.email], u.id) for u in web_users)
         return result
@@ -144,13 +130,13 @@
 
     @classmethod
     def create(cls, vlist):
-        records = super(Email, cls).create(vlist)
+        records = super().create(vlist)
         cls._format_email(records)
         return records
 
     @classmethod
     def write(cls, *args):
-        super(Email, cls).write(*args)
+        super().write(*args)
         records = sum(args[0:None:2], [])
         cls._format_email(records)
 
@@ -169,15 +155,15 @@
 
     @classmethod
     def subscribe_url(cls, url):
-        parts = urlparse.urlsplit(url)
-        tokens = filter(
-            None, urlparse.parse_qs(parts.query).get('token', [None]))
+        parts = urlsplit(url)
+        tokens = [
+            _f for _f in parse_qs(parts.query).get('token', [None]) if _f]
         for token in tokens:
             cls.subscribe(token)
 
     @classmethod
     def subscribe(cls, token):
-        # Make it slow to prevent brute force attack
+        # Make it slow to prevent brute force attacks
         delay = config.getint('marketing', 'subscribe_delay', default=1)
         Transaction().atexit(time.sleep, delay)
         with Transaction().set_context(active_test=False):
@@ -201,15 +187,15 @@
 
     @classmethod
     def unsubscribe_url(cls, url):
-        parts = urlparse.urlsplit(url)
-        tokens = filter(
-            None, urlparse.parse_qs(parts.query).get('token', [None]))
+        parts = urlsplit(url)
+        tokens = [
+            _f for _f in parse_qs(parts.query).get('token', [None]) if _f]
         for token in tokens:
             cls.unsubscribe(token)
 
     @classmethod
     def unsubscribe(cls, token):
-        # Make it slow to prevent brute force attack
+        # Make it slow to prevent brute force attacks
         delay = config.getint('marketing', 'subscribe_delay', default=1)
         Transaction().atexit(time.sleep, delay)
         records = cls.search([
@@ -223,7 +209,7 @@
 
     @classmethod
     def get_context(cls, records, data):
-        context = super(EmailSubscribe, cls).get_context(records, data)
+        context = super().get_context(records, data)
         context['extract_params'] = _extract_params
         return context
 
@@ -233,13 +219,13 @@
 
     @classmethod
     def get_context(cls, records, data):
-        context = super(EmailUnsubscribe, cls).get_context(records, data)
+        context = super().get_context(records, data)
         context['extract_params'] = _extract_params
         return context
 
 
 class EmailList(ModelSQL, ModelView):
-    "Marketing E-mail List"
+    "Marketing Mailing List"
     __name__ = 'marketing.email.list'
 
     name = fields.Char("Name", required=True)
@@ -269,8 +255,8 @@
         pool = Pool()
         Email = pool.get('marketing.email')
 
-        # Make time random to process to prevent guessing between subscribed
-        # and not subscribed requests.
+        # Randomize processing time to prevent guessing whether the email
+        # address is already subscribed to the list or not.
         Transaction().atexit(time.sleep, random.random())
 
         email = email.lower()
@@ -298,8 +284,8 @@
         pool = Pool()
         Email = pool.get('marketing.email')
 
-        # Make time random to process to prevent guessing between subscribed
-        # and not subscribed requests.
+        # Randomize processing time to prevent guessing whether the email
+        # address was subscribed to the list or not.
         Transaction().atexit(time.sleep, random.random())
 
         email = email.lower()
@@ -319,3 +305,197 @@
                 msg['To'] = record.email
                 msg['Subject'] = Header(title, 'utf-8')
                 sendmail_transactional(from_, [record.email], msg)
+
+
+class Message(Workflow, ModelSQL, ModelView):
+    "Marketing E-mail Message"
+    __name__ = 'marketing.email.message'
+    _rec_name = 'title'
+
+    _states = {
+        'readonly': Eval('state') != 'draft',
+        }
+    _depends = ['state']
+    from_ = fields.Char(
+        "From", states=_states, depends=_depends,
+        help="Leave empty for the value defined in the configuration file.")
+    list_ = fields.Many2One(
+        'marketing.email.list', "List",
+        required=True, states=_states, depends=_depends)
+    title = fields.Char(
+        "Title", required=True, states=_states, depends=_depends)
+    content = fields.Text(
+        "Content", states=_states, depends=_depends)
+    state = fields.Selection([
+            ('draft', "Draft"),
+            ('sending', "Sending"),
+            ('sent', "Sent"),
+            ], "State", readonly=True, select=True)
+    del _states, _depends
+
+    @classmethod
+    def __setup__(cls):
+        super().__setup__()
+        cls._transitions |= set([
+                ('draft', 'sending'),
+                ('sending', 'sent'),
+                ('sending', 'draft'),
+                ])
+        cls._buttons.update({
+                'draft': {
+                    'invisible': Eval('state') != 'sending',
+                    'depends': ['state'],
+                    },
+                'send': {
+                    'invisible': Eval('state') != 'draft',
+                    'depends': ['state'],
+                    },
+                'send_test': {
+                    'invisible': Eval('state') != 'draft',
+                    'depends': ['state'],
+                    },
+                })
+
+    @classmethod
+    def default_state(cls):
+        return 'draft'
+
+    @classmethod
+    @ModelView.button
+    @Workflow.transition('draft')
+    def draft(cls, messages):
+        pass
+
+    @classmethod
+    @ModelView.button_action('marketing_email.wizard_send_test')
+    def send_test(cls, messages):
+        pass
+
+    @classmethod
+    @ModelView.button
+    @Workflow.transition('sending')
+    def send(cls, messages):
+        pass
+
+    @classmethod
+    @Workflow.transition('sent')
+    def sent(cls, messages):
+        pass
+
+    @classmethod
+    def process(cls, messages=None, emails=None, smtpd_datamanager=None):
+        pool = Pool()
+        WebShortener = pool.get('web.shortened_url')
+
+        @lru_cache(None)
+        def short(url, record):
+            url = WebShortener(
+                record=record,
+                redirect_url=url)
+            url.save()
+            return url.shortened_url
+
+        def convert_href(message):
+            def filter_(stream):
+                for kind, data, pos in stream:
+                    if kind is START:
+                        tag, attrs = data
+                        if tag == 'a':
+                            href = attrs.get('href')
+                            attrs -= 'href'
+                            href = short(href, str(message))
+                            attrs |= [(QName('href'), href)]
+                            data = tag, attrs
+                    elif kind is END and data == 'body':
+                        yield START, (QName('img'), Attrs([
+                                    (QName('src'), short(
+                                            URL_OPEN, str(message))),
+                                    (QName('height'), '1'),
+                                    (QName('width'), '1'),
+                                    ])), pos
+                        yield END, QName('img'), pos
+                    yield kind, data, pos
+            return filter_
+
+        if not smtpd_datamanager:
+            smtpd_datamanager = SMTPDataManager()
+        if messages is None:
+            messages = cls.search([
+                    ('state', '=', 'sending'),
+                    ])
+
+        for message in messages:
+            template = MarkupTemplate(message.content)
+            for email in (emails or message.list_.emails):
+                content = (template
+                    .generate(email=email)
+                    .filter(convert_href(message))
+                    .render())
+
+                name = email.party.rec_name if email.party else ''
+                from_ = (message.from_
+                    or config.get('marketing', 'email_from')
+                    or config.get('email', 'from'))
+                to = _formataddr(name, email.email)
+
+                msg = MIMEMultipart('alternative')
+                msg['From'] = from_
+                msg['To'] = to
+                msg['Subject'] = Header(message.title, 'utf-8')
+                if html2text:
+                    converter = html2text.HTML2Text()
+                    part = MIMEText(
+                        converter.handle(content), 'plain', _charset='utf-8')
+                    msg.attach(part)
+                part = MIMEText(content, 'html', _charset='utf-8')
+                msg.attach(part)
+
+                sendmail_transactional(
+                    from_, getaddresses([to]), msg,
+                    datamanager=smtpd_datamanager)
+        if not emails:
+            cls.sent(messages)
+
+
+class SendTest(Wizard):
+    "Send Test E-mail"
+    __name__ = 'marketing.email.send_test'
+    start = StateView(
+        'marketing.email.send_test',
+        'marketing_email.send_test_view_form', [
+            Button("Cancel", 'end', 'tryton-cancel'),
+            Button("Send", 'send', 'tryton-ok', default=True),
+            ])
+    send = StateTransition()
+
+    def default_start(self, fields):
+        pool = Pool()
+        Message = pool.get('marketing.email.message')
+
+        message = Message(Transaction().context.get('active_id'))
+        return {
+            'list_': message.list_.id,
+            'message': message.id,
+            }
+
+    def transition_send(self):
+        pool = Pool()
+        Message = pool.get('marketing.email.message')
+        Message.process([self.start.message], [self.start.email])
+        return 'end'
+
+
+class SendTestView(ModelView):
+    "Send Test E-mail"
+    __name__ = 'marketing.email.send_test'
+
+    list_ = fields.Many2One(
+        'marketing.email.list', "List", readonly=True)
+    message = fields.Many2One(
+        'marketing.email.message', "Message", readonly=True)
+    email = fields.Many2One(
+        'marketing.email', "E-Mail", required=True,
+        domain=[
+            ('list_', '=', Eval('list_')),
+            ],
+        depends=['list_'])
--- a/marketing.xml	Thu Jan 31 19:56:22 2019 +0100
+++ b/marketing.xml	Wed Oct 23 16:10:03 2019 +0200
@@ -3,28 +3,6 @@
 this repository contains the full copyright notices and license terms. -->
 <tryton>
     <data>
-        <record model="res.group" id="group_marketing_mailing">
-            <field name="name">Marketing Mailing</field>
-        </record>
-        <record model="res.user-res.group" id="user_admin-group_marketing_mailing">
-            <field name="user" ref="res.user_admin"/>
-            <field name="group" ref="group_marketing_mailing"/>
-        </record>
-
-        <!-- Move to marketing module when it will exists -->
-        <menuitem name="Marketing" id="menu_marketing" sequence="5"/>
-        <record model="ir.ui.menu-res.group" id="menu_marketing-group_marketing_mailing">
-            <field name="menu" ref="menu_marketing"/>
-            <field name="group" ref="group_marketing_mailing"/>
-        </record>
-
-        <menuitem name="Mailing" id="menu_mailing" sequence="10"
-            parent="menu_marketing"/>
-        <record model="ir.ui.menu-res.group" id="menu_mailing_group_marketing_email">
-            <field name="menu" ref="menu_mailing"/>
-            <field name="group" ref="group_marketing_mailing"/>
-        </record>
-
         <record model="ir.ui.view" id="email_view_form">
             <field name="model">marketing.email</field>
             <field name="type">form</field>
@@ -40,19 +18,13 @@
         <record model="ir.action.act_window" id="act_email_relate_list">
             <field name="name">E-mails</field>
             <field name="res_model">marketing.email</field>
-            <field name="domain"
-                eval="[If(Eval('active_ids', []) == [Eval('active_id')], ('list_', '=', Eval('active_id')), ('list_', 'in', Eval('active_ids')))]"
-                pyson="1"/>
+            <field name="domain" eval="[('list_', '=', Eval('active_id'))]" pyson="1"/>
         </record>
         <record model="ir.action.keyword" id="act_email_relate_list_keyword1">
             <field name="keyword">form_relate</field>
             <field name="model">marketing.email.list,-1</field>
             <field name="action" ref="act_email_relate_list"/>
         </record>
-        <record model="ir.action-res.group" id="act_email_relate_list-group_marketing_mailing">
-            <field name="action" ref="act_email_relate_list"/>
-            <field name="group" ref="group_marketing_mailing"/>
-        </record>
 
         <record model="ir.model.access" id="access_email">
             <field name="model" search="[('model', '=', 'marketing.email')]"/>
@@ -63,7 +35,7 @@
         </record>
         <record model="ir.model.access" id="access_email_marketing_email">
             <field name="model" search="[('model', '=', 'marketing.email')]"/>
-            <field name="group" ref="group_marketing_mailing"/>
+            <field name="group" ref="marketing.group_marketing"/>
             <field name="perm_read" eval="True"/>
             <field name="perm_write" eval="True"/>
             <field name="perm_create" eval="True"/>
@@ -83,7 +55,7 @@
         </record>
 
         <record model="ir.action.act_window" id="act_email_list_form">
-            <field name="name">Lists</field>
+            <field name="name">Mailing Lists</field>
             <field name="res_model">marketing.email.list</field>
         </record>
         <record model="ir.action.act_window.view" id="act_email_list_form_view1">
@@ -96,9 +68,7 @@
             <field name="view" ref="email_list_view_form"/>
             <field name="act_window" ref="act_email_list_form"/>
         </record>
-
-        <menuitem parent="menu_mailing" action="act_email_list_form"
-            id="menu_email_list_form" sequence="10"/>
+        <menuitem parent="marketing.menu_marketing" action="act_email_list_form" id="menu_email_list_form"/>
 
         <record model="ir.model.access" id="access_email_list">
             <field name="model" search="[('model', '=', 'marketing.email.list')]"/>
@@ -109,7 +79,7 @@
         </record>
         <record model="ir.model.access" id="access_email_list_marketing_email">
             <field name="model" search="[('model', '=', 'marketing.email.list')]"/>
-            <field name="group" ref="group_marketing_mailing"/>
+            <field name="group" ref="marketing.group_marketing"/>
             <field name="perm_read" eval="True"/>
             <field name="perm_write" eval="True"/>
             <field name="perm_create" eval="True"/>
@@ -130,5 +100,110 @@
             <field name="report">marketing_email/email_unsubscribe.html</field>
             <field name="template_extension">html</field>
         </record>
+
+        <record model="ir.ui.view" id="email_message_view_form">
+            <field name="model">marketing.email.message</field>
+            <field name="type">form</field>
+            <field name="name">email_message_form</field>
+        </record>
+
+        <record model="ir.ui.view" id="email_message_view_list">
+            <field name="model">marketing.email.message</field>
+            <field name="type">tree</field>
+            <field name="name">email_message_list</field>
+        </record>
+
+        <record model="ir.action.act_window" id="act_email_message_form">
+            <field name="name">Messages</field>
+            <field name="res_model">marketing.email.message</field>
+            <field name="domain" eval="[('list_', '=', Eval('active_id'))]" pyson="1"/>
+        </record>
+        <record model="ir.action.act_window.view" id="act_email_message_form_view1">
+            <field name="sequence" eval="10"/>
+            <field name="view" ref="email_message_view_list"/>
+            <field name="act_window" ref="act_email_message_form"/>
+        </record>
+        <record model="ir.action.act_window.view" id="act_email_message_form_view2">
+            <field name="sequence" eval="20"/>
+            <field name="view" ref="email_message_view_form"/>
+            <field name="act_window" ref="act_email_message_form"/>
+        </record>
+        <record model="ir.action.act_window.domain" id="act_email_message_form_draft">
+            <field name="name">Draft</field>
+            <field name="sequence" eval="10"/>
+            <field name="domain" eval="[('state', '=', 'draft')]" pyson="1"/>
+            <field name="count" eval="True"/>
+            <field name="act_window" ref="act_email_message_form"/>
+        </record>
+        <record model="ir.action.act_window.domain" id="act_email_message_form_sending">
+            <field name="name">Sending</field>
+            <field name="sequence" eval="20"/>
+            <field name="domain" eval="[('state', '=', 'sending')]" pyson="1"/>
+            <field name="count" eval="True"/>
+            <field name="act_window" ref="act_email_message_form"/>
+        </record>
+        <record model="ir.action.act_window.domain" id="act_email_message_form_all">
+            <field name="name">All</field>
+            <field name="sequence" eval="9999"/>
+            <field name="domain"/>
+            <field name="act_window" ref="act_email_message_form"/>
+        </record>
+        <record model="ir.action.keyword" id="act_email_message_form_keyword1">
+            <field name="keyword">form_relate</field>
+            <field name="model">marketing.email.list,-1</field>
+            <field name="action" ref="act_email_message_form"/>
+        </record>
+
+        <record model="ir.model.button" id="message_draft_button">
+            <field name="name">draft</field>
+            <field name="string">Draft</field>
+            <field name="model" search="[('model', '=', 'marketing.email.message')]"/>
+        </record>
+
+        <record model="ir.model.button" id="message_send_button">
+            <field name="name">send</field>
+            <field name="string">Send</field>
+            <field name="model" search="[('model', '=', 'marketing.email.message')]"/>
+        </record>
+
+        <record model="ir.model.button" id="message_send_test_button">
+            <field name="name">send_test</field>
+            <field name="string">Send Test</field>
+            <field name="model" search="[('model', '=', 'marketing.email.message')]"/>
+        </record>
+
+        <record model="ir.model.access" id="access_email_message">
+            <field name="model" search="[('model', '=', 'marketing.email.message')]"/>
+            <field name="perm_read" eval="True"/>
+            <field name="perm_write" eval="False"/>
+            <field name="perm_create" eval="False"/>
+            <field name="perm_delete" eval="False"/>
+        </record>
+        <record model="ir.model.access" id="access_email_message_marketing_email">
+            <field name="model" search="[('model', '=', 'marketing.email.message')]"/>
+            <field name="group" ref="marketing.group_marketing"/>
+            <field name="perm_read" eval="True"/>
+            <field name="perm_write" eval="True"/>
+            <field name="perm_create" eval="True"/>
+            <field name="perm_delete" eval="True"/>
+        </record>
+
+        <record model="ir.action.wizard" id="wizard_send_test">
+            <field name="name">Send Test</field>
+            <field name="wiz_name">marketing.email.send_test</field>
+            <field name="model">marketing.email.message</field>
+        </record>
+
+        <record model="ir.ui.view" id="send_test_view_form">
+            <field name="model">marketing.email.send_test</field>
+            <field name="type">form</field>
+            <field name="name">send_test_form</field>
+        </record>
+
+        <record model="ir.cron" id="cron_send_messages">
+            <field name="method">marketing.email.message|process</field>
+            <field name="interval_number" eval="15"/>
+            <field name="interval_type">minutes</field>
+        </record>
     </data>
 </tryton>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/message.xml	Wed Oct 23 16:10:03 2019 +0200
@@ -0,0 +1,10 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<tryton>
+    <data grouped="1">
+        <record model="ir.message" id="msg_email_list_unique">
+            <field name="text">Email address can only be subscribed once on each list.</field>
+        </record>
+    </data>
+</tryton>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/routes.py	Wed Oct 23 16:10:03 2019 +0200
@@ -0,0 +1,12 @@
+# 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 werkzeug.wrappers import Response
+
+from trytond.tools import file_open
+from trytond.wsgi import app
+
+
+@app.route('/m/empty.gif')
+def empty(request):
+    fp = file_open('marketing_email/empty.gif', mode='rb')
+    return Response(fp, 200, content_type='image/gif', direct_passthrough=True)
--- a/setup.py	Thu Jan 31 19:56:22 2019 +0100
+++ b/setup.py	Wed Oct 23 16:10:03 2019 +0200
@@ -1,15 +1,12 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 # 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 setuptools import setup
-import re
+import io
 import os
-import io
-try:
-    from configparser import ConfigParser
-except ImportError:
-    from ConfigParser import ConfigParser
+import re
+from configparser import ConfigParser
+from setuptools import setup, find_packages
 
 
 def read(fname):
@@ -27,8 +24,9 @@
         major_version, minor_version + 1)
     return require
 
+
 config = ConfigParser()
-config.readfp(open('tryton.cfg'))
+config.read_file(open(os.path.join(os.path.dirname(__file__), 'tryton.cfg')))
 info = dict(config.items('tryton'))
 for key in ('depends', 'extras_depend', 'xml'):
     if key in info:
@@ -54,34 +52,34 @@
 requires.append(get_require_version('trytond'))
 
 tests_require = []
-try:
-    from unittest import mock
-    mock.__version__
-except ImportError:
-    tests_require.append('mock')
 dependency_links = []
 if minor_version % 2:
-    # Add development index for testing with proteus
     dependency_links.append('https://trydevpi.tryton.org/')
 
 setup(name=name,
     version=version,
     description='Tryton module to manage marketing mailing lists',
-    long_description=read('README'),
+    long_description=read('README.rst'),
     author='Tryton',
-    author_email='issue_tracker@tryton.org',
+    author_email='bugs@tryton.org',
     url='http://www.tryton.org/',
     download_url=download_url,
+    project_urls={
+        "Bug Tracker": 'https://bugs.tryton.org/',
+        "Documentation": 'https://docs.tryton.org/',
+        "Forum": 'https://www.tryton.org/forum',
+        "Source Code": 'https://hg.tryton.org/modules/marketing_email',
+        },
     keywords='tryton marketing email list',
     package_dir={'trytond.modules.marketing_email': '.'},
-    packages=[
-        'trytond.modules.marketing_email',
-        'trytond.modules.marketing_email.tests',
-        ],
+    packages=(
+        ['trytond.modules.marketing_email'] +
+        ['trytond.modules.marketing_email.%s' % p for p in find_packages()]
+        ),
     package_data={
         'trytond.modules.marketing_email': (info.get('xml', [])
             + ['tryton.cfg', 'view/*.xml', 'locale/*.po', '*.fodt', '*.html',
-                'icons/*.svg', 'tests/*.rst']),
+                '*.gif', 'icons/*.svg', 'tests/*.rst']),
         },
     classifiers=[
         'Development Status :: 5 - Production/Stable',
@@ -93,27 +91,33 @@
         'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
         'Natural Language :: Bulgarian',
         'Natural Language :: Catalan',
+        'Natural Language :: Chinese (Simplified)',
         'Natural Language :: Czech',
         'Natural Language :: Dutch',
         'Natural Language :: English',
+        'Natural Language :: Finnish',
         'Natural Language :: French',
         'Natural Language :: German',
         'Natural Language :: Hungarian',
         'Natural Language :: Italian',
+        'Natural Language :: Persian',
+        'Natural Language :: Polish',
         'Natural Language :: Portuguese (Brazilian)',
         'Natural Language :: Russian',
         'Natural Language :: Slovenian',
         'Natural Language :: Spanish',
+        'Natural Language :: Turkish',
         'Operating System :: OS Independent',
-        'Programming Language :: Python :: 2.7',
-        'Programming Language :: Python :: 3.3',
-        'Programming Language :: Python :: 3.4',
+        'Programming Language :: Python :: 3',
         'Programming Language :: Python :: 3.5',
+        'Programming Language :: Python :: 3.6',
+        'Programming Language :: Python :: 3.7',
         'Programming Language :: Python :: Implementation :: CPython',
         'Programming Language :: Python :: Implementation :: PyPy',
         'Topic :: Office/Business',
         ],
     license='GPL-3',
+    python_requires='>=3.5',
     install_requires=requires,
     dependency_links=dependency_links,
     zip_safe=False,
@@ -124,5 +128,4 @@
     test_suite='tests',
     test_loader='trytond.test_loader:Loader',
     tests_require=tests_require,
-    use_2to3=True,
     )
--- a/tests/test_marketing_email.py	Thu Jan 31 19:56:22 2019 +0100
+++ b/tests/test_marketing_email.py	Wed Oct 23 16:10:03 2019 +0200
@@ -2,20 +2,16 @@
 # this repository contains the full copyright notices and license terms.
 
 import unittest
-
-try:
-    from unittest.mock import patch, ANY
-except ImportError:
-    from mock import patch, ANY
-
+from unittest.mock import patch, ANY, Mock
 
 from trytond.config import config
-from trytond.modules.marketing_email import marketing as marketing_module
 from trytond.pool import Pool
 from trytond.tests.test_tryton import ModuleTestCase, with_transaction
 from trytond.tests.test_tryton import suite as test_suite
 from trytond.transaction import Transaction
 
+from trytond.modules.marketing_email import marketing as marketing_module
+
 SUBSCRIBE_URL = 'http://www.example.com/subscribe'
 UNSUBSCRIBE_URL = 'http://www.example.com/unsubscribe'
 FROM = 'marketing@example.com'
@@ -26,7 +22,7 @@
     module = 'marketing_email'
 
     def setUp(self):
-        super(MarketingEmailTestCase, self).setUp()
+        super().setUp()
         if not config.has_section('marketing'):
             config.add_section('marketing')
         subscribe_url = config.get('marketing', 'email_subscribe_url')
@@ -39,9 +35,9 @@
         self.addCleanup(
             lambda: config.set(
                 'marketing', 'email_unsubscribe_url', unsubscribe_url))
-        from_ = config.get('marketing', 'email_from')
-        config.set('marketing', 'email_from', FROM)
-        self.addCleanup(lambda: config.set('marketing', 'email_from', from_))
+        from_ = config.get('email', 'from')
+        config.set('email', 'from', FROM)
+        self.addCleanup(lambda: config.set('email', 'from', from_))
 
     @with_transaction()
     def test_subscribe(self):
@@ -114,6 +110,47 @@
         Email.unsubscribe_url(email.get_email_unsubscribe_url())
         self.assertFalse(email.active)
 
+    @with_transaction()
+    def test_sent_messages(self):
+        "Test messages are sent to the list"
+        pool = Pool()
+        Email = pool.get('marketing.email')
+        EmailList = pool.get('marketing.email.list')
+        Message = pool.get('marketing.email.message')
+        ShortenedURL = pool.get('web.shortened_url')
+
+        email_list = EmailList(name="Test")
+        email_list.save()
+
+        email = Email(email='user@example.com', list_=email_list)
+        email.save()
+        message = Message(list_=email_list)
+        message.title = 'Test'
+        message.content = (
+            '<html>'
+            '<body>'
+            '<a href="http://www.example.com/">Content</a>'
+            '</body>'
+            '</html>')
+        message.save()
+        Message.send([message])
+
+        with patch.object(
+                marketing_module,
+                'sendmail_transactional') as sendmail:
+            smtpd_datamanager = Mock()
+            Message.process(smtpd_datamanager=smtpd_datamanager)
+
+            sendmail.assert_called_once_with(
+                FROM, [('', 'user@example.com')], ANY,
+                datamanager=smtpd_datamanager)
+        urls = ShortenedURL.search([
+                ('record', '=', str(message)),
+                ])
+
+        self.assertEqual(message.state, 'sent')
+        self.assertEqual(len(urls), 2)
+
 
 def suite():
     suite = test_suite()
--- a/tox.ini	Thu Jan 31 19:56:22 2019 +0100
+++ b/tox.ini	Wed Oct 23 16:10:03 2019 +0200
@@ -1,18 +1,15 @@
 [tox]
-envlist = {py27,py33,py34,py35}-{sqlite,postgresql,mysql},pypy-{sqlite,postgresql}
+envlist = {py34,py35,py36,py37}-{sqlite,postgresql},pypy3-{sqlite,postgresql}
 
 [testenv]
 commands = {envpython} setup.py test
 deps =
-    {py27,py33,py34,py35}-postgresql: psycopg2 >= 2.5
-    pypy-postgresql: psycopg2cffi >= 2.5
-    mysql: MySQL-python
-    sqlite: sqlitebck
+    {py34,py35,py36,py37}-postgresql: psycopg2 >= 2.5
+    pypy3-postgresql: psycopg2cffi >= 2.5
+    {py34,py35,py36}-sqlite: sqlitebck
 setenv =
     sqlite: TRYTOND_DATABASE_URI={env:SQLITE_URI:sqlite://}
     postgresql: TRYTOND_DATABASE_URI={env:POSTGRESQL_URI:postgresql://}
-    mysql: TRYTOND_DATABASE_URI={env:MYSQL_URI:mysql://}
     sqlite: DB_NAME={env:SQLITE_NAME::memory:}
     postgresql: DB_NAME={env:POSTGRESQL_NAME:test}
-    mysql: DB_NAME={env:MYSQL_NAME:test}
-install_command = pip install --pre --find-links https://trydevpi.tryton.org/ {opts} {packages}
+install_command = pip install --pre --process-dependency-links {opts} {packages}
--- a/tryton.cfg	Thu Jan 31 19:56:22 2019 +0100
+++ b/tryton.cfg	Wed Oct 23 16:10:03 2019 +0200
@@ -1,9 +1,12 @@
 [tryton]
-version=4.4.0
+version=5.2.0
 depends:
     ir
+    marketing
     party
     res
     web_user
+    web_shortener
 xml:
     marketing.xml
+    message.xml
--- a/view/email_form.xml	Thu Jan 31 19:56:22 2019 +0100
+++ b/view/email_form.xml	Wed Oct 23 16:10:03 2019 +0200
@@ -2,10 +2,10 @@
 <!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
 this repository contains the full copyright notices and license terms. -->
 <form>
-    <label name="email"/>
-    <field name="email" colspan="3"/>
     <label name="list_"/>
     <field name="list_"/>
     <label name="active"/>
     <field name="active"/>
+    <label name="email"/>
+    <field name="email" colspan="3"/>
 </form>
--- a/view/email_list.xml	Thu Jan 31 19:56:22 2019 +0100
+++ b/view/email_list.xml	Wed Oct 23 16:10:03 2019 +0200
@@ -2,8 +2,8 @@
 <!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
 this repository contains the full copyright notices and license terms. -->
 <tree>
-    <field name="list_"/>
-    <field name="email"/>
-    <field name="party"/>
-    <field name="active" tree_invisible="1"/>
+    <field name="list_" expand="1"/>
+    <field name="email" expand="1"/>
+    <field name="party" expand="1"/>
+    <field name="web_user" expand="1"/>
 </tree>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/view/email_message_form.xml	Wed Oct 23 16:10:03 2019 +0200
@@ -0,0 +1,23 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<form cursor="list_">
+    <label name="from_"/>
+    <field name="from_" colspan="3"/>
+    <label name="list_" string="To:"/>
+    <field name="list_" colspan="3"/>
+    <label name="title"/>
+    <field name="title" colspan="3"/>
+    <label name="content" yfill="1" yalign="0"/>
+    <group col="-1" id="content" colspan="3" yexpand="1">
+        <field name="content"/>
+        <field name="content" string="Edit" widget="html" xexpand="0"/>
+    </group>
+    <label name="state"/>
+    <field name="state"/>
+    <group col="-1" colspan="2" id="buttons">
+        <button name="draft"/>
+        <button name="send_test" colspan="2"/>
+        <button name="send"/>
+    </group>
+</form>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/view/email_message_list.xml	Wed Oct 23 16:10:03 2019 +0200
@@ -0,0 +1,9 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<tree>
+    <field name="from_"/>
+    <field name="list_"/>
+    <field name="title"/>
+    <field name="state"/>
+</tree>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/view/send_test_form.xml	Wed Oct 23 16:10:03 2019 +0200
@@ -0,0 +1,9 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<form col="2">
+    <label name="email" string="To:"/>
+    <field name="email"/>
+    <label name="message"/>
+    <field name="message"/>
+</form>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web.py	Wed Oct 23 16:10:03 2019 +0200
@@ -0,0 +1,14 @@
+# 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 trytond.pool import PoolMeta
+
+
+class ShortenedURL(metaclass=PoolMeta):
+    __name__ = 'web.shortened_url'
+
+    @classmethod
+    def _get_models(cls):
+        return super()._get_models() + [
+            'marketing.email.message',
+            ]