562 lines
19 KiB
Python
562 lines
19 KiB
Python
# 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 random
|
|
import time
|
|
from collections import defaultdict
|
|
from email.message import EmailMessage
|
|
from functools import lru_cache, partial
|
|
from urllib.parse import (
|
|
parse_qs, parse_qsl, urlencode, urljoin, urlsplit, urlunsplit)
|
|
|
|
from genshi.core import END, START, Attrs, QName
|
|
from genshi.template import MarkupTemplate
|
|
from genshi.template import TemplateError as GenshiTemplateError
|
|
from sql import Literal
|
|
from sql.aggregate import Count
|
|
|
|
try:
|
|
import html2text
|
|
except ImportError:
|
|
html2text = None
|
|
|
|
import trytond.config as config
|
|
from trytond.i18n import gettext
|
|
from trytond.ir.session import token_hex
|
|
from trytond.model import (
|
|
DeactivableMixin, Index, ModelSQL, ModelView, Unique, Workflow, fields)
|
|
from trytond.pool import Pool
|
|
from trytond.pyson import Eval
|
|
from trytond.report import Report, get_email
|
|
from trytond.sendmail import SMTPDataManager, send_message_transactional
|
|
from trytond.tools import grouped_slice, reduce_ids
|
|
from trytond.tools.email_ import (
|
|
EmailNotValidError, format_address, normalize_email, set_from_header,
|
|
validate_email)
|
|
from trytond.transaction import Transaction, inactive_records
|
|
from trytond.url import http_host
|
|
from trytond.wizard import Button, StateTransition, StateView, Wizard
|
|
|
|
from .exceptions import EMailValidationError, TemplateError
|
|
|
|
|
|
def _add_params(url, **params):
|
|
parts = urlsplit(url)
|
|
query = parse_qsl(parts.query)
|
|
for key, value in sorted(params.items()):
|
|
query.append((key, value))
|
|
parts = list(parts)
|
|
parts[3] = urlencode(query)
|
|
return urlunsplit(parts)
|
|
|
|
|
|
def _extract_params(url):
|
|
return parse_qsl(urlsplit(url).query)
|
|
|
|
|
|
class Email(DeactivableMixin, ModelSQL, ModelView):
|
|
__name__ = 'marketing.email'
|
|
_rec_name = 'email'
|
|
|
|
email = fields.Char("Email", required=True)
|
|
list_ = fields.Many2One('marketing.email.list', "List", required=True)
|
|
email_token = fields.Char("Email Token", required=True)
|
|
web_user = fields.Function(
|
|
fields.Many2One('web.user', "Web User"), 'get_web_user')
|
|
party = fields.Function(
|
|
fields.Many2One('party.party', "Party"), 'get_web_user')
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
|
|
t = cls.__table__()
|
|
cls._sql_constraints = [
|
|
('email_list_unique', Unique(t, t.email, t.list_),
|
|
'marketing_email.msg_email_list_unique'),
|
|
]
|
|
cls._sql_indexes.add(Index(
|
|
t,
|
|
(t.list_, Index.Range()),
|
|
(t.email, Index.Equality(cardinality='high'))))
|
|
|
|
@classmethod
|
|
def default_email_token(cls, nbytes=None):
|
|
return token_hex(nbytes)
|
|
|
|
@classmethod
|
|
def get_web_user(cls, records, names):
|
|
pool = Pool()
|
|
WebUser = pool.get('web.user')
|
|
result = {}
|
|
web_user = 'web_user' in names
|
|
if web_user:
|
|
web_users = dict.fromkeys(list(map(int, records)))
|
|
result['web_user'] = web_users
|
|
party = 'party' in names
|
|
if party:
|
|
parties = dict.fromkeys(list(map(int, records)))
|
|
result['party'] = parties
|
|
for sub_records in grouped_slice(records):
|
|
email2id = {r.email: r.id for r in sub_records}
|
|
users = WebUser.search([
|
|
('email', 'in', list(email2id.keys())),
|
|
])
|
|
if web_user:
|
|
web_users.update((email2id[u.email], u.id) for u in users)
|
|
if party:
|
|
parties.update(
|
|
(email2id[u.email], u.party.id)
|
|
for u in users if u.party)
|
|
return result
|
|
|
|
@classmethod
|
|
def preprocess_values(cls, mode, values):
|
|
values = super().preprocess_values(mode, values)
|
|
if mode == 'create':
|
|
# Ensure to get a different token for each record
|
|
# default methods are called only once
|
|
values.setdefault('email_token', cls.default_email_token())
|
|
if values.get('email'):
|
|
values['email'] = normalize_email(values['email']).lower()
|
|
return values
|
|
|
|
@classmethod
|
|
def validate_fields(cls, records, fields_names):
|
|
super().validate_fields(records, fields_names)
|
|
cls.check_valid_email(records, fields_names)
|
|
|
|
@classmethod
|
|
def check_valid_email(cls, records, fields_names=None):
|
|
if fields_names and 'email' not in fields_names:
|
|
return
|
|
for record in records:
|
|
if record.email:
|
|
try:
|
|
validate_email(record.email)
|
|
except EmailNotValidError as e:
|
|
raise EMailValidationError(gettext(
|
|
'marketing_email.msg_email_invalid',
|
|
record=record.rec_name,
|
|
email=record.email),
|
|
str(e)) from e
|
|
|
|
def get_email_subscribe(self, report_name='marketing.email.subscribe'):
|
|
pool = Pool()
|
|
ActionReport = pool.get('ir.action.report')
|
|
report, = ActionReport.search([
|
|
('report_name', '=', report_name),
|
|
], limit=1)
|
|
return get_email(report, self, [self.list_.language])
|
|
|
|
def get_email_subscribe_url(self, url=None):
|
|
if url is None:
|
|
url = config.get('marketing', 'email_subscribe_url')
|
|
if url is not None:
|
|
return _add_params(url, token=self.email_token)
|
|
else:
|
|
return ''
|
|
|
|
@classmethod
|
|
def subscribe_url(cls, url):
|
|
parts = urlsplit(url)
|
|
tokens = filter(
|
|
None, parse_qs(parts.query).get('token', [None]))
|
|
return cls.subscribe(list(tokens))
|
|
|
|
@classmethod
|
|
def subscribe(cls, tokens):
|
|
# Make it slow to prevent brute force attacks
|
|
delay = config.getint('marketing', 'subscribe_delay', default=1)
|
|
Transaction().atexit(time.sleep, delay)
|
|
with inactive_records():
|
|
records = cls.search([
|
|
('email_token', 'in', tokens),
|
|
])
|
|
cls.write(records, {'active': True})
|
|
return bool(records)
|
|
|
|
def get_email_unsubscribe(self, report_name='marketing.email.unsubscribe'):
|
|
pool = Pool()
|
|
ActionReport = pool.get('ir.action.report')
|
|
report, = ActionReport.search([
|
|
('report_name', '=', report_name),
|
|
], limit=1)
|
|
return get_email(report, self, [self.list_.language])
|
|
|
|
def get_email_unsubscribe_url(self, url=None):
|
|
if url is None:
|
|
url = config.get('marketing', 'email_unsubscribe_url')
|
|
if url is not None:
|
|
return _add_params(url, token=self.email_token)
|
|
else:
|
|
return ''
|
|
|
|
@classmethod
|
|
def unsubscribe_url(cls, url):
|
|
parts = urlsplit(url)
|
|
tokens = filter(
|
|
None, parse_qs(parts.query).get('token', [None]))
|
|
cls.unsubscribe(list(tokens))
|
|
|
|
@classmethod
|
|
def unsubscribe(cls, tokens):
|
|
# Make it slow to prevent brute force attacks
|
|
delay = config.getint('marketing', 'subscribe_delay', default=1)
|
|
Transaction().atexit(time.sleep, delay)
|
|
records = cls.search([
|
|
('email_token', 'in', tokens),
|
|
])
|
|
cls.write(records, {'active': False})
|
|
return bool(records)
|
|
|
|
|
|
class EmailSubscribe(Report):
|
|
__name__ = 'marketing.email.subscribe'
|
|
|
|
@classmethod
|
|
def get_context(cls, records, header, data):
|
|
context = super().get_context(records, header, data)
|
|
context['extract_params'] = _extract_params
|
|
return context
|
|
|
|
|
|
class EmailUnsubscribe(Report):
|
|
__name__ = 'marketing.email.unsubscribe'
|
|
|
|
@classmethod
|
|
def get_context(cls, records, header, data):
|
|
context = super().get_context(records, header, data)
|
|
context['extract_params'] = _extract_params
|
|
return context
|
|
|
|
|
|
class EmailList(DeactivableMixin, ModelSQL, ModelView):
|
|
__name__ = 'marketing.email.list'
|
|
|
|
name = fields.Char("Name", required=True)
|
|
language = fields.Many2One('ir.lang', "Language", required=True)
|
|
emails = fields.One2Many('marketing.email', 'list_', "Emails")
|
|
subscribed = fields.Function(
|
|
fields.Integer("Subscribed"), 'get_subscribed')
|
|
|
|
@staticmethod
|
|
def default_language():
|
|
Lang = Pool().get('ir.lang')
|
|
code = Transaction().context.get(
|
|
'language', config.get('database', 'language'))
|
|
try:
|
|
lang, = Lang.search([
|
|
('code', '=', code),
|
|
('translatable', '=', True),
|
|
], limit=1)
|
|
return lang.id
|
|
except ValueError:
|
|
return None
|
|
|
|
@classmethod
|
|
def get_subscribed(cls, lists, name):
|
|
pool = Pool()
|
|
Email = pool.get('marketing.email')
|
|
email = Email.__table__()
|
|
cursor = Transaction().connection.cursor()
|
|
|
|
subscribed = defaultdict(int)
|
|
query = email.select(
|
|
email.list_, Count(Literal('*')), group_by=[email.list_])
|
|
for sub_lists in grouped_slice(lists):
|
|
query.where = (
|
|
reduce_ids(email.list_, sub_lists)
|
|
& email.active)
|
|
cursor.execute(*query)
|
|
subscribed.update(cursor)
|
|
return subscribed
|
|
|
|
def request_subscribe(self, email, from_=None):
|
|
pool = Pool()
|
|
Email = pool.get('marketing.email')
|
|
|
|
# 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()
|
|
with inactive_records():
|
|
records = Email.search([
|
|
('email', '=', email),
|
|
('list_', '=', self.id),
|
|
])
|
|
if not records:
|
|
record = Email(email=email, list_=self.id, active=False)
|
|
record.save()
|
|
else:
|
|
record, = records
|
|
if not record.active:
|
|
from_cfg = (config.get('marketing', 'email_from')
|
|
or config.get('email', 'from'))
|
|
msg, title = record.get_email_subscribe()
|
|
set_from_header(msg, from_cfg, from_ or from_cfg)
|
|
msg['To'] = record.email
|
|
msg['Subject'] = title
|
|
send_message_transactional(msg)
|
|
|
|
def request_unsubscribe(self, email, from_=None):
|
|
pool = Pool()
|
|
Email = pool.get('marketing.email')
|
|
|
|
# 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()
|
|
with inactive_records():
|
|
records = Email.search([
|
|
('email', '=', email),
|
|
('list_', '=', self.id),
|
|
])
|
|
if records:
|
|
record, = records
|
|
if record.active:
|
|
from_cfg = (config.get('marketing', 'email_from')
|
|
or config.get('email', 'from'))
|
|
msg, title = record.get_email_unsubscribe()
|
|
set_from_header(msg, from_cfg, from_ or from_cfg)
|
|
msg['To'] = record.email
|
|
msg['Subject'] = title
|
|
send_message_transactional(msg)
|
|
|
|
|
|
class Message(Workflow, ModelSQL, ModelView):
|
|
__name__ = 'marketing.email.message'
|
|
_rec_name = 'title'
|
|
|
|
_states = {
|
|
'readonly': Eval('state') != 'draft',
|
|
}
|
|
from_ = fields.Char(
|
|
"From", states=_states,
|
|
help="Leave empty for the value defined in the configuration file.")
|
|
list_ = fields.Many2One(
|
|
'marketing.email.list', "List",
|
|
required=True, states=_states)
|
|
title = fields.Char(
|
|
"Title", required=True, states=_states)
|
|
content = fields.Text(
|
|
"Content",
|
|
states={
|
|
'required': Eval('state') != 'draft',
|
|
'readonly': _states['readonly'],
|
|
})
|
|
urls = fields.One2Many(
|
|
'web.shortened_url', 'record', "URLs", readonly=True)
|
|
state = fields.Selection([
|
|
('draft', "Draft"),
|
|
('sending', "Sending"),
|
|
('sent', "Sent"),
|
|
], "State", readonly=True, sort=False)
|
|
del _states
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
t = cls.__table__()
|
|
cls._sql_indexes.add(
|
|
Index(
|
|
t, (t.state, Index.Equality(cardinality='low')),
|
|
where=t.state.in_(['draft', 'sending'])))
|
|
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
|
|
def validate_fields(cls, messages, field_names):
|
|
super().validate_fields(messages, field_names)
|
|
cls.check_content(messages, field_names)
|
|
|
|
@classmethod
|
|
def check_content(cls, messages, field_names=None):
|
|
if field_names and 'content' not in field_names:
|
|
return
|
|
for message in messages:
|
|
if not message.content:
|
|
continue
|
|
try:
|
|
MarkupTemplate(message.content)
|
|
except GenshiTemplateError as exception:
|
|
raise TemplateError(
|
|
gettext('marketing_email'
|
|
'.msg_message_invalid_content',
|
|
message=message.rec_name,
|
|
exception=exception)) from exception
|
|
|
|
@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')
|
|
spy_pixel = config.getboolean(
|
|
'marketing', 'email_spy_pixel', default=False)
|
|
|
|
url_base = config.get('marketing', 'email_base', default=http_host())
|
|
url_open = urljoin(url_base, '/m/empty.gif')
|
|
|
|
@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' and attrs.get('href'):
|
|
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' and spy_pixel:
|
|
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:
|
|
try:
|
|
template = MarkupTemplate(message.content)
|
|
except GenshiTemplateError as exception:
|
|
raise TemplateError(
|
|
gettext('marketing_email'
|
|
'.msg_message_invalid_content',
|
|
message=message.rec_name,
|
|
exception=exception)) from exception
|
|
for email in (emails or message.list_.emails):
|
|
content = (template
|
|
.generate(
|
|
email=email,
|
|
short=partial(short, record=message))
|
|
.filter(convert_href(message))
|
|
.render())
|
|
|
|
name = email.party.rec_name if email.party else ''
|
|
from_cfg = (config.get('marketing', 'email_from')
|
|
or config.get('email', 'from'))
|
|
to = format_address(email.email, name)
|
|
|
|
msg = EmailMessage()
|
|
set_from_header(msg, from_cfg, message.from_ or from_cfg)
|
|
msg['To'] = to
|
|
msg['Subject'] = message.title
|
|
msg['List-Unsubscribe'] = (
|
|
f'<{email.get_email_unsubscribe_url()}>')
|
|
msg['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'
|
|
if html2text:
|
|
converter = html2text.HTML2Text()
|
|
content_text = converter.handle(content)
|
|
msg.add_alternative(content_text, subtype='plain')
|
|
if msg.is_multipart():
|
|
msg.add_alternative(content, subtype='html')
|
|
else:
|
|
msg.set_content(content, subtype='html')
|
|
|
|
send_message_transactional(msg, datamanager=smtpd_datamanager)
|
|
if not emails:
|
|
cls.sent(messages)
|
|
|
|
|
|
class SendTest(Wizard):
|
|
__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):
|
|
__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', "Email", required=True,
|
|
domain=[
|
|
('list_', '=', Eval('list_', -1)),
|
|
])
|