first commit

This commit is contained in:
root
2026-03-14 09:42:12 +00:00
commit 0adbd20c2c
10991 changed files with 1646955 additions and 0 deletions

View File

@@ -0,0 +1,561 @@
# 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)),
])