2018-01-16 06:58:15 +01:00
|
|
|
# -*- coding: utf-8 -*-
|
2018-01-16 11:34:37 +01:00
|
|
|
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
|
2018-01-16 06:58:15 +01:00
|
|
|
|
|
|
|
import hashlib
|
|
|
|
import hmac
|
|
|
|
from datetime import datetime
|
|
|
|
import logging
|
|
|
|
import random
|
|
|
|
import threading
|
|
|
|
|
2018-01-16 11:34:37 +01:00
|
|
|
from flectra import api, fields, models, tools, _
|
|
|
|
from flectra.exceptions import UserError
|
|
|
|
from flectra.tools.safe_eval import safe_eval
|
|
|
|
from flectra.tools.translate import html_translate
|
2018-01-16 06:58:15 +01:00
|
|
|
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
MASS_MAILING_BUSINESS_MODELS = [
|
|
|
|
'crm.lead',
|
|
|
|
'event.registration',
|
|
|
|
'hr.applicant',
|
|
|
|
'res.partner',
|
|
|
|
'event.track',
|
|
|
|
'sale.order',
|
|
|
|
'mail.mass_mailing.list',
|
|
|
|
]
|
|
|
|
|
|
|
|
class MassMailingTag(models.Model):
|
|
|
|
"""Model of categories of mass mailing, i.e. marketing, newsletter, ... """
|
|
|
|
_name = 'mail.mass_mailing.tag'
|
|
|
|
_description = 'Mass Mailing Tag'
|
|
|
|
_order = 'name'
|
|
|
|
|
|
|
|
name = fields.Char(required=True, translate=True)
|
|
|
|
color = fields.Integer(string='Color Index')
|
|
|
|
|
|
|
|
_sql_constraints = [
|
|
|
|
('name_uniq', 'unique (name)', "Tag name already exists !"),
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
class MassMailingList(models.Model):
|
|
|
|
"""Model of a contact list. """
|
|
|
|
_name = 'mail.mass_mailing.list'
|
|
|
|
_order = 'name'
|
|
|
|
_description = 'Mailing List'
|
|
|
|
|
|
|
|
name = fields.Char(string='Mailing List', required=True)
|
|
|
|
active = fields.Boolean(default=True)
|
|
|
|
create_date = fields.Datetime(string='Creation Date')
|
|
|
|
contact_nbr = fields.Integer(compute="_compute_contact_nbr", string='Number of Contacts')
|
|
|
|
|
|
|
|
# Compute number of contacts non opt-out for a mailing list
|
|
|
|
def _compute_contact_nbr(self):
|
|
|
|
self.env.cr.execute('''
|
|
|
|
select
|
|
|
|
list_id, count(*)
|
|
|
|
from
|
|
|
|
mail_mass_mailing_contact_list_rel r
|
|
|
|
left join mail_mass_mailing_contact c on (r.contact_id=c.id)
|
|
|
|
where
|
|
|
|
c.opt_out <> true
|
|
|
|
group by
|
|
|
|
list_id
|
|
|
|
''')
|
|
|
|
data = dict(self.env.cr.fetchall())
|
|
|
|
for mailing_list in self:
|
|
|
|
mailing_list.contact_nbr = data.get(mailing_list.id, 0)
|
|
|
|
|
|
|
|
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 = 'mail.mass_mailing.contact'
|
|
|
|
_inherit = 'mail.thread'
|
|
|
|
_description = 'Mass Mailing Contact'
|
|
|
|
_order = 'email'
|
|
|
|
_rec_name = 'email'
|
|
|
|
|
|
|
|
name = fields.Char()
|
|
|
|
company_name = fields.Char(string='Company Name')
|
|
|
|
title_id = fields.Many2one('res.partner.title', string='Title')
|
|
|
|
email = fields.Char(required=True)
|
|
|
|
create_date = fields.Datetime(string='Creation Date')
|
|
|
|
list_ids = fields.Many2many(
|
|
|
|
'mail.mass_mailing.list', 'mail_mass_mailing_contact_list_rel',
|
|
|
|
'contact_id', 'list_id', string='Mailing Lists')
|
|
|
|
opt_out = fields.Boolean(string='Opt Out', help='The contact has chosen not to receive mails anymore from this list')
|
|
|
|
unsubscription_date = fields.Datetime(string='Unsubscription Date')
|
|
|
|
message_bounce = fields.Integer(string='Bounced', help='Counter of the number of bounced emails for this contact.', default=0)
|
|
|
|
country_id = fields.Many2one('res.country', string='Country')
|
|
|
|
tag_ids = fields.Many2many('res.partner.category', string='Tags')
|
|
|
|
|
|
|
|
@api.model
|
|
|
|
def create(self, vals):
|
|
|
|
if 'opt_out' in vals:
|
|
|
|
vals['unsubscription_date'] = vals['opt_out'] and fields.Datetime.now()
|
|
|
|
return super(MassMailingContact, self).create(vals)
|
|
|
|
|
|
|
|
@api.multi
|
|
|
|
def write(self, vals):
|
|
|
|
if 'opt_out' in vals:
|
|
|
|
vals['unsubscription_date'] = vals['opt_out'] and fields.Datetime.now()
|
|
|
|
return super(MassMailingContact, self).write(vals)
|
|
|
|
|
|
|
|
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
|
|
|
|
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]
|
|
|
|
|
|
|
|
@api.multi
|
|
|
|
def message_get_default_recipients(self):
|
|
|
|
return dict((record.id, {'partner_ids': [], 'email_to': record.email, 'email_cc': False}) for record in self)
|
|
|
|
|
|
|
|
|
|
|
|
class MassMailingStage(models.Model):
|
|
|
|
|
|
|
|
"""Stage for mass mailing campaigns. """
|
|
|
|
_name = 'mail.mass_mailing.stage'
|
|
|
|
_description = 'Mass Mailing Campaign Stage'
|
|
|
|
_order = 'sequence'
|
|
|
|
|
|
|
|
name = fields.Char(required=True, translate=True)
|
|
|
|
sequence = fields.Integer()
|
|
|
|
|
|
|
|
|
|
|
|
class MassMailingCampaign(models.Model):
|
|
|
|
"""Model of mass mailing campaigns. """
|
|
|
|
_name = "mail.mass_mailing.campaign"
|
|
|
|
_description = 'Mass Mailing Campaign'
|
|
|
|
_rec_name = "campaign_id"
|
|
|
|
_inherits = {'utm.campaign': 'campaign_id'}
|
|
|
|
|
|
|
|
stage_id = fields.Many2one('mail.mass_mailing.stage', string='Stage', required=True,
|
|
|
|
default=lambda self: self.env['mail.mass_mailing.stage'].search([], limit=1))
|
|
|
|
user_id = fields.Many2one(
|
|
|
|
'res.users', string='Responsible',
|
|
|
|
required=True, default=lambda self: self.env.uid)
|
|
|
|
campaign_id = fields.Many2one('utm.campaign', 'campaign_id',
|
|
|
|
required=True, ondelete='cascade', help="This name helps you tracking your different campaign efforts, e.g. Fall_Drive, Christmas_Special")
|
|
|
|
source_id = fields.Many2one('utm.source', string='Source',
|
|
|
|
help="This is the link source, e.g. Search Engine, another domain,or name of email list", default=lambda self: self.env.ref('utm.utm_source_newsletter'))
|
|
|
|
medium_id = fields.Many2one('utm.medium', string='Medium',
|
|
|
|
help="This is the delivery method, e.g. Postcard, Email, or Banner Ad", default=lambda self: self.env.ref('utm.utm_medium_email'))
|
|
|
|
tag_ids = fields.Many2many(
|
|
|
|
'mail.mass_mailing.tag', 'mail_mass_mailing_tag_rel',
|
|
|
|
'tag_id', 'campaign_id', string='Tags')
|
|
|
|
mass_mailing_ids = fields.One2many(
|
|
|
|
'mail.mass_mailing', 'mass_mailing_campaign_id',
|
|
|
|
string='Mass Mailings')
|
|
|
|
unique_ab_testing = fields.Boolean(string='Allow A/B Testing', default=True,
|
|
|
|
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.')
|
|
|
|
color = fields.Integer(string='Color Index')
|
|
|
|
clicks_ratio = fields.Integer(compute="_compute_clicks_ratio", string="Number of clicks")
|
|
|
|
# stat fields
|
|
|
|
total = fields.Integer(compute="_compute_statistics")
|
|
|
|
scheduled = fields.Integer(compute="_compute_statistics")
|
|
|
|
failed = fields.Integer(compute="_compute_statistics")
|
|
|
|
sent = fields.Integer(compute="_compute_statistics", string="Sent Emails")
|
|
|
|
delivered = fields.Integer(compute="_compute_statistics")
|
|
|
|
opened = fields.Integer(compute="_compute_statistics")
|
|
|
|
replied = fields.Integer(compute="_compute_statistics")
|
|
|
|
bounced = 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')
|
|
|
|
total_mailings = fields.Integer(compute="_compute_total_mailings", string='Mailings')
|
|
|
|
|
|
|
|
def _compute_clicks_ratio(self):
|
|
|
|
self.env.cr.execute("""
|
|
|
|
SELECT COUNT(DISTINCT(stats.id)) AS nb_mails, COUNT(DISTINCT(clicks.mail_stat_id)) AS nb_clicks, stats.mass_mailing_campaign_id AS id
|
|
|
|
FROM mail_mail_statistics AS stats
|
|
|
|
LEFT OUTER JOIN link_tracker_click AS clicks ON clicks.mail_stat_id = stats.id
|
|
|
|
WHERE stats.mass_mailing_campaign_id IN %s
|
|
|
|
GROUP BY stats.mass_mailing_campaign_id
|
|
|
|
""", (tuple(self.ids), ))
|
|
|
|
|
|
|
|
campaign_data = self.env.cr.dictfetchall()
|
|
|
|
mapped_data = dict([(c['id'], 100 * c['nb_clicks'] / c['nb_mails']) for c in campaign_data])
|
|
|
|
for campaign in self:
|
|
|
|
campaign.clicks_ratio = mapped_data.get(campaign.id, 0)
|
|
|
|
|
|
|
|
def _compute_statistics(self):
|
|
|
|
""" Compute statistics of the mass mailing campaign """
|
|
|
|
self.env.cr.execute("""
|
|
|
|
SELECT
|
|
|
|
c.id as campaign_id,
|
|
|
|
COUNT(s.id) AS total,
|
|
|
|
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 THEN 1 ELSE null END) AS scheduled,
|
|
|
|
COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is not null THEN 1 ELSE null END) AS failed,
|
|
|
|
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
|
|
|
|
mail_mail_statistics s
|
|
|
|
RIGHT JOIN
|
|
|
|
mail_mass_mailing_campaign c
|
|
|
|
ON (c.id = s.mass_mailing_campaign_id)
|
|
|
|
WHERE
|
|
|
|
c.id IN %s
|
|
|
|
GROUP BY
|
|
|
|
c.id
|
|
|
|
""", (tuple(self.ids), ))
|
|
|
|
|
|
|
|
for row in self.env.cr.dictfetchall():
|
|
|
|
total = row['total'] or 1
|
|
|
|
row['delivered'] = row['sent'] - row['bounced']
|
|
|
|
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('campaign_id')).update(row)
|
|
|
|
|
|
|
|
def _compute_total_mailings(self):
|
|
|
|
campaign_data = self.env['mail.mass_mailing'].read_group(
|
|
|
|
[('mass_mailing_campaign_id', 'in', self.ids)],
|
|
|
|
['mass_mailing_campaign_id'], ['mass_mailing_campaign_id'])
|
|
|
|
mapped_data = dict([(c['mass_mailing_campaign_id'][0], c['mass_mailing_campaign_id_count']) for c in campaign_data])
|
|
|
|
for campaign in self:
|
|
|
|
campaign.total_mailings = mapped_data.get(campaign.id, 0)
|
|
|
|
|
|
|
|
def get_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 = [('mass_mailing_campaign_id', '=', campaign.id)]
|
|
|
|
if model:
|
|
|
|
domain += [('model', '=', model)]
|
|
|
|
res[campaign.id] = set(self.env['mail.mail.statistics'].search(domain).mapped('res_id'))
|
|
|
|
return res
|
|
|
|
|
|
|
|
@api.model
|
|
|
|
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
|
|
|
|
""" Override read_group to always display all states. """
|
|
|
|
if groupby and groupby[0] == "stage_id":
|
|
|
|
# Default result structure
|
|
|
|
states_read = self.env['mail.mass_mailing.stage'].search_read([], ['name'])
|
|
|
|
states = [(state['id'], state['name']) for state in states_read]
|
|
|
|
read_group_all_states = [{
|
|
|
|
'__context': {'group_by': groupby[1:]},
|
|
|
|
'__domain': domain + [('stage_id', '=', state_value)],
|
|
|
|
'stage_id': state_value,
|
|
|
|
'state_count': 0,
|
|
|
|
} for state_value, state_name in states]
|
|
|
|
# Get standard results
|
|
|
|
read_group_res = super(MassMailingCampaign, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby)
|
|
|
|
# Update standard results with default results
|
|
|
|
result = []
|
|
|
|
for state_value, state_name in states:
|
|
|
|
res = [x for x in read_group_res if x['stage_id'] == (state_value, state_name)]
|
|
|
|
if not res:
|
|
|
|
res = [x for x in read_group_all_states if x['stage_id'] == state_value]
|
|
|
|
res[0]['stage_id'] = [state_value, state_name]
|
|
|
|
result.append(res[0])
|
|
|
|
return result
|
|
|
|
else:
|
|
|
|
return super(MassMailingCampaign, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby)
|
|
|
|
|
|
|
|
|
|
|
|
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 = 'mail.mass_mailing'
|
|
|
|
_description = 'Mass Mailing'
|
|
|
|
# number of periods for tracking mail_mail statistics
|
|
|
|
_period_number = 6
|
|
|
|
_order = 'sent_date DESC'
|
|
|
|
_inherits = {'utm.source': 'source_id'}
|
|
|
|
_rec_name = "source_id"
|
|
|
|
|
|
|
|
@api.model
|
|
|
|
def default_get(self, fields):
|
|
|
|
res = super(MassMailing, self).default_get(fields)
|
|
|
|
if 'reply_to_mode' in fields and not 'reply_to_mode' in res and res.get('mailing_model_real'):
|
|
|
|
if res['mailing_model_real'] in ['res.partner', 'mail.mass_mailing.contact']:
|
|
|
|
res['reply_to_mode'] = 'email'
|
|
|
|
else:
|
|
|
|
res['reply_to_mode'] = 'thread'
|
|
|
|
return res
|
|
|
|
|
|
|
|
active = fields.Boolean(default=True)
|
|
|
|
email_from = fields.Char(string='From', required=True,
|
|
|
|
default=lambda self: self.env['mail.message']._get_default_from())
|
|
|
|
create_date = fields.Datetime(string='Creation Date')
|
|
|
|
sent_date = fields.Datetime(string='Sent Date', oldname='date', copy=False)
|
|
|
|
schedule_date = fields.Datetime(string='Schedule in the Future')
|
|
|
|
body_html = fields.Html(string='Body', 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')
|
|
|
|
mass_mailing_campaign_id = fields.Many2one('mail.mass_mailing.campaign', string='Mass Mailing Campaign')
|
|
|
|
campaign_id = fields.Many2one('utm.campaign', string='Campaign',
|
|
|
|
help="This name helps you tracking your different campaign efforts, e.g. Fall_Drive, Christmas_Special")
|
|
|
|
source_id = fields.Many2one('utm.source', string='Subject', 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',
|
|
|
|
help="This is the delivery method, e.g. Postcard, Email, or Banner Ad", default=lambda self: self.env.ref('utm.utm_medium_email'))
|
|
|
|
clicks_ratio = fields.Integer(compute="_compute_clicks_ratio", string="Number of Clicks")
|
|
|
|
state = fields.Selection([('draft', 'Draft'), ('in_queue', 'In Queue'), ('sending', 'Sending'), ('done', 'Sent')],
|
|
|
|
string='Status', required=True, copy=False, default='draft')
|
|
|
|
color = fields.Integer(string='Color Index')
|
|
|
|
# mailing options
|
|
|
|
reply_to_mode = fields.Selection(
|
|
|
|
[('thread', 'Followers of leads/applicants'), ('email', 'Specified Email Address')],
|
|
|
|
string='Reply-To Mode', required=True)
|
|
|
|
reply_to = fields.Char(string='Reply To', help='Preferred Reply-To Address',
|
|
|
|
default=lambda self: self.env['mail.message']._get_default_from())
|
|
|
|
# recipients
|
|
|
|
mailing_model_real = fields.Char(compute='_compute_model', string='Recipients Real Model', default='mail.mass_mailing.contact', required=True)
|
|
|
|
mailing_model_id = fields.Many2one('ir.model', string='Recipients Model', domain=[('model', 'in', MASS_MAILING_BUSINESS_MODELS)],
|
|
|
|
default=lambda self: self.env.ref('mass_mailing.model_mail_mass_mailing_list').id)
|
2018-04-05 10:25:40 +02:00
|
|
|
mailing_model_name = fields.Char(related='mailing_model_id.model', string='Recipients Model Name', readonly=True, related_sudo=True)
|
2018-01-16 06:58:15 +01:00
|
|
|
mailing_domain = fields.Char(string='Domain', oldname='domain', default=[])
|
|
|
|
contact_list_ids = fields.Many2many('mail.mass_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)
|
|
|
|
# statistics data
|
|
|
|
statistics_ids = fields.One2many('mail.mail.statistics', 'mass_mailing_id', string='Emails Statistics')
|
|
|
|
total = fields.Integer(compute="_compute_total")
|
|
|
|
scheduled = fields.Integer(compute="_compute_statistics")
|
|
|
|
failed = fields.Integer(compute="_compute_statistics")
|
|
|
|
sent = fields.Integer(compute="_compute_statistics")
|
|
|
|
delivered = fields.Integer(compute="_compute_statistics")
|
|
|
|
opened = 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')
|
|
|
|
next_departure = fields.Datetime(compute="_compute_next_departure", string='Scheduled date')
|
|
|
|
|
|
|
|
def _compute_total(self):
|
|
|
|
for mass_mailing in self:
|
|
|
|
mass_mailing.total = len(mass_mailing.sudo().get_recipients())
|
|
|
|
|
|
|
|
def _compute_clicks_ratio(self):
|
|
|
|
self.env.cr.execute("""
|
|
|
|
SELECT COUNT(DISTINCT(stats.id)) AS nb_mails, COUNT(DISTINCT(clicks.mail_stat_id)) AS nb_clicks, stats.mass_mailing_id AS id
|
|
|
|
FROM mail_mail_statistics AS stats
|
|
|
|
LEFT OUTER JOIN link_tracker_click AS clicks ON clicks.mail_stat_id = stats.id
|
|
|
|
WHERE stats.mass_mailing_id IN %s
|
|
|
|
GROUP BY stats.mass_mailing_id
|
|
|
|
""", (tuple(self.ids), ))
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
@api.depends('mailing_model_id')
|
|
|
|
def _compute_model(self):
|
|
|
|
for record in self:
|
|
|
|
record.mailing_model_real = (record.mailing_model_name != 'mail.mass_mailing.list') and record.mailing_model_name or 'mail.mass_mailing.contact'
|
|
|
|
|
|
|
|
def _compute_statistics(self):
|
|
|
|
""" Compute statistics of the mass mailing """
|
|
|
|
self.env.cr.execute("""
|
|
|
|
SELECT
|
|
|
|
m.id as mailing_id,
|
|
|
|
COUNT(s.id) AS total,
|
|
|
|
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 THEN 1 ELSE null END) AS scheduled,
|
|
|
|
COUNT(CASE WHEN s.scheduled is not null AND s.sent is null AND s.exception is not null THEN 1 ELSE null END) AS failed,
|
|
|
|
COUNT(CASE WHEN s.sent 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,
|
|
|
|
COUNT(CASE WHEN s.exception is not null THEN 1 ELSE null END) AS failed
|
|
|
|
FROM
|
|
|
|
mail_mail_statistics s
|
|
|
|
RIGHT JOIN
|
|
|
|
mail_mass_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.pop('total') 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)
|
|
|
|
|
|
|
|
@api.multi
|
|
|
|
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 _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.onchange('mass_mailing_campaign_id')
|
|
|
|
def _onchange_mass_mailing_campaign_id(self):
|
|
|
|
if self.mass_mailing_campaign_id:
|
|
|
|
dic = {'campaign_id': self.mass_mailing_campaign_id.campaign_id,
|
|
|
|
'source_id': self.mass_mailing_campaign_id.source_id,
|
|
|
|
'medium_id': self.mass_mailing_campaign_id.medium_id}
|
|
|
|
self.update(dic)
|
|
|
|
|
|
|
|
@api.onchange('mailing_model_id', 'contact_list_ids')
|
|
|
|
def _onchange_model_and_list(self):
|
|
|
|
if self.mailing_model_name == 'mail.mass_mailing.list':
|
|
|
|
if self.contact_list_ids:
|
|
|
|
self.mailing_domain = "[('list_ids', 'in', [%s]), ('opt_out', '=', False)]" % (','.join(str(id) for id in self.contact_list_ids.ids),)
|
|
|
|
else:
|
|
|
|
self.mailing_domain = "[(0, '=', 1)]"
|
|
|
|
elif self.mailing_model_name and 'opt_out' in self.env[self.mailing_model_name]._fields and not self.mailing_domain:
|
|
|
|
self.mailing_domain = "[('opt_out', '=', False)]"
|
|
|
|
self.body_html = "on_change_model_and_list"
|
|
|
|
|
|
|
|
#------------------------------------------------------
|
|
|
|
# Technical stuff
|
|
|
|
#------------------------------------------------------
|
|
|
|
|
|
|
|
@api.model
|
|
|
|
def name_create(self, name):
|
|
|
|
""" _rec_name is source_id, creates a utm.source instead """
|
|
|
|
mass_mailing = self.create({'name': name})
|
|
|
|
return mass_mailing.name_get()[0]
|
|
|
|
|
|
|
|
@api.multi
|
|
|
|
def copy(self, default=None):
|
|
|
|
self.ensure_one()
|
|
|
|
default = dict(default or {},
|
|
|
|
name=_('%s (copy)') % self.name)
|
|
|
|
return super(MassMailing, self).copy(default=default)
|
|
|
|
|
|
|
|
@api.model
|
|
|
|
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
|
|
|
|
""" Override read_group to always display all states. """
|
|
|
|
if groupby and groupby[0] == "state":
|
|
|
|
# Default result structure
|
|
|
|
states = [('draft', _('Draft')), ('in_queue', _('In Queue')), ('sending', _('Sending')), ('done', _('Sent'))]
|
|
|
|
read_group_all_states = [{
|
|
|
|
'__context': {'group_by': groupby[1:]},
|
|
|
|
'__domain': domain + [('state', '=', state_value)],
|
|
|
|
'state': state_value,
|
|
|
|
'state_count': 0,
|
|
|
|
} for state_value, state_name in states]
|
|
|
|
# Get standard results
|
|
|
|
read_group_res = super(MassMailing, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby)
|
|
|
|
# Update standard results with default results
|
|
|
|
result = []
|
|
|
|
for state_value, state_name in states:
|
|
|
|
res = [x for x in read_group_res if x['state'] == state_value]
|
|
|
|
if not res:
|
|
|
|
res = [x for x in read_group_all_states if x['state'] == state_value]
|
|
|
|
res[0]['state'] = state_value
|
|
|
|
result.append(res[0])
|
|
|
|
return result
|
|
|
|
else:
|
|
|
|
return super(MassMailing, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby)
|
|
|
|
|
|
|
|
def update_opt_out(self, email, res_ids, value):
|
|
|
|
model = self.env[self.mailing_model_real].with_context(active_test=False)
|
|
|
|
if 'opt_out' in model._fields:
|
|
|
|
email_fname = 'email_from'
|
|
|
|
if 'email' in model._fields:
|
|
|
|
email_fname = 'email'
|
|
|
|
records = model.search([('id', 'in', res_ids), (email_fname, 'ilike', email)])
|
|
|
|
records.write({'opt_out': value})
|
|
|
|
|
|
|
|
#------------------------------------------------------
|
|
|
|
# Views & Actions
|
|
|
|
#------------------------------------------------------
|
|
|
|
|
|
|
|
@api.multi
|
|
|
|
def action_duplicate(self):
|
|
|
|
self.ensure_one()
|
|
|
|
mass_mailing_copy = self.copy()
|
|
|
|
if mass_mailing_copy:
|
2018-04-05 10:25:40 +02:00
|
|
|
context = dict(self.env.context)
|
|
|
|
context['form_view_initial_mode'] = 'edit'
|
2018-01-16 06:58:15 +01:00
|
|
|
return {
|
|
|
|
'type': 'ir.actions.act_window',
|
|
|
|
'view_type': 'form',
|
|
|
|
'view_mode': 'form',
|
|
|
|
'res_model': 'mail.mass_mailing',
|
|
|
|
'res_id': mass_mailing_copy.id,
|
2018-04-05 10:25:40 +02:00
|
|
|
'context': context,
|
2018-01-16 06:58:15 +01:00
|
|
|
}
|
|
|
|
return False
|
|
|
|
|
|
|
|
@api.multi
|
|
|
|
def action_test_mailing(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': 'mail.mass_mailing.test',
|
|
|
|
'target': 'new',
|
|
|
|
'context': ctx,
|
|
|
|
}
|
|
|
|
|
|
|
|
@api.multi
|
|
|
|
def put_in_queue(self):
|
|
|
|
self.write({'sent_date': fields.Datetime.now(), 'state': 'in_queue'})
|
|
|
|
|
|
|
|
@api.multi
|
|
|
|
def cancel_mass_mailing(self):
|
|
|
|
self.write({'state': 'draft'})
|
|
|
|
|
|
|
|
@api.multi
|
|
|
|
def retry_failed_mail(self):
|
|
|
|
failed_mails = self.env['mail.mail'].search([('mailing_id', 'in', self.ids), ('state', '=', 'exception')])
|
|
|
|
failed_mails.mapped('statistics_ids').unlink()
|
|
|
|
failed_mails.sudo().unlink()
|
|
|
|
self.write({'state': 'in_queue'})
|
|
|
|
|
|
|
|
#------------------------------------------------------
|
|
|
|
# Email Sending
|
|
|
|
#------------------------------------------------------
|
|
|
|
|
|
|
|
def _get_blacklist(self):
|
|
|
|
"""Returns a set of emails opted-out in target model"""
|
|
|
|
# TODO: implement a global blacklist table, to easily share
|
|
|
|
# it and update it.
|
|
|
|
self.ensure_one()
|
|
|
|
blacklist = {}
|
|
|
|
target = self.env[self.mailing_model_real]
|
|
|
|
mail_field = 'email' if 'email' in target._fields else 'email_from'
|
|
|
|
if 'opt_out' in target._fields:
|
|
|
|
# avoid loading a large number of records in memory
|
|
|
|
# + use a basic heuristic for extracting emails
|
|
|
|
query = """
|
|
|
|
SELECT lower(substring(%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)'))
|
|
|
|
FROM %(target)s
|
|
|
|
WHERE opt_out AND
|
|
|
|
substring(%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)') IS NOT NULL;
|
|
|
|
"""
|
|
|
|
query = query % {'target': target._table, 'mail_field': mail_field}
|
|
|
|
self._cr.execute(query)
|
|
|
|
blacklist = set(m[0] for m in self._cr.fetchall())
|
|
|
|
_logger.info(
|
|
|
|
"Mass-mailing %s targets %s, blacklist: %s emails",
|
|
|
|
self, target._name, len(blacklist))
|
|
|
|
else:
|
|
|
|
_logger.info("Mass-mailing %s targets %s, no blacklist available", self, target._name)
|
|
|
|
return blacklist
|
|
|
|
|
|
|
|
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]
|
|
|
|
mail_field = 'email' if 'email' in target._fields else 'email_from'
|
|
|
|
# avoid loading a large number of records in memory
|
|
|
|
# + use a basic heuristic for extracting emails
|
|
|
|
query = """
|
|
|
|
SELECT lower(substring(%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)'))
|
|
|
|
FROM mail_mail_statistics s
|
|
|
|
JOIN %(target)s t ON (s.res_id = t.id)
|
|
|
|
WHERE substring(%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)') IS NOT NULL
|
|
|
|
"""
|
|
|
|
if self.mass_mailing_campaign_id.unique_ab_testing:
|
|
|
|
query +="""
|
|
|
|
AND s.mass_mailing_campaign_id = %%(mailing_campaign_id)s;
|
|
|
|
"""
|
|
|
|
else:
|
|
|
|
query +="""
|
|
|
|
AND s.mass_mailing_id = %%(mailing_id)s;
|
|
|
|
"""
|
|
|
|
query = query % {'target': target._table, 'mail_field': mail_field}
|
|
|
|
params = {'mailing_id': self.id, 'mailing_campaign_id': self.mass_mailing_campaign_id.id}
|
|
|
|
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_blacklist': self._get_blacklist(),
|
|
|
|
'mass_mailing_seen_list': self._get_seen_list(),
|
|
|
|
}
|
|
|
|
|
|
|
|
def get_recipients(self):
|
|
|
|
if self.mailing_domain:
|
|
|
|
domain = safe_eval(self.mailing_domain)
|
|
|
|
res_ids = self.env[self.mailing_model_real].search(domain).ids
|
|
|
|
else:
|
|
|
|
res_ids = []
|
|
|
|
domain = [('id', 'in', res_ids)]
|
|
|
|
|
|
|
|
# randomly choose a fragment
|
|
|
|
if self.contact_ab_pc < 100:
|
|
|
|
contact_nbr = self.env[self.mailing_model_real].search_count(domain)
|
|
|
|
topick = int(contact_nbr / 100.0 * self.contact_ab_pc)
|
|
|
|
if self.mass_mailing_campaign_id and self.mass_mailing_campaign_id.unique_ab_testing:
|
|
|
|
already_mailed = self.mass_mailing_campaign_id.get_recipients()[self.mass_mailing_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['mail.mail.statistics'].search_read([('model', '=', self.mailing_model_real),
|
|
|
|
('res_id', 'in', res_ids),
|
|
|
|
('mass_mailing_id', '=', self.id)], ['res_id'])
|
|
|
|
already_mailed_res_ids = [record['res_id'] for record in already_mailed]
|
|
|
|
return list(set(res_ids) - set(already_mailed_res_ids))
|
|
|
|
|
|
|
|
def 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(_('Please select recipients.'))
|
|
|
|
|
|
|
|
# Convert links in absolute URLs before the application of the shortener
|
|
|
|
mailing.body_html = self.env['mail.template']._replace_local_links(mailing.body_html)
|
|
|
|
|
|
|
|
composer_values = {
|
|
|
|
'author_id': author_id,
|
|
|
|
'attachment_ids': [(4, attachment.id) for attachment in mailing.attachment_ids],
|
|
|
|
'body': mailing.convert_links()[mailing.id],
|
|
|
|
'subject': mailing.name,
|
|
|
|
'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',
|
2018-07-10 08:47:50 +02:00
|
|
|
'template_id': None,
|
2018-01-16 06:58:15 +01:00
|
|
|
}
|
|
|
|
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 = self._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.state = 'done'
|
|
|
|
return True
|
|
|
|
|
|
|
|
def convert_links(self):
|
|
|
|
res = {}
|
|
|
|
for mass_mailing in self:
|
|
|
|
utm_mixin = mass_mailing.mass_mailing_campaign_id if mass_mailing.mass_mailing_campaign_id else mass_mailing
|
|
|
|
html = mass_mailing.body_html if mass_mailing.body_html else ''
|
|
|
|
|
|
|
|
vals = {'mass_mailing_id': mass_mailing.id}
|
|
|
|
|
|
|
|
if mass_mailing.mass_mailing_campaign_id:
|
|
|
|
vals['mass_mailing_campaign_id'] = mass_mailing.mass_mailing_campaign_id.id
|
|
|
|
if utm_mixin.campaign_id:
|
|
|
|
vals['campaign_id'] = utm_mixin.campaign_id.id
|
|
|
|
if utm_mixin.source_id:
|
|
|
|
vals['source_id'] = utm_mixin.source_id.id
|
|
|
|
if utm_mixin.medium_id:
|
|
|
|
vals['medium_id'] = utm_mixin.medium_id.id
|
|
|
|
|
|
|
|
res[mass_mailing.id] = self.env['link.tracker'].convert_links(html, vals, blacklist=['/unsubscribe_from_list'])
|
|
|
|
|
|
|
|
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:
|
2018-04-05 10:25:40 +02:00
|
|
|
user = mass_mailing.write_uid or self.env.user
|
|
|
|
mass_mailing = mass_mailing.with_context(**user.sudo(user=user).context_get())
|
2018-01-16 06:58:15 +01:00
|
|
|
if len(mass_mailing.get_remaining_recipients()) > 0:
|
|
|
|
mass_mailing.state = 'sending'
|
|
|
|
mass_mailing.send_mail()
|
|
|
|
else:
|
|
|
|
mass_mailing.state = 'done'
|