summaryrefslogtreecommitdiff
path: root/addons/mass_mailing/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/mass_mailing/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/mass_mailing/models')
-rw-r--r--addons/mass_mailing/models/__init__.py15
-rw-r--r--addons/mass_mailing/models/link_tracker.py42
-rw-r--r--addons/mass_mailing/models/mail_mail.py88
-rw-r--r--addons/mass_mailing/models/mail_render_mixin.py23
-rw-r--r--addons/mass_mailing/models/mail_thread.py87
-rw-r--r--addons/mass_mailing/models/mailing.py825
-rw-r--r--addons/mass_mailing/models/mailing_contact.py170
-rw-r--r--addons/mass_mailing/models/mailing_list.py139
-rw-r--r--addons/mass_mailing/models/mailing_trace.py130
-rw-r--r--addons/mass_mailing/models/res_company.py17
-rw-r--r--addons/mass_mailing/models/res_config_settings.py21
-rw-r--r--addons/mass_mailing/models/res_users.py21
-rw-r--r--addons/mass_mailing/models/utm.py98
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