diff --git a/addons/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py index 2a45147ec0d..c3a8c851814 100644 --- a/addons/mass_mailing/models/mass_mailing.py +++ b/addons/mass_mailing/models/mass_mailing.py @@ -161,7 +161,7 @@ class MassMailingCampaign(models.Model): for campaign in self: campaign.total_mailings = mapped_data.get(campaign.id, 0) - def get_recipients(self, model=None): + 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, {}) @@ -184,7 +184,6 @@ class MassMailingCampaign(models.Model): 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 @@ -275,7 +274,7 @@ class MassMailing(models.Model): def _compute_total(self): for mass_mailing in self: - mass_mailing.total = len(mass_mailing.sudo().get_recipients()) + mass_mailing.total = len(mass_mailing.sudo()._get_recipients()) def _compute_clicks_ratio(self): self.env.cr.execute(""" @@ -331,24 +330,6 @@ class MassMailing(models.Model): row['bounced_ratio'] = 100.0 * row['bounced'] / total self.browse(row.pop('mailing_id')).update(row) - 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 @@ -390,21 +371,22 @@ class MassMailing(models.Model): if self.subject and not self.name: self.name = self.subject - #------------------------------------------------------ - # Technical stuff - #------------------------------------------------------ + # ------------------------------------------------------ + # ORM + # ------------------------------------------------------ @api.model - def name_create(self, name): - """ _rec_name is source_id, creates a utm.source instead """ - mass_mailing = self.create({'name': name, 'subject': name}) - return mass_mailing.name_get()[0] + def create(self, values): + if values.get('name') and not values.get('subject'): + values['subject'] = values['name'] + if values.get('body_html'): + values['body_html'] = self._convert_inline_images_to_urls(values['body_html']) + return super(MassMailing, self).create(values) - @api.model - def create(self, vals): - if vals.get('name') and not vals.get('subject'): - vals['subject'] = vals['name'] - return super(MassMailing, self).create(vals) + 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): @@ -416,74 +398,9 @@ class MassMailing(models.Model): def _group_expand_states(self, states, domain, order): return [key for key, val in type(self).state.selection] - def update_opt_out(self, email, list_ids, value): - if len(list_ids) > 0: - model = self.env['mail.mass_mailing.contact'].with_context(active_test=False) - records = model.search([('email_normalized', '=', tools.email_normalize(email))]) - opt_out_records = self.env['mail.mass_mailing.list_contact_rel'].search([ - ('contact_id', 'in', records.ids), - ('list_id', 'in', list_ids), - ('opt_out', '!=', value) - ]) - - opt_out_records.write({'opt_out': value}) - message = _('The recipient unsubscribed from %s mailing list(s)') \ - if value else _('The recipient subscribed to %s 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')))) - - @api.model - def create(self, values): - 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) - - 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 - - #------------------------------------------------------ - # Views & Actions - #------------------------------------------------------ + # ------------------------------------------------------ + # ACTIONS + # ------------------------------------------------------ def action_duplicate(self): self.ensure_one() @@ -528,7 +445,7 @@ class MassMailing(models.Model): failed_mails = self.env['mail.mail'].search([('mailing_id', 'in', self.ids), ('state', '=', 'exception')]) failed_mails.mapped('statistics_ids').unlink() failed_mails.sudo().unlink() - res_ids = self.get_recipients() + res_ids = self._get_recipients() except_mailed = self.env['mail.mail.statistics'].search([ ('model', '=', self.mailing_model_real), ('res_id', 'in', res_ids), @@ -571,9 +488,28 @@ class MassMailing(models.Model): 'domain': [('id', 'in', res_ids)], } - #------------------------------------------------------ + def update_opt_out(self, email, list_ids, value): + if len(list_ids) > 0: + model = self.env['mail.mass_mailing.contact'].with_context(active_test=False) + records = model.search([('email_normalized', '=', tools.email_normalize(email))]) + opt_out_records = self.env['mail.mass_mailing.list_contact_rel'].search([ + ('contact_id', 'in', records.ids), + ('list_id', 'in', list_ids), + ('opt_out', '!=', value) + ]) + + opt_out_records.write({'opt_out': value}) + message = _('The recipient unsubscribed from %s mailing list(s)') \ + if value else _('The recipient subscribed to %s 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""" @@ -672,7 +608,7 @@ class MassMailing(models.Model): 'post_convert_links': self._get_convert_links(), } - def get_recipients(self): + 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 @@ -685,7 +621,7 @@ class MassMailing(models.Model): 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] + 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) @@ -694,20 +630,21 @@ class MassMailing(models.Model): 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 _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']) + 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 send_mail(self, res_ids=None): + 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() + res_ids = mailing._get_remaining_recipients() if not res_ids: raise UserError(_('There is no recipients selected.')) @@ -765,8 +702,63 @@ class MassMailing(models.Model): 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: + if len(mass_mailing._get_remaining_recipients()) > 0: mass_mailing.state = 'sending' - mass_mailing.send_mail() + mass_mailing.action_send_mail() else: mass_mailing.write({'state': 'done', 'sent_date': fields.Datetime.now()}) + + # ------------------------------------------------------ + # TOOLS + # ------------------------------------------------------ + + 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/mass_mailing_stats.py b/addons/mass_mailing/models/mass_mailing_stats.py index f0d8a763438..0710a0e8b1a 100644 --- a/addons/mass_mailing/models/mass_mailing_stats.py +++ b/addons/mass_mailing/models/mass_mailing_stats.py @@ -15,6 +15,7 @@ class MailMailStats(models.Model): _rec_name = 'message_id' _order = 'message_id' + # mail data mail_mail_id = fields.Many2one('mail.mail', string='Mail', index=True) mail_mail_id_int = fields.Integer( string='Mail ID (tech)', @@ -23,7 +24,9 @@ class MailMailStats(models.Model): 'However the ID is needed for several action and controllers.', index=True, ) + email = fields.Char(string="Recipient 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 @@ -54,9 +57,8 @@ class MailMailStats(models.Model): ('bounced', 'Bounced'), ('ignored', 'Ignored')], store=True) state_update = fields.Datetime(compute="_compute_state", string='State Update', - help='Last state update of the mail', - store=True) - email = fields.Char(string="Recipient email address") + help='Last state update of the mail', + store=True) @api.depends('sent', 'opened', 'clicked', 'replied', 'bounced', 'exception', 'ignored') def _compute_state(self): diff --git a/addons/test_mass_mailing/tests/test_mail_auto_blacklist.py b/addons/test_mass_mailing/tests/test_mail_auto_blacklist.py index ac04a835847..eee8c7ea964 100644 --- a/addons/test_mass_mailing/tests/test_mail_auto_blacklist.py +++ b/addons/test_mass_mailing/tests/test_mail_auto_blacklist.py @@ -54,7 +54,7 @@ class TestAutoBlacklist(common.TransactionCase): [self.mailing_contact_1.id])], 'body_html': 'This is a bounced mail for auto blacklist demo'}) self.mass_mailing.put_in_queue() - res_ids = self.mass_mailing.get_remaining_recipients() + res_ids = self.mass_mailing._get_remaining_recipients() composer_values = { 'body': self.mass_mailing.convert_links()[self.mass_mailing.id], 'subject': self.mass_mailing.name, diff --git a/addons/test_mass_mailing/tests/test_mass_mailing.py b/addons/test_mass_mailing/tests/test_mass_mailing.py index 28ba0596e6b..f9d4e0d845e 100644 --- a/addons/test_mass_mailing/tests/test_mass_mailing.py +++ b/addons/test_mass_mailing/tests/test_mass_mailing.py @@ -42,7 +42,7 @@ class TestMassMail(MassMailingCase): 'mailing_domain': [('id', 'in', self.mm_recs.ids)], }) - mailing.send_mail() + mailing.action_send_mail() # basic test emails are sent self.assertEqual(mailing.sent, 5) @@ -92,7 +92,7 @@ class TestAccessRights(TransactionCase): self.mailing_contact_4.id, self.mailing_contact_5.id])], 'body_html': 'This is mass mail marketing demo'}) self.mass_mailing.put_in_queue() - res_ids = self.mass_mailing.get_remaining_recipients() + res_ids = self.mass_mailing._get_remaining_recipients() composer_values = { 'body': self.mass_mailing.convert_links()[self.mass_mailing.id], 'subject': self.mass_mailing.name, @@ -296,7 +296,7 @@ class TestOnResPartner(TransactionCase): 'body_html': 'This is mass mail marketing demo'}) self.mass_mailing.mailing_model_real = 'res.partner' self.mass_mailing.put_in_queue() - res_ids = self.mass_mailing.get_remaining_recipients() + res_ids = self.mass_mailing._get_remaining_recipients() composer_values = { 'body': self.mass_mailing.convert_links()[self.mass_mailing.id], 'subject': self.mass_mailing.name, diff --git a/addons/test_mass_mailing/tests/test_performance.py b/addons/test_mass_mailing/tests/test_performance.py index a3b6699e2be..2bc18cf1f99 100644 --- a/addons/test_mass_mailing/tests/test_performance.py +++ b/addons/test_mass_mailing/tests/test_performance.py @@ -51,7 +51,7 @@ class TestMassMailPerformance(TestMassMailPerformanceBase): }) with self.assertQueryCount(__system__=2477, marketing=3128): - mailing.send_mail() + mailing.action_send_mail() self.assertEqual(mailing.sent, 50) self.assertEqual(mailing.delivered, 50) @@ -89,7 +89,7 @@ class TestMassMailBlPerformance(TestMassMailPerformanceBase): }) with self.assertQueryCount(__system__=2864, marketing=3611): - mailing.send_mail() + mailing.action_send_mail() self.assertEqual(mailing.sent, 50) self.assertEqual(mailing.delivered, 50)