diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/mass_mailing/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mass_mailing/models')
| -rw-r--r-- | addons/mass_mailing/models/__init__.py | 15 | ||||
| -rw-r--r-- | addons/mass_mailing/models/link_tracker.py | 42 | ||||
| -rw-r--r-- | addons/mass_mailing/models/mail_mail.py | 88 | ||||
| -rw-r--r-- | addons/mass_mailing/models/mail_render_mixin.py | 23 | ||||
| -rw-r--r-- | addons/mass_mailing/models/mail_thread.py | 87 | ||||
| -rw-r--r-- | addons/mass_mailing/models/mailing.py | 825 | ||||
| -rw-r--r-- | addons/mass_mailing/models/mailing_contact.py | 170 | ||||
| -rw-r--r-- | addons/mass_mailing/models/mailing_list.py | 139 | ||||
| -rw-r--r-- | addons/mass_mailing/models/mailing_trace.py | 130 | ||||
| -rw-r--r-- | addons/mass_mailing/models/res_company.py | 17 | ||||
| -rw-r--r-- | addons/mass_mailing/models/res_config_settings.py | 21 | ||||
| -rw-r--r-- | addons/mass_mailing/models/res_users.py | 21 | ||||
| -rw-r--r-- | addons/mass_mailing/models/utm.py | 98 |
13 files changed, 1676 insertions, 0 deletions
diff --git a/addons/mass_mailing/models/__init__.py b/addons/mass_mailing/models/__init__.py new file mode 100644 index 00000000..9d9a63bd --- /dev/null +++ b/addons/mass_mailing/models/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import link_tracker +from . import mailing_contact +from . import mailing_list +from . import mailing_trace +from . import mailing +from . import mail_mail +from . import mail_render_mixin +from . import mail_thread +from . import res_config_settings +from . import res_users +from . import utm +from . import res_company diff --git a/addons/mass_mailing/models/link_tracker.py b/addons/mass_mailing/models/link_tracker.py new file mode 100644 index 00000000..5fa99fba --- /dev/null +++ b/addons/mass_mailing/models/link_tracker.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class LinkTracker(models.Model): + _inherit = "link.tracker" + + mass_mailing_id = fields.Many2one('mailing.mailing', string='Mass Mailing') + + +class LinkTrackerClick(models.Model): + _inherit = "link.tracker.click" + + mailing_trace_id = fields.Many2one('mailing.trace', string='Mail Statistics') + mass_mailing_id = fields.Many2one('mailing.mailing', string='Mass Mailing') + + def _prepare_click_values_from_route(self, **route_values): + click_values = super(LinkTrackerClick, self)._prepare_click_values_from_route(**route_values) + + if click_values.get('mailing_trace_id'): + trace_sudo = self.env['mailing.trace'].sudo().browse(route_values['mailing_trace_id']).exists() + if not trace_sudo: + click_values['mailing_trace_id'] = False + else: + if not click_values.get('campaign_id'): + click_values['campaign_id'] = trace_sudo.campaign_id.id + if not click_values.get('mass_mailing_id'): + click_values['mass_mailing_id'] = trace_sudo.mass_mailing_id.id + + return click_values + + @api.model + def add_click(self, code, **route_values): + click = super(LinkTrackerClick, self).add_click(code, **route_values) + + if click and click.mailing_trace_id: + click.mailing_trace_id.set_opened() + click.mailing_trace_id.set_clicked() + + return click diff --git a/addons/mass_mailing/models/mail_mail.py b/addons/mass_mailing/models/mail_mail.py new file mode 100644 index 00000000..4339dfdf --- /dev/null +++ b/addons/mass_mailing/models/mail_mail.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import re +import werkzeug.urls + +from odoo import api, fields, models, tools + + +class MailMail(models.Model): + """Add the mass mailing campaign data to mail""" + _inherit = ['mail.mail'] + + mailing_id = fields.Many2one('mailing.mailing', string='Mass Mailing') + mailing_trace_ids = fields.One2many('mailing.trace', 'mail_mail_id', string='Statistics') + + @api.model_create_multi + def create(self, values_list): + """ Override mail_mail creation to create an entry in mail.mail.statistics """ + # TDE note: should be after 'all values computed', to have values (FIXME after merging other branch holding create refactoring) + mails = super(MailMail, self).create(values_list) + for mail, values in zip(mails, values_list): + if values.get('mailing_trace_ids'): + mail.mailing_trace_ids.write({'message_id': mail.message_id}) + return mails + + def _get_tracking_url(self): + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + token = tools.hmac(self.env(su=True), 'mass_mailing-mail_mail-open', self.id) + return werkzeug.urls.url_join(base_url, 'mail/track/%s/%s/blank.gif' % (self.id, token)) + + def _send_prepare_body(self): + """ Override to add the tracking URL to the body and to add + trace ID in shortened urls """ + # TDE: temporary addition (mail was parameter) due to semi-new-API + self.ensure_one() + body = super(MailMail, self)._send_prepare_body() + + if self.mailing_id and body and self.mailing_trace_ids: + for match in re.findall(tools.URL_REGEX, self.body_html): + href = match[0] + url = match[1] + + parsed = werkzeug.urls.url_parse(url, scheme='http') + + if parsed.scheme.startswith('http') and parsed.path.startswith('/r/'): + new_href = href.replace(url, url + '/m/' + str(self.mailing_trace_ids[0].id)) + body = body.replace(href, new_href) + + # generate tracking URL + tracking_url = self._get_tracking_url() + body = tools.append_content_to_html( + body, + '<img src="%s"/>' % tracking_url, + plaintext=False, + ) + + body = self.env['mail.render.mixin']._replace_local_links(body) + + return body + + def _send_prepare_values(self, partner=None): + # TDE: temporary addition (mail was parameter) due to semi-new-API + res = super(MailMail, self)._send_prepare_values(partner) + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url').rstrip('/') + if self.mailing_id and res.get('body') and res.get('email_to'): + emails = tools.email_split(res.get('email_to')[0]) + email_to = emails and emails[0] or False + + urls_to_replace = [ + (base_url + '/unsubscribe_from_list', self.mailing_id._get_unsubscribe_url(email_to, self.res_id)), + (base_url + '/view', self.mailing_id._get_view_url(email_to, self.res_id)) + ] + + for url_to_replace, new_url in urls_to_replace: + if url_to_replace in res['body']: + res['body'] = res['body'].replace(url_to_replace, new_url if new_url else '#') + return res + + def _postprocess_sent_message(self, success_pids, failure_reason=False, failure_type=None): + mail_sent = not failure_type # we consider that a recipient error is a failure with mass mailling and show them as failed + for mail in self: + if mail.mailing_id: + if mail_sent is True and mail.mailing_trace_ids: + mail.mailing_trace_ids.write({'sent': fields.Datetime.now(), 'exception': False}) + elif mail_sent is False and mail.mailing_trace_ids: + mail.mailing_trace_ids.write({'exception': fields.Datetime.now(), 'failure_type': failure_type}) + return super(MailMail, self)._postprocess_sent_message(success_pids, failure_reason=failure_reason, failure_type=failure_type) diff --git a/addons/mass_mailing/models/mail_render_mixin.py b/addons/mass_mailing/models/mail_render_mixin.py new file mode 100644 index 00000000..133643f2 --- /dev/null +++ b/addons/mass_mailing/models/mail_render_mixin.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class MailRenderMixin(models.AbstractModel): + _inherit = "mail.render.mixin" + + @api.model + def _render_template_postprocess(self, rendered): + # super will transform relative url to absolute + rendered = super(MailRenderMixin, self)._render_template_postprocess(rendered) + + # apply shortener after + if self.env.context.get('post_convert_links'): + for res_id, html in rendered.items(): + rendered[res_id] = self._shorten_links( + html, + self.env.context['post_convert_links'], + blacklist=['/unsubscribe_from_list', '/view'] + ) + return rendered diff --git a/addons/mass_mailing/models/mail_thread.py b/addons/mass_mailing/models/mail_thread.py new file mode 100644 index 00000000..1f442675 --- /dev/null +++ b/addons/mass_mailing/models/mail_thread.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import datetime + +from odoo import api, models, fields, tools + +BLACKLIST_MAX_BOUNCED_LIMIT = 5 + + +class MailThread(models.AbstractModel): + """ Update MailThread to add the support of bounce management in mass mailing traces. """ + _inherit = 'mail.thread' + + @api.model + def _message_route_process(self, message, message_dict, routes): + """ Override to update the parent mailing traces. The parent is found + by using the References header of the incoming message and looking for + matching message_id in mailing.trace. """ + if routes: + # even if 'reply_to' in ref (cfr mail/mail_thread) that indicates a new thread redirection + # (aka bypass alias configuration in gateway) consider it as a reply for statistics purpose + thread_references = message_dict['references'] or message_dict['in_reply_to'] + msg_references = tools.mail_header_msgid_re.findall(thread_references) + if msg_references: + self.env['mailing.trace'].set_opened(mail_message_ids=msg_references) + self.env['mailing.trace'].set_replied(mail_message_ids=msg_references) + return super(MailThread, self)._message_route_process(message, message_dict, routes) + + def message_post_with_template(self, template_id, **kwargs): + # avoid having message send through `message_post*` methods being implicitly considered as + # mass-mailing + no_massmail = self.with_context( + default_mass_mailing_name=False, + default_mass_mailing_id=False, + ) + return super(MailThread, no_massmail).message_post_with_template(template_id, **kwargs) + + @api.model + def _routing_handle_bounce(self, email_message, message_dict): + """ In addition, an auto blacklist rule check if the email can be blacklisted + to avoid sending mails indefinitely to this email address. + This rule checks if the email bounced too much. If this is the case, + the email address is added to the blacklist in order to avoid continuing + to send mass_mail to that email address. If it bounced too much times + in the last month and the bounced are at least separated by one week, + to avoid blacklist someone because of a temporary mail server error, + then the email is considered as invalid and is blacklisted.""" + super(MailThread, self)._routing_handle_bounce(email_message, message_dict) + + bounced_email = message_dict['bounced_email'] + bounced_msg_id = message_dict['bounced_msg_id'] + bounced_partner = message_dict['bounced_partner'] + + if bounced_msg_id: + self.env['mailing.trace'].set_bounced(mail_message_ids=bounced_msg_id) + if bounced_email: + three_months_ago = fields.Datetime.to_string(datetime.datetime.now() - datetime.timedelta(weeks=13)) + stats = self.env['mailing.trace'].search(['&', ('bounced', '>', three_months_ago), ('email', '=ilike', bounced_email)]).mapped('bounced') + if len(stats) >= BLACKLIST_MAX_BOUNCED_LIMIT and (not bounced_partner or any(p.message_bounce >= BLACKLIST_MAX_BOUNCED_LIMIT for p in bounced_partner)): + if max(stats) > min(stats) + datetime.timedelta(weeks=1): + blacklist_rec = self.env['mail.blacklist'].sudo()._add(bounced_email) + blacklist_rec._message_log( + body='This email has been automatically blacklisted because of too much bounced.') + + @api.model + def message_new(self, msg_dict, custom_values=None): + """ Overrides mail_thread message_new that is called by the mailgateway + through message_process. + This override updates the document according to the email. + """ + defaults = {} + + if issubclass(type(self), self.pool['utm.mixin']): + thread_references = msg_dict.get('references', '') or msg_dict.get('in_reply_to', '') + msg_references = tools.mail_header_msgid_re.findall(thread_references) + if msg_references: + traces = self.env['mailing.trace'].search([('message_id', 'in', msg_references)], limit=1) + if traces: + defaults['campaign_id'] = traces.campaign_id.id + defaults['source_id'] = traces.mass_mailing_id.source_id.id + defaults['medium_id'] = traces.mass_mailing_id.medium_id.id + + if custom_values: + defaults.update(custom_values) + + return super(MailThread, self).message_new(msg_dict, custom_values=defaults) diff --git a/addons/mass_mailing/models/mailing.py b/addons/mass_mailing/models/mailing.py new file mode 100644 index 00000000..06965f0e --- /dev/null +++ b/addons/mass_mailing/models/mailing.py @@ -0,0 +1,825 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import hashlib +import hmac +import logging +import lxml +import random +import re +import threading +import werkzeug.urls +from ast import literal_eval +from datetime import datetime +from dateutil.relativedelta import relativedelta +from werkzeug.urls import url_join + +from odoo import api, fields, models, tools, _ +from odoo.exceptions import UserError +from odoo.osv import expression + +_logger = logging.getLogger(__name__) + +MASS_MAILING_BUSINESS_MODELS = [ + 'crm.lead', + 'event.registration', + 'hr.applicant', + 'res.partner', + 'event.track', + 'sale.order', + 'mailing.list', + 'mailing.contact' +] + +# Syntax of the data URL Scheme: https://tools.ietf.org/html/rfc2397#section-3 +# Used to find inline images +image_re = re.compile(r"data:(image/[A-Za-z]+);base64,(.*)") + + +class MassMailing(models.Model): + """ MassMailing models a wave of emails for a mass mailign campaign. + A mass mailing is an occurence of sending emails. """ + _name = 'mailing.mailing' + _description = 'Mass Mailing' + _inherit = ['mail.thread', 'mail.activity.mixin', 'mail.render.mixin'] + _order = 'sent_date DESC' + _inherits = {'utm.source': 'source_id'} + _rec_name = "subject" + + @api.model + def default_get(self, fields): + vals = super(MassMailing, self).default_get(fields) + if 'contact_list_ids' in fields and not vals.get('contact_list_ids') and vals.get('mailing_model_id'): + if vals.get('mailing_model_id') == self.env['ir.model']._get('mailing.list').id: + mailing_list = self.env['mailing.list'].search([], limit=2) + if len(mailing_list) == 1: + vals['contact_list_ids'] = [(6, 0, [mailing_list.id])] + return vals + + @api.model + def _get_default_mail_server_id(self): + server_id = self.env['ir.config_parameter'].sudo().get_param('mass_mailing.mail_server_id') + try: + server_id = literal_eval(server_id) if server_id else False + return self.env['ir.mail_server'].search([('id', '=', server_id)]).id + except ValueError: + return False + + active = fields.Boolean(default=True, tracking=True) + subject = fields.Char('Subject', help='Subject of your Mailing', required=True, translate=True) + preview = fields.Char( + 'Preview', translate=True, + help='Catchy preview sentence that encourages recipients to open this email.\n' + 'In most inboxes, this is displayed next to the subject.\n' + 'Keep it empty if you prefer the first characters of your email content to appear instead.') + email_from = fields.Char(string='Send From', required=True, + default=lambda self: self.env.user.email_formatted) + sent_date = fields.Datetime(string='Sent Date', copy=False) + schedule_date = fields.Datetime(string='Scheduled for', tracking=True) + # don't translate 'body_arch', the translations are only on 'body_html' + body_arch = fields.Html(string='Body', translate=False) + body_html = fields.Html(string='Body converted to be sent by mail', sanitize_attributes=False) + attachment_ids = fields.Many2many('ir.attachment', 'mass_mailing_ir_attachments_rel', + 'mass_mailing_id', 'attachment_id', string='Attachments') + keep_archives = fields.Boolean(string='Keep Archives') + campaign_id = fields.Many2one('utm.campaign', string='UTM Campaign', index=True) + source_id = fields.Many2one('utm.source', string='Source', required=True, ondelete='cascade', + help="This is the link source, e.g. Search Engine, another domain, or name of email list") + medium_id = fields.Many2one( + 'utm.medium', string='Medium', + compute='_compute_medium_id', readonly=False, store=True, + help="UTM Medium: delivery method (email, sms, ...)") + state = fields.Selection([('draft', 'Draft'), ('in_queue', 'In Queue'), ('sending', 'Sending'), ('done', 'Sent')], + string='Status', required=True, tracking=True, copy=False, default='draft', group_expand='_group_expand_states') + color = fields.Integer(string='Color Index') + user_id = fields.Many2one('res.users', string='Responsible', tracking=True, default=lambda self: self.env.user) + # mailing options + mailing_type = fields.Selection([('mail', 'Email')], string="Mailing Type", default="mail", required=True) + reply_to_mode = fields.Selection([ + ('thread', 'Recipient Followers'), ('email', 'Specified Email Address')], + string='Reply-To Mode', compute='_compute_reply_to_mode', + readonly=False, store=True, + help='Thread: replies go to target document. Email: replies are routed to a given email.') + reply_to = fields.Char( + string='Reply To', compute='_compute_reply_to', readonly=False, store=True, + help='Preferred Reply-To Address') + # recipients + mailing_model_real = fields.Char(string='Recipients Real Model', compute='_compute_model') + mailing_model_id = fields.Many2one( + 'ir.model', string='Recipients Model', ondelete='cascade', required=True, + domain=[('model', 'in', MASS_MAILING_BUSINESS_MODELS)], + default=lambda self: self.env.ref('mass_mailing.model_mailing_list').id) + mailing_model_name = fields.Char( + string='Recipients Model Name', related='mailing_model_id.model', + readonly=True, related_sudo=True) + mailing_domain = fields.Char( + string='Domain', compute='_compute_mailing_domain', + readonly=False, store=True) + mail_server_id = fields.Many2one('ir.mail_server', string='Mail Server', + default=_get_default_mail_server_id, + help="Use a specific mail server in priority. Otherwise Odoo relies on the first outgoing mail server available (based on their sequencing) as it does for normal mails.") + contact_list_ids = fields.Many2many('mailing.list', 'mail_mass_mailing_list_rel', string='Mailing Lists') + contact_ab_pc = fields.Integer(string='A/B Testing percentage', + help='Percentage of the contacts that will be mailed. Recipients will be taken randomly.', default=100) + unique_ab_testing = fields.Boolean(string='Allow A/B Testing', default=False, + help='If checked, recipients will be mailed only once for the whole campaign. ' + 'This lets you send different mailings to randomly selected recipients and test ' + 'the effectiveness of the mailings, without causing duplicate messages.') + kpi_mail_required = fields.Boolean('KPI mail required', copy=False) + # statistics data + mailing_trace_ids = fields.One2many('mailing.trace', 'mass_mailing_id', string='Emails Statistics') + total = fields.Integer(compute="_compute_total") + scheduled = fields.Integer(compute="_compute_statistics") + expected = fields.Integer(compute="_compute_statistics") + ignored = fields.Integer(compute="_compute_statistics") + sent = fields.Integer(compute="_compute_statistics") + delivered = fields.Integer(compute="_compute_statistics") + opened = fields.Integer(compute="_compute_statistics") + clicked = fields.Integer(compute="_compute_statistics") + replied = fields.Integer(compute="_compute_statistics") + bounced = fields.Integer(compute="_compute_statistics") + failed = fields.Integer(compute="_compute_statistics") + received_ratio = fields.Integer(compute="_compute_statistics", string='Received Ratio') + opened_ratio = fields.Integer(compute="_compute_statistics", string='Opened Ratio') + replied_ratio = fields.Integer(compute="_compute_statistics", string='Replied Ratio') + bounced_ratio = fields.Integer(compute="_compute_statistics", string='Bounced Ratio') + clicks_ratio = fields.Integer(compute="_compute_clicks_ratio", string="Number of Clicks") + next_departure = fields.Datetime(compute="_compute_next_departure", string='Scheduled date') + + def _compute_total(self): + for mass_mailing in self: + total = self.env[mass_mailing.mailing_model_real].search_count(mass_mailing._parse_mailing_domain()) + if mass_mailing.contact_ab_pc < 100: + total = int(total / 100.0 * mass_mailing.contact_ab_pc) + mass_mailing.total = total + + def _compute_clicks_ratio(self): + self.env.cr.execute(""" + SELECT COUNT(DISTINCT(stats.id)) AS nb_mails, COUNT(DISTINCT(clicks.mailing_trace_id)) AS nb_clicks, stats.mass_mailing_id AS id + FROM mailing_trace AS stats + LEFT OUTER JOIN link_tracker_click AS clicks ON clicks.mailing_trace_id = stats.id + WHERE stats.mass_mailing_id IN %s + GROUP BY stats.mass_mailing_id + """, [tuple(self.ids) or (None,)]) + mass_mailing_data = self.env.cr.dictfetchall() + mapped_data = dict([(m['id'], 100 * m['nb_clicks'] / m['nb_mails']) for m in mass_mailing_data]) + for mass_mailing in self: + mass_mailing.clicks_ratio = mapped_data.get(mass_mailing.id, 0) + + def _compute_statistics(self): + """ Compute statistics of the mass mailing """ + for key in ( + 'scheduled', 'expected', 'ignored', 'sent', 'delivered', 'opened', + 'clicked', 'replied', 'bounced', 'failed', 'received_ratio', + 'opened_ratio', 'replied_ratio', 'bounced_ratio', + ): + self[key] = False + if not self.ids: + return + # ensure traces are sent to db + self.flush() + self.env.cr.execute(""" + SELECT + m.id as mailing_id, + COUNT(s.id) AS expected, + COUNT(CASE WHEN s.sent is not null THEN 1 ELSE null END) AS sent, + COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is null AND s.ignored is null AND s.bounced is null THEN 1 ELSE null END) AS scheduled, + COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is null AND s.ignored is not null THEN 1 ELSE null END) AS ignored, + COUNT(CASE WHEN s.sent is not null AND s.exception is null AND s.bounced is null THEN 1 ELSE null END) AS delivered, + COUNT(CASE WHEN s.opened is not null THEN 1 ELSE null END) AS opened, + COUNT(CASE WHEN s.clicked is not null THEN 1 ELSE null END) AS clicked, + COUNT(CASE WHEN s.replied is not null THEN 1 ELSE null END) AS replied, + COUNT(CASE WHEN s.bounced is not null THEN 1 ELSE null END) AS bounced, + COUNT(CASE WHEN s.exception is not null THEN 1 ELSE null END) AS failed + FROM + mailing_trace s + RIGHT JOIN + mailing_mailing m + ON (m.id = s.mass_mailing_id) + WHERE + m.id IN %s + GROUP BY + m.id + """, (tuple(self.ids), )) + for row in self.env.cr.dictfetchall(): + total = (row['expected'] - row['ignored']) or 1 + row['received_ratio'] = 100.0 * row['delivered'] / total + row['opened_ratio'] = 100.0 * row['opened'] / total + row['replied_ratio'] = 100.0 * row['replied'] / total + row['bounced_ratio'] = 100.0 * row['bounced'] / total + self.browse(row.pop('mailing_id')).update(row) + + def _compute_next_departure(self): + cron_next_call = self.env.ref('mass_mailing.ir_cron_mass_mailing_queue').sudo().nextcall + str2dt = fields.Datetime.from_string + cron_time = str2dt(cron_next_call) + for mass_mailing in self: + if mass_mailing.schedule_date: + schedule_date = str2dt(mass_mailing.schedule_date) + mass_mailing.next_departure = max(schedule_date, cron_time) + else: + mass_mailing.next_departure = cron_time + + @api.depends('mailing_type') + def _compute_medium_id(self): + for mailing in self: + if mailing.mailing_type == 'mail' and not mailing.medium_id: + mailing.medium_id = self.env.ref('utm.utm_medium_email').id + + @api.depends('mailing_model_id') + def _compute_model(self): + for record in self: + record.mailing_model_real = (record.mailing_model_name != 'mailing.list') and record.mailing_model_name or 'mailing.contact' + + @api.depends('mailing_model_real') + def _compute_reply_to_mode(self): + for mailing in self: + if mailing.mailing_model_real in ['res.partner', 'mailing.contact']: + mailing.reply_to_mode = 'email' + else: + mailing.reply_to_mode = 'thread' + + @api.depends('reply_to_mode') + def _compute_reply_to(self): + for mailing in self: + if mailing.reply_to_mode == 'email' and not mailing.reply_to: + mailing.reply_to = self.env.user.email_formatted + elif mailing.reply_to_mode == 'thread': + mailing.reply_to = False + + @api.depends('mailing_model_name', 'contact_list_ids') + def _compute_mailing_domain(self): + for mailing in self: + if not mailing.mailing_model_name: + mailing.mailing_domain = '' + else: + mailing.mailing_domain = repr(mailing._get_default_mailing_domain()) + + # ------------------------------------------------------ + # ORM + # ------------------------------------------------------ + + @api.model + def create(self, values): + if values.get('subject') and not values.get('name'): + values['name'] = "%s %s" % (values['subject'], datetime.strftime(fields.datetime.now(), tools.DEFAULT_SERVER_DATETIME_FORMAT)) + if values.get('body_html'): + values['body_html'] = self._convert_inline_images_to_urls(values['body_html']) + return super(MassMailing, self).create(values) + + def write(self, values): + if values.get('body_html'): + values['body_html'] = self._convert_inline_images_to_urls(values['body_html']) + return super(MassMailing, self).write(values) + + @api.returns('self', lambda value: value.id) + def copy(self, default=None): + self.ensure_one() + default = dict(default or {}, + name=_('%s (copy)', self.name), + contact_list_ids=self.contact_list_ids.ids) + return super(MassMailing, self).copy(default=default) + + def _group_expand_states(self, states, domain, order): + return [key for key, val in type(self).state.selection] + + # ------------------------------------------------------ + # ACTIONS + # ------------------------------------------------------ + + def action_duplicate(self): + self.ensure_one() + mass_mailing_copy = self.copy() + if mass_mailing_copy: + context = dict(self.env.context) + context['form_view_initial_mode'] = 'edit' + return { + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'mailing.mailing', + 'res_id': mass_mailing_copy.id, + 'context': context, + } + return False + + def action_test(self): + self.ensure_one() + ctx = dict(self.env.context, default_mass_mailing_id=self.id) + return { + 'name': _('Test Mailing'), + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'mailing.mailing.test', + 'target': 'new', + 'context': ctx, + } + + def action_schedule(self): + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id("mass_mailing.mailing_mailing_schedule_date_action") + action['context'] = dict(self.env.context, default_mass_mailing_id=self.id) + return action + + def action_put_in_queue(self): + self.write({'state': 'in_queue'}) + + def action_cancel(self): + self.write({'state': 'draft', 'schedule_date': False, 'next_departure': False}) + + def action_retry_failed(self): + failed_mails = self.env['mail.mail'].sudo().search([ + ('mailing_id', 'in', self.ids), + ('state', '=', 'exception') + ]) + failed_mails.mapped('mailing_trace_ids').unlink() + failed_mails.unlink() + self.write({'state': 'in_queue'}) + + def action_view_traces_scheduled(self): + return self._action_view_traces_filtered('scheduled') + + def action_view_traces_ignored(self): + return self._action_view_traces_filtered('ignored') + + def action_view_traces_failed(self): + return self._action_view_traces_filtered('failed') + + def action_view_traces_sent(self): + return self._action_view_traces_filtered('sent') + + def _action_view_traces_filtered(self, view_filter): + action = self.env["ir.actions.actions"]._for_xml_id("mass_mailing.mailing_trace_action") + action['name'] = _('%s Traces') % (self.name) + action['context'] = {'search_default_mass_mailing_id': self.id,} + filter_key = 'search_default_filter_%s' % (view_filter) + action['context'][filter_key] = True + return action + + def action_view_clicked(self): + model_name = self.env['ir.model']._get('link.tracker').display_name + return { + 'name': model_name, + 'type': 'ir.actions.act_window', + 'view_mode': 'tree', + 'res_model': 'link.tracker', + 'domain': [('mass_mailing_id.id', '=', self.id)], + 'context': dict(self._context, create=False) + } + + def action_view_opened(self): + return self._action_view_documents_filtered('opened') + + def action_view_replied(self): + return self._action_view_documents_filtered('replied') + + def action_view_bounced(self): + return self._action_view_documents_filtered('bounced') + + def action_view_delivered(self): + return self._action_view_documents_filtered('delivered') + + def _action_view_documents_filtered(self, view_filter): + if view_filter in ('opened', 'replied', 'bounced'): + opened_stats = self.mailing_trace_ids.filtered(lambda stat: stat[view_filter]) + elif view_filter == ('delivered'): + opened_stats = self.mailing_trace_ids.filtered(lambda stat: stat.sent and not stat.bounced) + else: + opened_stats = self.env['mailing.trace'] + res_ids = opened_stats.mapped('res_id') + model_name = self.env['ir.model']._get(self.mailing_model_real).display_name + return { + 'name': model_name, + 'type': 'ir.actions.act_window', + 'view_mode': 'tree', + 'res_model': self.mailing_model_real, + 'domain': [('id', 'in', res_ids)], + 'context': dict(self._context, create=False) + } + + def update_opt_out(self, email, list_ids, value): + if len(list_ids) > 0: + model = self.env['mailing.contact'].with_context(active_test=False) + records = model.search([('email_normalized', '=', tools.email_normalize(email))]) + opt_out_records = self.env['mailing.contact.subscription'].search([ + ('contact_id', 'in', records.ids), + ('list_id', 'in', list_ids), + ('opt_out', '!=', value) + ]) + + opt_out_records.write({'opt_out': value}) + message = _('The recipient <strong>unsubscribed from %s</strong> mailing list(s)') \ + if value else _('The recipient <strong>subscribed to %s</strong> mailing list(s)') + for record in records: + # filter the list_id by record + record_lists = opt_out_records.filtered(lambda rec: rec.contact_id.id == record.id) + if len(record_lists) > 0: + record.sudo().message_post(body=message % ', '.join(str(list.name) for list in record_lists.mapped('list_id'))) + + # ------------------------------------------------------ + # Email Sending + # ------------------------------------------------------ + + def _get_opt_out_list(self): + """Returns a set of emails opted-out in target model""" + self.ensure_one() + opt_out = {} + target = self.env[self.mailing_model_real] + if self.mailing_model_real == "mailing.contact": + # if user is opt_out on One list but not on another + # or if two user with same email address, one opted in and the other one opted out, send the mail anyway + # TODO DBE Fixme : Optimise the following to get real opt_out and opt_in + target_list_contacts = self.env['mailing.contact.subscription'].search( + [('list_id', 'in', self.contact_list_ids.ids)]) + opt_out_contacts = target_list_contacts.filtered(lambda rel: rel.opt_out).mapped('contact_id.email_normalized') + opt_in_contacts = target_list_contacts.filtered(lambda rel: not rel.opt_out).mapped('contact_id.email_normalized') + opt_out = set(c for c in opt_out_contacts if c not in opt_in_contacts) + + _logger.info( + "Mass-mailing %s targets %s, blacklist: %s emails", + self, target._name, len(opt_out)) + else: + _logger.info("Mass-mailing %s targets %s, no opt out list available", self, target._name) + return opt_out + + def _get_link_tracker_values(self): + self.ensure_one() + vals = {'mass_mailing_id': self.id} + + if self.campaign_id: + vals['campaign_id'] = self.campaign_id.id + if self.source_id: + vals['source_id'] = self.source_id.id + if self.medium_id: + vals['medium_id'] = self.medium_id.id + return vals + + def _get_seen_list(self): + """Returns a set of emails already targeted by current mailing/campaign (no duplicates)""" + self.ensure_one() + target = self.env[self.mailing_model_real] + + # avoid loading a large number of records in memory + # + use a basic heuristic for extracting emails + query = """ + SELECT lower(substring(t.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)')) + FROM mailing_trace s + JOIN %(target)s t ON (s.res_id = t.id) + WHERE substring(t.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)') IS NOT NULL + """ + + # Apply same 'get email field' rule from mail_thread.message_get_default_recipients + if 'partner_id' in target._fields: + mail_field = 'email' + query = """ + SELECT lower(substring(p.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)')) + FROM mailing_trace s + JOIN %(target)s t ON (s.res_id = t.id) + JOIN res_partner p ON (t.partner_id = p.id) + WHERE substring(p.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)') IS NOT NULL + """ + elif issubclass(type(target), self.pool['mail.thread.blacklist']): + mail_field = 'email_normalized' + elif 'email_from' in target._fields: + mail_field = 'email_from' + elif 'partner_email' in target._fields: + mail_field = 'partner_email' + elif 'email' in target._fields: + mail_field = 'email' + else: + raise UserError(_("Unsupported mass mailing model %s", self.mailing_model_id.name)) + + if self.unique_ab_testing: + query +=""" + AND s.campaign_id = %%(mailing_campaign_id)s; + """ + else: + query +=""" + AND s.mass_mailing_id = %%(mailing_id)s + AND s.model = %%(target_model)s; + """ + query = query % {'target': target._table, 'mail_field': mail_field} + params = {'mailing_id': self.id, 'mailing_campaign_id': self.campaign_id.id, 'target_model': self.mailing_model_real} + self._cr.execute(query, params) + seen_list = set(m[0] for m in self._cr.fetchall()) + _logger.info( + "Mass-mailing %s has already reached %s %s emails", self, len(seen_list), target._name) + return seen_list + + def _get_mass_mailing_context(self): + """Returns extra context items with pre-filled blacklist and seen list for massmailing""" + return { + 'mass_mailing_opt_out_list': self._get_opt_out_list(), + 'mass_mailing_seen_list': self._get_seen_list(), + 'post_convert_links': self._get_link_tracker_values(), + } + + def _get_recipients(self): + mailing_domain = self._parse_mailing_domain() + res_ids = self.env[self.mailing_model_real].search(mailing_domain).ids + + # randomly choose a fragment + if self.contact_ab_pc < 100: + contact_nbr = self.env[self.mailing_model_real].search_count(mailing_domain) + topick = int(contact_nbr / 100.0 * self.contact_ab_pc) + if self.campaign_id and self.unique_ab_testing: + already_mailed = self.campaign_id._get_mailing_recipients()[self.campaign_id.id] + else: + already_mailed = set([]) + remaining = set(res_ids).difference(already_mailed) + if topick > len(remaining): + topick = len(remaining) + res_ids = random.sample(remaining, topick) + return res_ids + + def _get_remaining_recipients(self): + res_ids = self._get_recipients() + already_mailed = self.env['mailing.trace'].search_read([ + ('model', '=', self.mailing_model_real), + ('res_id', 'in', res_ids), + ('mass_mailing_id', '=', self.id)], ['res_id']) + done_res_ids = {record['res_id'] for record in already_mailed} + return [rid for rid in res_ids if rid not in done_res_ids] + + def _get_unsubscribe_url(self, email_to, res_id): + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + url = werkzeug.urls.url_join( + base_url, 'mail/mailing/%(mailing_id)s/unsubscribe?%(params)s' % { + 'mailing_id': self.id, + 'params': werkzeug.urls.url_encode({ + 'res_id': res_id, + 'email': email_to, + 'token': self._unsubscribe_token(res_id, email_to), + }), + } + ) + return url + + def _get_view_url(self, email_to, res_id): + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + url = werkzeug.urls.url_join( + base_url, 'mailing/%(mailing_id)s/view?%(params)s' % { + 'mailing_id': self.id, + 'params': werkzeug.urls.url_encode({ + 'res_id': res_id, + 'email': email_to, + 'token': self._unsubscribe_token(res_id, email_to), + }), + } + ) + return url + + def action_send_mail(self, res_ids=None): + author_id = self.env.user.partner_id.id + + for mailing in self: + if not res_ids: + res_ids = mailing._get_remaining_recipients() + if not res_ids: + raise UserError(_('There are no recipients selected.')) + + composer_values = { + 'author_id': author_id, + 'attachment_ids': [(4, attachment.id) for attachment in mailing.attachment_ids], + 'body': mailing._prepend_preview(mailing.body_html, mailing.preview), + 'subject': mailing.subject, + 'model': mailing.mailing_model_real, + 'email_from': mailing.email_from, + 'record_name': False, + 'composition_mode': 'mass_mail', + 'mass_mailing_id': mailing.id, + 'mailing_list_ids': [(4, l.id) for l in mailing.contact_list_ids], + 'no_auto_thread': mailing.reply_to_mode != 'thread', + 'template_id': None, + 'mail_server_id': mailing.mail_server_id.id, + } + if mailing.reply_to_mode == 'email': + composer_values['reply_to'] = mailing.reply_to + + composer = self.env['mail.compose.message'].with_context(active_ids=res_ids).create(composer_values) + extra_context = mailing._get_mass_mailing_context() + composer = composer.with_context(active_ids=res_ids, **extra_context) + # auto-commit except in testing mode + auto_commit = not getattr(threading.currentThread(), 'testing', False) + composer.send_mail(auto_commit=auto_commit) + mailing.write({ + 'state': 'done', + 'sent_date': fields.Datetime.now(), + # send the KPI mail only if it's the first sending + 'kpi_mail_required': not mailing.sent_date, + }) + return True + + def convert_links(self): + res = {} + for mass_mailing in self: + html = mass_mailing.body_html if mass_mailing.body_html else '' + + vals = {'mass_mailing_id': mass_mailing.id} + + if mass_mailing.campaign_id: + vals['campaign_id'] = mass_mailing.campaign_id.id + if mass_mailing.source_id: + vals['source_id'] = mass_mailing.source_id.id + if mass_mailing.medium_id: + vals['medium_id'] = mass_mailing.medium_id.id + + res[mass_mailing.id] = mass_mailing._shorten_links(html, vals, blacklist=['/unsubscribe_from_list', '/view']) + + return res + + @api.model + def _process_mass_mailing_queue(self): + mass_mailings = self.search([('state', 'in', ('in_queue', 'sending')), '|', ('schedule_date', '<', fields.Datetime.now()), ('schedule_date', '=', False)]) + for mass_mailing in mass_mailings: + user = mass_mailing.write_uid or self.env.user + mass_mailing = mass_mailing.with_context(**user.with_user(user).context_get()) + if len(mass_mailing._get_remaining_recipients()) > 0: + mass_mailing.state = 'sending' + mass_mailing.action_send_mail() + else: + mass_mailing.write({ + 'state': 'done', + 'sent_date': fields.Datetime.now(), + # send the KPI mail only if it's the first sending + 'kpi_mail_required': not mass_mailing.sent_date, + }) + + mailings = self.env['mailing.mailing'].search([ + ('kpi_mail_required', '=', True), + ('state', '=', 'done'), + ('sent_date', '<=', fields.Datetime.now() - relativedelta(days=1)), + ('sent_date', '>=', fields.Datetime.now() - relativedelta(days=5)), + ]) + if mailings: + mailings._action_send_statistics() + + # ------------------------------------------------------ + # STATISTICS + # ------------------------------------------------------ + def _action_send_statistics(self): + """Send an email to the responsible of each finished mailing with the statistics.""" + self.kpi_mail_required = False + + for mailing in self: + user = mailing.user_id + mailing = mailing.with_context(lang=user.lang or self._context.get('lang')) + + link_trackers = self.env['link.tracker'].search( + [('mass_mailing_id', '=', mailing.id)] + ).sorted('count', reverse=True) + link_trackers_body = self.env['ir.qweb']._render( + 'mass_mailing.mass_mailing_kpi_link_trackers', + {'object': mailing, 'link_trackers': link_trackers}, + ) + + rendered_body = self.env['ir.qweb']._render( + 'digest.digest_mail_main', + { + 'body': tools.html_sanitize(link_trackers_body), + 'company': user.company_id, + 'user': user, + 'display_mobile_banner': True, + ** mailing._prepare_statistics_email_values() + }, + ) + + full_mail = self.env['mail.render.mixin']._render_encapsulate( + 'digest.digest_mail_layout', + rendered_body, + ) + + mail_values = { + 'subject': _('24H Stats of mailing "%s"') % mailing.subject, + 'email_from': user.email_formatted, + 'email_to': user.email_formatted, + 'body_html': full_mail, + 'auto_delete': True, + } + mail = self.env['mail.mail'].sudo().create(mail_values) + mail.send(raise_exception=False) + + def _prepare_statistics_email_values(self): + """Return some statistics that will be displayed in the mailing statistics email. + + Each item in the returned list will be displayed as a table, with a title and + 1, 2 or 3 columns. + """ + self.ensure_one() + + random_tip = self.env['digest.tip'].search( + [('group_id.category_id', '=', self.env.ref('base.module_category_marketing_email_marketing').id)] + ) + if random_tip: + random_tip = random.choice(random_tip).tip_description + + formatted_date = tools.format_datetime( + self.env, self.sent_date, self.user_id.tz, 'MMM dd, YYYY', self.user_id.lang + ) if self.sent_date else False + + web_base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + + return { + 'title': _('24H Stats of mailing'), + 'sub_title': '"%s"' % self.subject, + 'top_button_label': _('More Info'), + 'top_button_url': url_join(web_base_url, f'/web#id={self.id}&model=mailing.mailing&view_type=form'), + 'kpi_data': [ + { + 'kpi_fullname': _('Engagement on %i Emails Sent') % self.sent, + 'kpi_action': None, + 'kpi_col1': { + 'value': f'{self.received_ratio}%', + 'col_subtitle': '%s (%i)' % (_('RECEIVED'), self.delivered), + }, + 'kpi_col2': { + 'value': f'{self.opened_ratio}%', + 'col_subtitle': '%s (%i)' % (_('OPENED'), self.opened), + }, + 'kpi_col3': { + 'value': f'{self.replied_ratio}%', + 'col_subtitle': '%s (%i)' % (_('REPLIED'), self.replied), + }, + }, { + 'kpi_fullname': _('Business Benefits on %i Emails Sent') % self.sent, + 'kpi_action': None, + 'kpi_col1': {}, + 'kpi_col2': {}, + 'kpi_col3': {}, + }, + ], + 'tips': [random_tip] if random_tip else False, + 'formatted_date': formatted_date, + } + + # ------------------------------------------------------ + # TOOLS + # ------------------------------------------------------ + + def _get_default_mailing_domain(self): + mailing_domain = [] + if self.mailing_model_name == 'mailing.list' and self.contact_list_ids: + mailing_domain = [('list_ids', 'in', self.contact_list_ids.ids)] + + if self.mailing_type == 'mail' and 'is_blacklisted' in self.env[self.mailing_model_name]._fields: + mailing_domain = expression.AND([[('is_blacklisted', '=', False)], mailing_domain]) + + return mailing_domain + + def _parse_mailing_domain(self): + self.ensure_one() + try: + mailing_domain = literal_eval(self.mailing_domain) + except Exception: + mailing_domain = [('id', 'in', [])] + return mailing_domain + + def _unsubscribe_token(self, res_id, email): + """Generate a secure hash for this mailing list and parameters. + + This is appended to the unsubscription URL and then checked at + unsubscription time to ensure no malicious unsubscriptions are + performed. + + :param int res_id: + ID of the resource that will be unsubscribed. + + :param str email: + Email of the resource that will be unsubscribed. + """ + secret = self.env["ir.config_parameter"].sudo().get_param("database.secret") + token = (self.env.cr.dbname, self.id, int(res_id), tools.ustr(email)) + return hmac.new(secret.encode('utf-8'), repr(token).encode('utf-8'), hashlib.sha512).hexdigest() + + def _convert_inline_images_to_urls(self, body_html): + """ + Find inline base64 encoded images, make an attachement out of + them and replace the inline image with an url to the attachement. + """ + + def _image_to_url(b64image: bytes): + """Store an image in an attachement and returns an url""" + attachment = self.env['ir.attachment'].create({ + 'datas': b64image, + 'name': "cropped_image_mailing_{}".format(self.id), + 'type': 'binary',}) + + attachment.generate_access_token() + + return '/web/image/%s?access_token=%s' % ( + attachment.id, attachment.access_token) + + modified = False + root = lxml.html.fromstring(body_html) + for node in root.iter('img'): + match = image_re.match(node.attrib.get('src', '')) + if match: + mime = match.group(1) # unsed + image = match.group(2).encode() # base64 image as bytes + + node.attrib['src'] = _image_to_url(image) + modified = True + + if modified: + return lxml.html.tostring(root) + + return body_html diff --git a/addons/mass_mailing/models/mailing_contact.py b/addons/mass_mailing/models/mailing_contact.py new file mode 100644 index 00000000..27ce87a2 --- /dev/null +++ b/addons/mass_mailing/models/mailing_contact.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models +from odoo.osv import expression + + +class MassMailingContactListRel(models.Model): + """ Intermediate model between mass mailing list and mass mailing contact + Indicates if a contact is opted out for a particular list + """ + _name = 'mailing.contact.subscription' + _description = 'Mass Mailing Subscription Information' + _table = 'mailing_contact_list_rel' + _rec_name = 'contact_id' + + contact_id = fields.Many2one('mailing.contact', string='Contact', ondelete='cascade', required=True) + list_id = fields.Many2one('mailing.list', string='Mailing List', ondelete='cascade', required=True) + opt_out = fields.Boolean(string='Opt Out', + help='The contact has chosen not to receive mails anymore from this list', default=False) + unsubscription_date = fields.Datetime(string='Unsubscription Date') + message_bounce = fields.Integer(related='contact_id.message_bounce', store=False, readonly=False) + is_blacklisted = fields.Boolean(related='contact_id.is_blacklisted', store=False, readonly=False) + + _sql_constraints = [ + ('unique_contact_list', 'unique (contact_id, list_id)', + 'A mailing contact cannot subscribe to the same mailing list multiple times.') + ] + + @api.model + def create(self, vals): + if 'opt_out' in vals: + vals['unsubscription_date'] = vals['opt_out'] and fields.Datetime.now() + return super(MassMailingContactListRel, self).create(vals) + + def write(self, vals): + if 'opt_out' in vals: + vals['unsubscription_date'] = vals['opt_out'] and fields.Datetime.now() + return super(MassMailingContactListRel, self).write(vals) + + +class MassMailingContact(models.Model): + """Model of a contact. This model is different from the partner model + because it holds only some basic information: name, email. The purpose is to + be able to deal with large contact list to email without bloating the partner + base.""" + _name = 'mailing.contact' + _inherit = ['mail.thread.blacklist'] + _description = 'Mailing Contact' + _order = 'email' + + def default_get(self, fields): + """ When coming from a mailing list we may have a default_list_ids context + key. We should use it to create subscription_list_ids default value that + are displayed to the user as list_ids is not displayed on form view. """ + res = super(MassMailingContact, self).default_get(fields) + if 'subscription_list_ids' in fields and not res.get('subscription_list_ids'): + list_ids = self.env.context.get('default_list_ids') + if 'default_list_ids' not in res and list_ids and isinstance(list_ids, (list, tuple)): + res['subscription_list_ids'] = [ + (0, 0, {'list_id': list_id}) for list_id in list_ids] + return res + + name = fields.Char() + company_name = fields.Char(string='Company Name') + title_id = fields.Many2one('res.partner.title', string='Title') + email = fields.Char('Email') + list_ids = fields.Many2many( + 'mailing.list', 'mailing_contact_list_rel', + 'contact_id', 'list_id', string='Mailing Lists') + subscription_list_ids = fields.One2many('mailing.contact.subscription', 'contact_id', string='Subscription Information') + country_id = fields.Many2one('res.country', string='Country') + tag_ids = fields.Many2many('res.partner.category', string='Tags') + opt_out = fields.Boolean('Opt Out', compute='_compute_opt_out', search='_search_opt_out', + help='Opt out flag for a specific mailing list.' + 'This field should not be used in a view without a unique and active mailing list context.') + + @api.model + def _search_opt_out(self, operator, value): + # Assumes operator is '=' or '!=' and value is True or False + if operator != '=': + if operator == '!=' and isinstance(value, bool): + value = not value + else: + raise NotImplementedError() + + if 'default_list_ids' in self._context and isinstance(self._context['default_list_ids'], (list, tuple)) and len(self._context['default_list_ids']) == 1: + [active_list_id] = self._context['default_list_ids'] + contacts = self.env['mailing.contact.subscription'].search([('list_id', '=', active_list_id)]) + return [('id', 'in', [record.contact_id.id for record in contacts if record.opt_out == value])] + else: + return expression.FALSE_DOMAIN if value else expression.TRUE_DOMAIN + + @api.depends('subscription_list_ids') + @api.depends_context('default_list_ids') + def _compute_opt_out(self): + if 'default_list_ids' in self._context and isinstance(self._context['default_list_ids'], (list, tuple)) and len(self._context['default_list_ids']) == 1: + [active_list_id] = self._context['default_list_ids'] + for record in self: + active_subscription_list = record.subscription_list_ids.filtered(lambda l: l.list_id.id == active_list_id) + record.opt_out = active_subscription_list.opt_out + else: + for record in self: + record.opt_out = False + + def get_name_email(self, name): + name, email = self.env['res.partner']._parse_partner_name(name) + if name and not email: + email = name + if email and not name: + name = email + return name, email + + @api.model_create_multi + def create(self, vals_list): + """ Synchronize default_list_ids (currently used notably for computed + fields) default key with subscription_list_ids given by user when creating + contacts. + + Those two values have the same purpose, adding a list to to the contact + either through a direct write on m2m, either through a write on middle + model subscription. + + This is a bit hackish but is due to default_list_ids key being + used to compute oupt_out field. This should be cleaned in master but here + we simply try to limit issues while keeping current behavior. """ + default_list_ids = self._context.get('default_list_ids') + default_list_ids = default_list_ids if isinstance(default_list_ids, (list, tuple)) else [] + + if default_list_ids: + for vals in vals_list: + current_list_ids = [] + subscription_ids = vals.get('subscription_list_ids') or [] + for subscription in subscription_ids: + if len(subscription) == 3: + current_list_ids.append(subscription[2]['list_id']) + for list_id in set(default_list_ids) - set(current_list_ids): + subscription_ids.append((0, 0, {'list_id': list_id})) + vals['subscription_list_ids'] = subscription_ids + + return super(MassMailingContact, self.with_context(default_list_ids=False)).create(vals_list) + + @api.returns('self', lambda value: value.id) + def copy(self, default=None): + """ Cleans the default_list_ids while duplicating mailing contact in context of + a mailing list because we already have subscription lists copied over for newly + created contact, no need to add the ones from default_list_ids again """ + if self.env.context.get('default_list_ids'): + self = self.with_context(default_list_ids=False) + return super().copy(default) + + @api.model + def name_create(self, name): + name, email = self.get_name_email(name) + contact = self.create({'name': name, 'email': email}) + return contact.name_get()[0] + + @api.model + def add_to_list(self, name, list_id): + name, email = self.get_name_email(name) + contact = self.create({'name': name, 'email': email, 'list_ids': [(4, list_id)]}) + return contact.name_get()[0] + + def _message_get_default_recipients(self): + return {r.id: { + 'partner_ids': [], + 'email_to': r.email_normalized, + 'email_cc': False} + for r in self + } diff --git a/addons/mass_mailing/models/mailing_list.py b/addons/mass_mailing/models/mailing_list.py new file mode 100644 index 00000000..68de4770 --- /dev/null +++ b/addons/mass_mailing/models/mailing_list.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, fields, models +from odoo.exceptions import UserError + + +class MassMailingList(models.Model): + """Model of a contact list. """ + _name = 'mailing.list' + _order = 'name' + _description = 'Mailing List' + + name = fields.Char(string='Mailing List', required=True) + active = fields.Boolean(default=True) + contact_nbr = fields.Integer(compute="_compute_contact_nbr", string='Number of Contacts') + contact_ids = fields.Many2many( + 'mailing.contact', 'mailing_contact_list_rel', 'list_id', 'contact_id', + string='Mailing Lists') + subscription_ids = fields.One2many( + 'mailing.contact.subscription', 'list_id', string='Subscription Information', + depends=['contact_ids']) + is_public = fields.Boolean(default=True, help="The mailing list can be accessible by recipient in the unsubscription" + " page to allows him to update his subscription preferences.") + + # Compute number of contacts non opt-out, non blacklisted and valid email recipient for a mailing list + def _compute_contact_nbr(self): + if self.ids: + self.env.cr.execute(''' + select + list_id, count(*) + from + mailing_contact_list_rel r + left join mailing_contact c on (r.contact_id=c.id) + left join mail_blacklist bl on c.email_normalized = bl.email and bl.active + where + list_id in %s + AND COALESCE(r.opt_out,FALSE) = FALSE + AND c.email_normalized IS NOT NULL + AND bl.id IS NULL + group by + list_id + ''', (tuple(self.ids), )) + data = dict(self.env.cr.fetchall()) + for mailing_list in self: + mailing_list.contact_nbr = data.get(mailing_list._origin.id, 0) + else: + self.contact_nbr = 0 + + def write(self, vals): + # Prevent archiving used mailing list + if 'active' in vals and not vals.get('active'): + mass_mailings = self.env['mailing.mailing'].search_count([ + ('state', '!=', 'done'), + ('contact_list_ids', 'in', self.ids), + ]) + + if mass_mailings > 0: + raise UserError(_("At least one of the mailing list you are trying to archive is used in an ongoing mailing campaign.")) + + return super(MassMailingList, self).write(vals) + + def name_get(self): + return [(list.id, "%s (%s)" % (list.name, list.contact_nbr)) for list in self] + + def action_view_contacts(self): + action = self.env["ir.actions.actions"]._for_xml_id("mass_mailing.action_view_mass_mailing_contacts") + action['domain'] = [('list_ids', 'in', self.ids)] + context = dict(self.env.context, search_default_filter_valid_email_recipient=1, default_list_ids=self.ids) + action['context'] = context + return action + + def action_merge(self, src_lists, archive): + """ + Insert all the contact from the mailing lists 'src_lists' to the + mailing list in 'self'. Possibility to archive the mailing lists + 'src_lists' after the merge except the destination mailing list 'self'. + """ + # Explation of the SQL query with an example. There are the following lists + # A (id=4): yti@odoo.com; yti@example.com + # B (id=5): yti@odoo.com; yti@openerp.com + # C (id=6): nothing + # To merge the mailing lists A and B into C, we build the view st that looks + # like this with our example: + # + # contact_id | email | row_number | list_id | + # ------------+---------------------------+------------------------ + # 4 | yti@odoo.com | 1 | 4 | + # 6 | yti@odoo.com | 2 | 5 | + # 5 | yti@example.com | 1 | 4 | + # 7 | yti@openerp.com | 1 | 5 | + # + # The row_column is kind of an occurence counter for the email address. + # Then we create the Many2many relation between the destination list and the contacts + # while avoiding to insert an existing email address (if the destination is in the source + # for example) + self.ensure_one() + # Put destination is sources lists if not already the case + src_lists |= self + self.env['mailing.contact'].flush(['email', 'email_normalized']) + self.env['mailing.contact.subscription'].flush(['contact_id', 'opt_out', 'list_id']) + self.env.cr.execute(""" + INSERT INTO mailing_contact_list_rel (contact_id, list_id) + SELECT st.contact_id AS contact_id, %s AS list_id + FROM + ( + SELECT + contact.id AS contact_id, + contact.email AS email, + list.id AS list_id, + row_number() OVER (PARTITION BY email ORDER BY email) AS rn + FROM + mailing_contact contact, + mailing_contact_list_rel contact_list_rel, + mailing_list list + WHERE contact.id=contact_list_rel.contact_id + AND COALESCE(contact_list_rel.opt_out,FALSE) = FALSE + AND contact.email_normalized NOT IN (select email from mail_blacklist where active = TRUE) + AND list.id=contact_list_rel.list_id + AND list.id IN %s + AND NOT EXISTS + ( + SELECT 1 + FROM + mailing_contact contact2, + mailing_contact_list_rel contact_list_rel2 + WHERE contact2.email = contact.email + AND contact_list_rel2.contact_id = contact2.id + AND contact_list_rel2.list_id = %s + ) + ) st + WHERE st.rn = 1;""", (self.id, tuple(src_lists.ids), self.id)) + self.flush() + self.invalidate_cache() + if archive: + (src_lists - self).action_archive() + + def close_dialog(self): + return {'type': 'ir.actions.act_window_close'} diff --git a/addons/mass_mailing/models/mailing_trace.py b/addons/mass_mailing/models/mailing_trace.py new file mode 100644 index 00000000..b4a798e0 --- /dev/null +++ b/addons/mass_mailing/models/mailing_trace.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class MailingTrace(models.Model): + """ MailingTrace models the statistics collected about emails. Those statistics + are stored in a separated model and table to avoid bloating the mail_mail table + with statistics values. This also allows to delete emails send with mass mailing + without loosing the statistics about them. """ + _name = 'mailing.trace' + _description = 'Mailing Statistics' + _rec_name = 'id' + _order = 'scheduled DESC' + + trace_type = fields.Selection([('mail', 'Mail')], string='Type', default='mail', required=True) + display_name = fields.Char(compute='_compute_display_name') + # mail data + mail_mail_id = fields.Many2one('mail.mail', string='Mail', index=True) + mail_mail_id_int = fields.Integer( + string='Mail ID (tech)', + help='ID of the related mail_mail. This field is an integer field because ' + 'the related mail_mail can be deleted separately from its statistics. ' + 'However the ID is needed for several action and controllers.', + index=True, + ) + email = fields.Char(string="Email", help="Normalized email address") + message_id = fields.Char(string='Message-ID') + # document + model = fields.Char(string='Document model') + res_id = fields.Integer(string='Document ID') + # campaign / wave data + mass_mailing_id = fields.Many2one('mailing.mailing', string='Mailing', index=True, ondelete='cascade') + campaign_id = fields.Many2one( + related='mass_mailing_id.campaign_id', + string='Campaign', + store=True, readonly=True, index=True) + # Bounce and tracking + ignored = fields.Datetime(help='Date when the email has been invalidated. ' + 'Invalid emails are blacklisted, opted-out or invalid email format') + scheduled = fields.Datetime(help='Date when the email has been created', default=fields.Datetime.now) + sent = fields.Datetime(help='Date when the email has been sent') + exception = fields.Datetime(help='Date of technical error leading to the email not being sent') + opened = fields.Datetime(help='Date when the email has been opened the first time') + replied = fields.Datetime(help='Date when this email has been replied for the first time.') + bounced = fields.Datetime(help='Date when this email has bounced.') + # Link tracking + links_click_ids = fields.One2many('link.tracker.click', 'mailing_trace_id', string='Links click') + clicked = fields.Datetime(help='Date when customer clicked on at least one tracked link') + # Status + state = fields.Selection(compute="_compute_state", + selection=[('outgoing', 'Outgoing'), + ('exception', 'Exception'), + ('sent', 'Sent'), + ('opened', 'Opened'), + ('replied', 'Replied'), + ('bounced', 'Bounced'), + ('ignored', 'Ignored')], store=True) + failure_type = fields.Selection(selection=[ + ("SMTP", "Connection failed (outgoing mail server problem)"), + ("RECIPIENT", "Invalid email address"), + ("BOUNCE", "Email address rejected by destination"), + ("UNKNOWN", "Unknown error"), + ], string='Failure type') + state_update = fields.Datetime(compute="_compute_state", string='State Update', + help='Last state update of the mail', + store=True) + + @api.depends('trace_type', 'mass_mailing_id') + def _compute_display_name(self): + for trace in self: + trace.display_name = '%s: %s (%s)' % (trace.trace_type, trace.mass_mailing_id.name, trace.id) + + @api.depends('sent', 'opened', 'clicked', 'replied', 'bounced', 'exception', 'ignored') + def _compute_state(self): + self.update({'state_update': fields.Datetime.now()}) + for stat in self: + if stat.ignored: + stat.state = 'ignored' + elif stat.exception: + stat.state = 'exception' + elif stat.replied: + stat.state = 'replied' + elif stat.opened or stat.clicked: + stat.state = 'opened' + elif stat.bounced: + stat.state = 'bounced' + elif stat.sent: + stat.state = 'sent' + else: + stat.state = 'outgoing' + + @api.model_create_multi + def create(self, values_list): + for values in values_list: + if 'mail_mail_id' in values: + values['mail_mail_id_int'] = values['mail_mail_id'] + return super(MailingTrace, self).create(values_list) + + def _get_records(self, mail_mail_ids=None, mail_message_ids=None, domain=None): + if not self.ids and mail_mail_ids: + base_domain = [('mail_mail_id_int', 'in', mail_mail_ids)] + elif not self.ids and mail_message_ids: + base_domain = [('message_id', 'in', mail_message_ids)] + else: + base_domain = [('id', 'in', self.ids)] + if domain: + base_domain = ['&'] + domain + base_domain + return self.search(base_domain) + + def set_opened(self, mail_mail_ids=None, mail_message_ids=None): + traces = self._get_records(mail_mail_ids, mail_message_ids, [('opened', '=', False)]) + traces.write({'opened': fields.Datetime.now(), 'bounced': False}) + return traces + + def set_clicked(self, mail_mail_ids=None, mail_message_ids=None): + traces = self._get_records(mail_mail_ids, mail_message_ids, [('clicked', '=', False)]) + traces.write({'clicked': fields.Datetime.now()}) + return traces + + def set_replied(self, mail_mail_ids=None, mail_message_ids=None): + traces = self._get_records(mail_mail_ids, mail_message_ids, [('replied', '=', False)]) + traces.write({'replied': fields.Datetime.now()}) + return traces + + def set_bounced(self, mail_mail_ids=None, mail_message_ids=None): + traces = self._get_records(mail_mail_ids, mail_message_ids, [('bounced', '=', False), ('opened', '=', False)]) + traces.write({'bounced': fields.Datetime.now()}) + return traces diff --git a/addons/mass_mailing/models/res_company.py b/addons/mass_mailing/models/res_company.py new file mode 100644 index 00000000..39865fe4 --- /dev/null +++ b/addons/mass_mailing/models/res_company.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class ResCompany(models.Model): + _inherit = "res.company" + + def _get_social_media_links(self): + self.ensure_one() + return { + 'social_facebook': self.social_facebook, + 'social_linkedin': self.social_linkedin, + 'social_twitter': self.social_twitter, + 'social_instagram': self.social_instagram + } diff --git a/addons/mass_mailing/models/res_config_settings.py b/addons/mass_mailing/models/res_config_settings.py new file mode 100644 index 00000000..b915057d --- /dev/null +++ b/addons/mass_mailing/models/res_config_settings.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + group_mass_mailing_campaign = fields.Boolean(string="Mailing Campaigns", implied_group='mass_mailing.group_mass_mailing_campaign', help="""This is useful if your marketing campaigns are composed of several emails""") + mass_mailing_outgoing_mail_server = fields.Boolean(string="Dedicated Server", config_parameter='mass_mailing.outgoing_mail_server', + help='Use a specific mail server in priority. Otherwise Odoo relies on the first outgoing mail server available (based on their sequencing) as it does for normal mails.') + mass_mailing_mail_server_id = fields.Many2one('ir.mail_server', string='Mail Server', config_parameter='mass_mailing.mail_server_id') + show_blacklist_buttons = fields.Boolean(string="Blacklist Option when Unsubscribing", + config_parameter='mass_mailing.show_blacklist_buttons', + help="""Allow the recipient to manage himself his state in the blacklist via the unsubscription page.""") + + @api.onchange('mass_mailing_outgoing_mail_server') + def _onchange_mass_mailing_outgoing_mail_server(self): + if not self.mass_mailing_outgoing_mail_server: + self.mass_mailing_mail_server_id = False diff --git a/addons/mass_mailing/models/res_users.py b/addons/mass_mailing/models/res_users.py new file mode 100644 index 00000000..04b1cb74 --- /dev/null +++ b/addons/mass_mailing/models/res_users.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models, _ + + +class Users(models.Model): + _name = 'res.users' + _inherit = ['res.users'] + + @api.model + def systray_get_activities(self): + """ Update systray name of mailing.mailing from "Mass Mailing" + to "Email Marketing". + """ + activities = super(Users, self).systray_get_activities() + for activity in activities: + if activity.get('model') == 'mailing.mailing': + activity['name'] = _('Email Marketing') + break + return activities diff --git a/addons/mass_mailing/models/utm.py b/addons/mass_mailing/models/utm.py new file mode 100644 index 00000000..88c99e0f --- /dev/null +++ b/addons/mass_mailing/models/utm.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class UtmCampaign(models.Model): + _inherit = 'utm.campaign' + + mailing_mail_ids = fields.One2many( + 'mailing.mailing', 'campaign_id', + domain=[('mailing_type', '=', 'mail')], + string='Mass Mailings') + mailing_mail_count = fields.Integer('Number of Mass Mailing', compute="_compute_mailing_mail_count") + # stat fields + received_ratio = fields.Integer(compute="_compute_statistics", string='Received Ratio') + opened_ratio = fields.Integer(compute="_compute_statistics", string='Opened Ratio') + replied_ratio = fields.Integer(compute="_compute_statistics", string='Replied Ratio') + bounced_ratio = fields.Integer(compute="_compute_statistics", string='Bounced Ratio') + + @api.depends('mailing_mail_ids') + def _compute_mailing_mail_count(self): + if self.ids: + mailing_data = self.env['mailing.mailing'].read_group( + [('campaign_id', 'in', self.ids)], + ['campaign_id'], + ['campaign_id'] + ) + mapped_data = {m['campaign_id'][0]: m['campaign_id_count'] for m in mailing_data} + else: + mapped_data = dict() + for campaign in self: + campaign.mailing_mail_count = mapped_data.get(campaign.id, 0) + + def _compute_statistics(self): + """ Compute statistics of the mass mailing campaign """ + default_vals = { + 'received_ratio': 0, + 'opened_ratio': 0, + 'replied_ratio': 0, + 'bounced_ratio': 0 + } + if not self.ids: + self.update(default_vals) + return + self.env.cr.execute(""" + SELECT + c.id as campaign_id, + COUNT(s.id) AS expected, + COUNT(CASE WHEN s.sent is not null THEN 1 ELSE null END) AS sent, + COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is null AND s.ignored is not null THEN 1 ELSE null END) AS ignored, + COUNT(CASE WHEN s.id is not null AND s.bounced is null THEN 1 ELSE null END) AS delivered, + COUNT(CASE WHEN s.opened is not null THEN 1 ELSE null END) AS opened, + COUNT(CASE WHEN s.replied is not null THEN 1 ELSE null END) AS replied, + COUNT(CASE WHEN s.bounced is not null THEN 1 ELSE null END) AS bounced + FROM + mailing_trace s + RIGHT JOIN + utm_campaign c + ON (c.id = s.campaign_id) + WHERE + c.id IN %s + GROUP BY + c.id + """, (tuple(self.ids), )) + + all_stats = self.env.cr.dictfetchall() + stats_per_campaign = { + stats['campaign_id']: stats + for stats in all_stats + } + + for campaign in self: + stats = stats_per_campaign.get(campaign.id) + if not stats: + vals = default_vals + else: + total = (stats['expected'] - stats['ignored']) or 1 + delivered = stats['sent'] - stats['bounced'] + vals = { + 'received_ratio': 100.0 * delivered / total, + 'opened_ratio': 100.0 * stats['opened'] / total, + 'replied_ratio': 100.0 * stats['replied'] / total, + 'bounced_ratio': 100.0 * stats['bounced'] / total + } + + campaign.update(vals) + + def _get_mailing_recipients(self, model=None): + """Return the recipients of a mailing campaign. This is based on the statistics + build for each mailing. """ + res = dict.fromkeys(self.ids, {}) + for campaign in self: + domain = [('campaign_id', '=', campaign.id)] + if model: + domain += [('model', '=', model)] + res[campaign.id] = set(self.env['mailing.trace'].search(domain).mapped('res_id')) + return res |
