Initial commit: Mail packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:51 +02:00
commit 4e53507711
1948 changed files with 751201 additions and 0 deletions

View file

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import common
from . import test_mailing_ab_testing
from . import test_mailing_internals
from . import test_mailing_list
from . import test_mailing_controllers
from . import test_mailing_mailing_schedule_date
from . import test_mailing_ui
from . import test_utm
from . import test_mailing_retry

View file

@ -0,0 +1,347 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
import random
import re
import werkzeug
from unittest.mock import patch
from odoo import tools
from odoo.addons.link_tracker.tests.common import MockLinkTracker
from odoo.addons.mail.tests.common import MailCase, MailCommon, mail_new_test_user
from odoo.sql_db import Cursor
class MassMailCase(MailCase, MockLinkTracker):
# ------------------------------------------------------------
# ASSERTS
# ------------------------------------------------------------
def assertMailingStatistics(self, mailing, **kwargs):
""" Helper to assert mailing statistics fields. As we have many of them
it helps lessening test asserts. """
if not kwargs.get('expected'):
kwargs['expected'] = len(mailing.mailing_trace_ids)
if not kwargs.get('delivered'):
kwargs['delivered'] = len(mailing.mailing_trace_ids)
for fname in ['scheduled', 'expected', 'sent', 'delivered',
'opened', 'replied', 'clicked',
'canceled', 'failed', 'bounced']:
self.assertEqual(
mailing[fname], kwargs.get(fname, 0),
'Mailing %s statistics failed: got %s instead of %s' % (fname, mailing[fname], kwargs.get(fname, 0))
)
def assertMailTraces(self, recipients_info, mailing, records,
check_mail=True, sent_unlink=False,
author=None, mail_links_info=None):
""" Check content of traces. Traces are fetched based on a given mailing
and records. Their content is compared to recipients_info structure that
holds expected information. Links content may be checked, notably to
assert shortening or unsubscribe links. Mail.mail records may optionally
be checked.
:param recipients_info: list[{
# TRACE
'partner': res.partner record (may be empty),
'email': email used when sending email (may be empty, computed based on partner),
'trace_status': outgoing / sent / open / reply / bounce / error / cancel (sent by default),
'record: linked record,
# MAIL.MAIL
'content': optional content that should be present in mail.mail body_html;
'email_to_mail': optional email used for the mail, when different from the
one stored on the trace itself;
'email_to_recipients': optional, see '_assertMailMail';
'failure_type': optional failure reason;
}, { ... }]
:param mailing: a mailing.mailing record from which traces have been
generated;
:param records: records given to mailing that generated traces. It is
used notably to find traces using their IDs;
:param check_mail: if True, also check mail.mail records that should be
linked to traces;
:param sent_unlink: it True, sent mail.mail are deleted and we check gateway
output result instead of actual mail.mail records;
:param mail_links_info: if given, should follow order of ``recipients_info``
and give details about links. See ``assertLinkShortenedHtml`` helper for
more details about content to give;
:param author: author of sent mail.mail;
"""
# map trace state to email state
state_mapping = {
'sent': 'sent',
'open': 'sent', # opened implies something has been sent
'reply': 'sent', # replied implies something has been sent
'error': 'exception',
'cancel': 'cancel',
'bounce': 'cancel',
}
traces = self.env['mailing.trace'].search([
('mass_mailing_id', 'in', mailing.ids),
('res_id', 'in', records.ids)
])
debug_info = '\n'.join(
(
f'Trace: to {t.email} - state {t.trace_status} - res_id {t.res_id}'
for t in traces
)
)
# ensure trace coherency
self.assertTrue(all(s.model == records._name for s in traces))
self.assertEqual(set(s.res_id for s in traces), set(records.ids))
# check each traces
if not mail_links_info:
mail_links_info = [None] * len(recipients_info)
for recipient_info, link_info, record in zip(recipients_info, mail_links_info, records):
partner = recipient_info.get('partner', self.env['res.partner'])
email = recipient_info.get('email')
email_to_mail = recipient_info.get('email_to_mail') or email
email_to_recipients = recipient_info.get('email_to_recipients')
status = recipient_info.get('trace_status', 'sent')
record = record or recipient_info.get('record')
content = recipient_info.get('content')
if email is None and partner:
email = partner.email_normalized
recipient_trace = traces.filtered(
lambda t: (t.email == email or (not email and not t.email)) and \
t.trace_status == status and \
(t.res_id == record.id if record else True)
)
self.assertTrue(
len(recipient_trace) == 1,
'MailTrace: email %s (recipient %s, status: %s, record: %s): found %s records (1 expected)\n%s' % (
email, partner, status, record,
len(recipient_trace), debug_info)
)
self.assertTrue(bool(recipient_trace.mail_mail_id_int))
if 'failure_type' in recipient_info or status in ('error', 'cancel', 'bounce'):
self.assertEqual(recipient_trace.failure_type, recipient_info['failure_type'])
if check_mail:
if author is None:
author = self.env.user.partner_id
# mail.mail specific values to check
fields_values = {'mailing_id': mailing}
if recipient_info.get('mail_values'):
fields_values.update(recipient_info['mail_values'])
if 'failure_reason' in recipient_info:
fields_values['failure_reason'] = recipient_info['failure_reason']
if 'email_to_mail' in recipient_info:
fields_values['email_to'] = recipient_info['email_to_mail']
# specific for partner: email_formatted is used
if partner:
if status == 'sent' and sent_unlink:
self.assertSentEmail(author, [partner])
else:
self.assertMailMail(
partner, state_mapping[status],
author=author,
content=content,
email_to_recipients=email_to_recipients,
fields_values=fields_values,
)
# specific if email is False -> could have troubles finding it if several falsy traces
elif not email and status in ('cancel', 'bounce'):
self.assertMailMailWId(
recipient_trace.mail_mail_id_int, state_mapping[status],
author=author,
content=content,
email_to_recipients=email_to_recipients,
fields_values=fields_values,
)
else:
self.assertMailMailWEmails(
[email_to_mail], state_mapping[status],
author=author,
content=content,
email_to_recipients=email_to_recipients,
fields_values=fields_values,
)
if link_info:
trace_mail = self._find_mail_mail_wrecord(record)
for (anchor_id, url, is_shortened, add_link_params) in link_info:
link_params = {'utm_medium': 'Email', 'utm_source': mailing.name}
if add_link_params:
link_params.update(**add_link_params)
self.assertLinkShortenedHtml(
trace_mail.body_html,
(anchor_id, url, is_shortened),
link_params=link_params,
)
# ------------------------------------------------------------
# TOOLS
# ------------------------------------------------------------
def gateway_mail_bounce(self, mailing, record, bounce_base_values=None):
""" Generate a bounce at mailgateway level.
:param mailing: a ``mailing.mailing`` record on which we find a trace
to bounce;
:param record: record which should bounce;
:param bounce_base_values: optional values given to routing;
"""
trace = mailing.mailing_trace_ids.filtered(
lambda t: t.model == record._name and t.res_id == record.id
)
parsed_bounce_values = {
'email_from': 'some.email@external.example.com', # TDE check: email_from -> trace email ?
'to': 'bounce@test.example.com', # TDE check: bounce alias ?
'message_id': tools.generate_tracking_message_id('MailTest'),
'bounced_partner': self.env['res.partner'].sudo(),
'bounced_message': self.env['mail.message'].sudo()
}
if bounce_base_values:
parsed_bounce_values.update(bounce_base_values)
parsed_bounce_values.update({
'bounced_email': trace.email,
'bounced_msg_id': [trace.message_id],
})
self.env['mail.thread']._routing_handle_bounce(False, parsed_bounce_values)
return trace
def gateway_mail_click(self, mailing, record, click_label):
""" Simulate a click on a sent email.
:param mailing: a ``mailing.mailing`` record on which we find a trace
to click;
:param record: record which should click;
:param click_label: label of link on which we should click;
"""
trace = mailing.mailing_trace_ids.filtered(
lambda t: t.model == record._name and t.res_id == record.id
)
email = self._find_sent_mail_wemail(trace.email)
self.assertTrue(bool(email))
for (_url_href, link_url, _dummy, label) in re.findall(tools.HTML_TAG_URL_REGEX, email['body']):
if label == click_label and '/r/' in link_url: # shortened link, like 'http://localhost:8069/r/LBG/m/53'
parsed_url = werkzeug.urls.url_parse(link_url)
path_items = parsed_url.path.split('/')
code, trace_id = path_items[2], int(path_items[4])
self.assertEqual(trace.id, trace_id)
self.env['link.tracker.click'].sudo().add_click(
code,
ip='100.200.300.%3f' % random.random(),
country_code='BE',
mailing_trace_id=trace.id
)
break
else:
raise AssertionError('url %s not found in mailing %s for record %s' % (click_label, mailing, record))
return trace
def gateway_mail_open(self, mailing, record):
""" Simulate opening an email through blank.gif icon access. As we
don't want to use the whole Http layer just for that we will just
call 'set_opened()' on trace, until having a better option.
:param mailing: a ``mailing.mailing`` record on which we find a trace
to open;
:param record: record which should open;
"""
trace = mailing.mailing_trace_ids.filtered(
lambda t: t.model == record._name and t.res_id == record.id
)
trace.set_opened()
return trace
@classmethod
def _create_bounce_trace(cls, mailing, records, dt=None):
if dt is None:
dt = datetime.datetime.now() - datetime.timedelta(days=1)
return cls._create_traces(mailing, records, dt, trace_status='bounce')
@classmethod
def _create_sent_traces(cls, mailing, records, dt=None):
if dt is None:
dt = datetime.datetime.now() - datetime.timedelta(days=1)
return cls._create_traces(mailing, records, dt, trace_status='sent')
@classmethod
def _create_traces(cls, mailing, records, dt, **values):
if 'email_normalized' in records:
fname = 'email_normalized'
elif 'email_from' in records:
fname = 'email_from'
else:
fname = 'email'
randomized = random.random()
# Cursor.now() uses transaction's timestamp and not datetime lib -> freeze_time
# is not sufficient
with patch.object(Cursor, 'now', lambda *args, **kwargs: dt):
traces = cls.env['mailing.trace'].sudo().create([
dict({'mass_mailing_id': mailing.id,
'model': record._name,
'res_id': record.id,
'trace_status': values.get('trace_status', 'bounce'),
# TDE FIXME: improve this with a mail-enabled heuristics
'email': record[fname],
'message_id': '<%5f@gilbert.boitempomils>' % randomized,
}, **values)
for record in records
])
return traces
@classmethod
def _create_mailing_list(cls):
""" Shortcut to create mailing lists. Currently hardcoded, maybe evolve
in a near future. """
cls.mailing_list_1 = cls.env['mailing.list'].with_context(cls._test_context).create({
'name': 'List1',
'contact_ids': [
(0, 0, {'name': 'Déboulonneur', 'email': 'fleurus@example.com'}),
(0, 0, {'name': 'Gorramts', 'email': 'gorramts@example.com'}),
(0, 0, {'name': 'Ybrant', 'email': 'ybrant@example.com'}),
]
})
cls.mailing_list_2 = cls.env['mailing.list'].with_context(cls._test_context).create({
'name': 'List2',
'contact_ids': [
(0, 0, {'name': 'Gilberte', 'email': 'gilberte@example.com'}),
(0, 0, {'name': 'Gilberte En Mieux', 'email': 'gilberte@example.com'}),
(0, 0, {'name': 'Norbert', 'email': 'norbert@example.com'}),
(0, 0, {'name': 'Ybrant', 'email': 'ybrant@example.com'}),
]
})
@classmethod
def _create_mailing_list_of_x_contacts(cls, contacts_nbr):
""" Shortcut to create a mailing list that contains a defined number
of contacts. """
return cls.env['mailing.list'].with_context(cls._test_context).create({
'name': 'Test List',
'contact_ids': [
(0, 0, {'name': 'Contact %s' % i, 'email': 'contact%s@example.com' % i})
for i in range(contacts_nbr)
],
})
class MassMailCommon(MailCommon, MassMailCase):
@classmethod
def setUpClass(cls):
super(MassMailCommon, cls).setUpClass()
cls.user_marketing = mail_new_test_user(
cls.env, login='user_marketing',
groups='base.group_user,base.group_partner_manager,mass_mailing.group_mass_mailing_user',
name='Martial Marketing', signature='--\nMartial')
cls.email_reply_to = 'MyCompany SomehowAlias <test.alias@test.mycompany.com>'
cls.env.flush_all()

View file

@ -0,0 +1,206 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from odoo.addons.mass_mailing.tests.common import MassMailCommon
from odoo.tests import users, tagged
from odoo.tools import mute_logger
from odoo.tests.common import Form
from odoo import fields
@tagged('post_install', '-at_install')
class TestMailingABTestingCommon(MassMailCommon):
def setUp(self):
super().setUp()
self.mailing_list = self._create_mailing_list_of_x_contacts(150)
self.ab_testing_mailing_1 = self.env['mailing.mailing'].create({
'subject': 'A/B Testing V1',
'contact_list_ids': self.mailing_list.ids,
'ab_testing_enabled': True,
'ab_testing_pc': 10,
'ab_testing_schedule_datetime': datetime.now(),
})
self.ab_testing_mailing_2 = self.ab_testing_mailing_1.copy({
'subject': 'A/B Testing V2',
'ab_testing_pc': 20,
})
self.ab_testing_campaign = self.ab_testing_mailing_1.campaign_id
self.ab_testing_mailing_ids = self.ab_testing_mailing_1 + self.ab_testing_mailing_2
self.env.flush_all()
self.env.invalidate_all()
class TestMailingABTesting(TestMailingABTestingCommon):
@mute_logger('odoo.addons.mail.models.mail_mail')
@users('user_marketing')
def test_mailing_ab_testing_auto_flow(self):
with self.mock_mail_gateway():
self.ab_testing_mailing_ids.action_send_mail()
self.assertEqual(self.ab_testing_mailing_1.state, 'done')
self.assertEqual(self.ab_testing_mailing_2.state, 'done')
self.assertEqual(self.ab_testing_mailing_1.opened_ratio, 0)
self.assertEqual(self.ab_testing_mailing_2.opened_ratio, 0)
total_trace_ids = self.ab_testing_mailing_ids.mailing_trace_ids
unique_recipients_used = set(map(lambda mail: mail.res_id, total_trace_ids.mail_mail_id))
self.assertEqual(len(self.ab_testing_mailing_1.mailing_trace_ids), 15)
self.assertEqual(len(self.ab_testing_mailing_2.mailing_trace_ids), 30)
self.assertEqual(len(unique_recipients_used), 45)
self.ab_testing_mailing_1.mailing_trace_ids[:10].set_opened()
self.ab_testing_mailing_2.mailing_trace_ids[:15].set_opened()
self.ab_testing_mailing_ids.invalidate_recordset()
self.assertEqual(self.ab_testing_mailing_1.opened_ratio, 66)
self.assertEqual(self.ab_testing_mailing_2.opened_ratio, 50)
with self.mock_mail_gateway():
self.ab_testing_mailing_2.action_send_winner_mailing()
self.ab_testing_mailing_ids.invalidate_recordset()
winner_mailing = self.ab_testing_campaign.mailing_mail_ids.filtered(lambda mailing: mailing.ab_testing_pc == 100)
self.assertEqual(winner_mailing.subject, 'A/B Testing V1')
@mute_logger('odoo.addons.mail.models.mail_mail')
@users('user_marketing')
def test_mailing_ab_testing_auto_flow_cron(self):
self.ab_testing_mailing_1.write({
'ab_testing_schedule_datetime': datetime.now() + timedelta(days=-1),
})
with self.mock_mail_gateway():
self.ab_testing_mailing_ids.action_send_mail()
self.assertEqual(self.ab_testing_mailing_1.state, 'done')
self.assertEqual(self.ab_testing_mailing_2.state, 'done')
self.assertEqual(self.ab_testing_mailing_1.opened_ratio, 0)
self.assertEqual(self.ab_testing_mailing_2.opened_ratio, 0)
total_trace_ids = self.ab_testing_mailing_ids.mailing_trace_ids
unique_recipients_used = set(map(lambda mail: mail.res_id, total_trace_ids.mail_mail_id))
self.assertEqual(len(self.ab_testing_mailing_1.mailing_trace_ids), 15)
self.assertEqual(len(self.ab_testing_mailing_2.mailing_trace_ids), 30)
self.assertEqual(len(unique_recipients_used), 45)
self.ab_testing_mailing_1.mailing_trace_ids[:10].set_opened()
self.ab_testing_mailing_2.mailing_trace_ids[:15].set_opened()
self.ab_testing_mailing_ids.invalidate_recordset()
self.assertEqual(self.ab_testing_mailing_1.opened_ratio, 66)
self.assertEqual(self.ab_testing_mailing_2.opened_ratio, 50)
with self.mock_mail_gateway():
self.env.ref('mass_mailing.ir_cron_mass_mailing_ab_testing').sudo().method_direct_trigger()
self.ab_testing_mailing_ids.invalidate_recordset()
winner_mailing = self.ab_testing_campaign.mailing_mail_ids.filtered(lambda mailing: mailing.ab_testing_pc == 100)
self.assertEqual(winner_mailing.subject, 'A/B Testing V1')
@users('user_marketing')
def test_mailing_ab_testing_campaign(self):
schedule_datetime = datetime.now() + timedelta(days=30)
ab_mailing = self.env['mailing.mailing'].create({
'subject': 'A/B Testing V1',
'contact_list_ids': self.mailing_list.ids,
'ab_testing_enabled': True,
'ab_testing_winner_selection': 'manual',
'ab_testing_schedule_datetime': schedule_datetime,
})
ab_mailing.invalidate_recordset()
# Check if the campaign is correctly created and the values set on the mailing are still the same
self.assertTrue(ab_mailing.campaign_id, "A campaign id is present for the A/B test mailing")
self.assertEqual(ab_mailing.ab_testing_winner_selection, 'manual', "The selection winner has been propagated correctly")
self.assertEqual(ab_mailing.ab_testing_schedule_datetime, schedule_datetime, "The schedule date has been propagated correctly")
# Check that while enabling the A/B testing, if campaign is already set, new one should not be created
created_mailing_campaign = ab_mailing.campaign_id
ab_mailing.ab_testing_enabled = False
ab_mailing.ab_testing_enabled = True
self.assertEqual(ab_mailing.campaign_id, created_mailing_campaign, "No new campaign should have been created")
# Check that while enabling the A/B testing, if user manually selects a campaign, it should be saved
# rather than being replaced with the automatically created one
ab_mailing.write({'ab_testing_enabled': False, 'campaign_id': False})
ab_mailing.write({'ab_testing_enabled': True, 'campaign_id': created_mailing_campaign})
self.assertEqual(ab_mailing.campaign_id, created_mailing_campaign, "No new campaign should have been created")
ab_mailing_2 = self.env['mailing.mailing'].create({
'subject': 'A/B Testing V2',
'contact_list_ids': self.mailing_list.ids,
})
ab_mailing_2.invalidate_recordset()
ab_mailing_2.ab_testing_enabled = True
# Check if the campaign is correctly created with default values
self.assertTrue(ab_mailing.campaign_id, "A campaign id is present for the A/B test mailing")
self.assertTrue(ab_mailing.ab_testing_winner_selection, "The selection winner has been set to default value")
self.assertTrue(ab_mailing.ab_testing_schedule_datetime, "The schedule date has been set to default value")
@users('user_marketing')
def test_mailing_ab_testing_compare(self):
# compare version feature should returns all mailings of the same
# campaign having a/b testing enabled.
compare_version = self.ab_testing_mailing_1.action_compare_versions()
self.assertEqual(
self.env['mailing.mailing'].search(compare_version.get('domain')),
self.ab_testing_mailing_1 + self.ab_testing_mailing_2
)
@mute_logger('odoo.addons.mail.models.mail_mail')
@users('user_marketing')
def test_mailing_ab_testing_manual_flow(self):
self.ab_testing_mailing_1.write({
'ab_testing_winner_selection': 'manual',
})
with self.mock_mail_gateway():
self.ab_testing_mailing_ids.action_send_mail()
self.assertEqual(self.ab_testing_mailing_1.state, 'done')
self.assertEqual(self.ab_testing_mailing_2.state, 'done')
self.assertEqual(self.ab_testing_mailing_1.opened_ratio, 0)
self.assertEqual(self.ab_testing_mailing_2.opened_ratio, 0)
total_trace_ids = self.ab_testing_mailing_ids.mailing_trace_ids
unique_recipients_used = set(map(lambda mail: mail.res_id, total_trace_ids.mail_mail_id))
self.assertEqual(len(self.ab_testing_mailing_1.mailing_trace_ids), 15)
self.assertEqual(len(self.ab_testing_mailing_2.mailing_trace_ids), 30)
self.assertEqual(len(unique_recipients_used), 45)
self.ab_testing_mailing_1.mailing_trace_ids[:10].set_opened()
self.ab_testing_mailing_2.mailing_trace_ids[:15].set_opened()
self.ab_testing_mailing_ids.invalidate_recordset()
self.assertEqual(self.ab_testing_mailing_1.opened_ratio, 66)
self.assertEqual(self.ab_testing_mailing_2.opened_ratio, 50)
with self.mock_mail_gateway():
self.ab_testing_mailing_2.action_send_winner_mailing()
self.ab_testing_mailing_ids.invalidate_recordset()
winner_mailing = self.ab_testing_campaign.mailing_mail_ids.filtered(lambda mailing: mailing.ab_testing_pc == 100)
self.assertEqual(winner_mailing.subject, 'A/B Testing V2')
@mute_logger('odoo.addons.mail.models.mail_mail')
@users('user_marketing')
def test_mailing_ab_testing_minimum_participants(self):
""" Test that it should send minimum one mail(if possible) when ab_testing_pc is too small compared to the amount of targeted records."""
mailing_list = self._create_mailing_list_of_x_contacts(10)
ab_testing = self.env['mailing.mailing'].create({
'subject': 'A/B Testing SMS V1',
'contact_list_ids': mailing_list.ids,
'ab_testing_enabled': True,
'ab_testing_pc': 2,
'ab_testing_schedule_datetime': datetime.now(),
'mailing_type': 'mail',
'campaign_id': self.ab_testing_campaign.id,
})
with self.mock_mail_gateway():
ab_testing.action_send_mail()
self.assertEqual(ab_testing.state, 'done')
self.assertEqual(len(self._mails), 1)
def test_mailing_ab_testing_duplicate_date(self):
""" Test that "Send final on" date value should be copied in new mass_mailing """
ab_testing_mail_1 = Form(self.ab_testing_mailing_1)
ab_testing_mail_1.ab_testing_schedule_datetime = datetime.now() + timedelta(days=10)
action = ab_testing_mail_1.save().action_duplicate()
ab_testing_mailing_2 = self.env[action['res_model']].browse(action['res_id'])
self.assertEqual(fields.Datetime.to_string(ab_testing_mailing_2.ab_testing_schedule_datetime), ab_testing_mail_1.ab_testing_schedule_datetime)

View file

@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from freezegun import freeze_time
import datetime
import werkzeug
from odoo.addons.mass_mailing.tests.common import MassMailCommon
from odoo.tests.common import HttpCase
from odoo.tests import tagged
@tagged('link_tracker')
class TestMailingControllers(MassMailCommon, HttpCase):
@classmethod
def setUpClass(cls):
super(TestMailingControllers, cls).setUpClass()
cls._create_mailing_list()
cls.test_mailing = cls.env['mailing.mailing'].create({
'body_html': '<p>Hello <t t-out="object.name"/><br />Go to <a id="url" href="https://www.example.com/foo/bar?baz=qux">this link</a></p>',
'contact_list_ids': [(4, cls.mailing_list_1.id), (4, cls.mailing_list_2.id)],
'mailing_model_id': cls.env['ir.model']._get('mailing.list').id,
'mailing_type': 'mail',
'name': 'TestMailing',
'reply_to': cls.email_reply_to,
'subject': 'Test',
})
cls.test_contact = cls.mailing_list_1.contact_ids[0]
# freeze time base value
cls._reference_now = datetime.datetime(2022, 6, 14, 10, 0, 0)
def test_tracking_short_code(self):
""" Test opening short code linked to a mailing trace: should set the
trace as opened and clicked, create a click record. """
mailing = self.test_mailing.with_env(self.env)
with self.mock_mail_gateway(mail_unlink_sent=False):
mailing.action_send_mail()
mail = self._find_mail_mail_wrecord(self.test_contact)
mailing_trace = mail.mailing_trace_ids
link_tracker_code = self._get_code_from_short_url(
self._get_href_from_anchor_id(mail.body, 'url')
)
self.assertEqual(len(link_tracker_code), 1)
self.assertEqual(link_tracker_code.link_id.count, 0)
self.assertEqual(mail.state, 'sent')
self.assertEqual(len(mailing_trace), 1)
self.assertFalse(mailing_trace.links_click_datetime)
self.assertFalse(mailing_trace.open_datetime)
self.assertEqual(mailing_trace.trace_status, 'sent')
short_link_url = werkzeug.urls.url_join(
mail.get_base_url(),
f'r/{link_tracker_code.code}/m/{mailing_trace.id}'
)
with freeze_time(self._reference_now):
response = self.url_open(short_link_url, allow_redirects=False)
self.assertEqual(response.headers['Location'], 'https://www.example.com/foo/bar?baz=qux&utm_source=TestMailing&utm_medium=Email')
self.assertEqual(link_tracker_code.link_id.count, 1)
self.assertEqual(mailing_trace.links_click_datetime, self._reference_now)
self.assertEqual(mailing_trace.open_datetime, self._reference_now)
self.assertEqual(mailing_trace.trace_status, 'open')
def test_tracking_url_token(self):
""" Test tracking of mails linked to a mailing trace: should set the
trace as opened. """
mailing = self.test_mailing.with_env(self.env)
with self.mock_mail_gateway(mail_unlink_sent=False):
mailing.action_send_mail()
mail = self._find_mail_mail_wrecord(self.test_contact)
mailing_trace = mail.mailing_trace_ids
self.assertEqual(mail.state, 'sent')
self.assertEqual(len(mailing_trace), 1)
self.assertFalse(mailing_trace.open_datetime)
self.assertEqual(mailing_trace.trace_status, 'sent')
with freeze_time(self._reference_now):
response = self.url_open(mail._get_tracking_url())
self.assertEqual(response.status_code, 200)
self.assertEqual(mail.state, 'sent')
self.assertEqual(mailing_trace.open_datetime, self._reference_now)
self.assertEqual(mailing_trace.trace_status, 'open')
track_url = werkzeug.urls.url_join(
mail.get_base_url(),
'mail/track/%s/fake_token/blank.gif' % mail.id
)
response = self.url_open(track_url)
self.assertEqual(response.status_code, 400)

View file

@ -0,0 +1,856 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import re
from ast import literal_eval
from datetime import datetime
from unittest.mock import patch
from freezegun import freeze_time
from psycopg2 import IntegrityError
from unittest.mock import patch
from odoo.addons.base.tests.test_ir_cron import CronMixinCase
from odoo.addons.mass_mailing.tests.common import MassMailCommon
from odoo.exceptions import ValidationError
from odoo.sql_db import Cursor
from odoo.tests.common import users, Form, HttpCase, tagged
from odoo.tools import mute_logger
BASE_64_STRING = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
@tagged("mass_mailing")
class TestMassMailValues(MassMailCommon):
@classmethod
def setUpClass(cls):
super(TestMassMailValues, cls).setUpClass()
cls._create_mailing_list()
@users('user_marketing')
def test_mailing_body_cropped_vml_image(self):
""" Testing mail mailing responsive bg-image cropping for Outlook.
Outlook needs background images to be converted to VML but there is no
way to emulate `background-size: cover` that works for Windows Mail as
well. We therefore need to crop the image in the VML version to mimick
the style of other email clients.
"""
attachment = {}
def patched_get_image(self, url, session):
return base64.b64decode(BASE_64_STRING)
original_images_to_urls = self.env['mailing.mailing']._create_attachments_from_inline_images
def patched_images_to_urls(self, b64images):
urls = original_images_to_urls(b64images)
if len(urls) == 1:
(attachment_id, attachment_token) = re.search(r'/web/image/(?P<id>[0-9]+)\?access_token=(?P<token>.*)', urls[0]).groups()
attachment['id'] = attachment_id
attachment['token'] = attachment_token
return urls
else:
return []
with patch("odoo.addons.mass_mailing.models.mailing.MassMailing._get_image_by_url",
new=patched_get_image), \
patch("odoo.addons.mass_mailing.models.mailing.MassMailing._create_attachments_from_inline_images",
new=patched_images_to_urls):
mailing = self.env['mailing.mailing'].create({
'name': 'Test',
'subject': 'Test',
'state': 'draft',
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
'body_html': """
<html>
<!--[if mso]>
<v:image src="https://www.example.com/image" style="width:100px;height:100px;"/>
<![endif]-->
</html>
""",
})
self.assertEqual(str(mailing.body_html), f"""
<html>
<!--[if mso]>
<v:image src="/web/image/{attachment['id']}?access_token={attachment['token']}" style="width:100px;height:100px;"/>
<![endif]-->
</html>
""".strip())
@users('user_marketing')
def test_mailing_body_inline_image(self):
""" Testing mail mailing base64 image conversion to attachment.
This test ensures that the base64 images are correctly converted to
attachments, even when they appear in MSO conditional comments.
"""
attachments = []
original_images_to_urls = self.env['mailing.mailing']._create_attachments_from_inline_images
def patched_images_to_urls(self, b64images):
urls = original_images_to_urls(b64images)
for url in urls:
(attachment_id, attachment_token) = re.search(r'/web/image/(?P<id>[0-9]+)\?access_token=(?P<token>.*)', url).groups()
attachments.append({
'id': attachment_id,
'token': attachment_token,
})
return urls
with patch("odoo.addons.mass_mailing.models.mailing.MassMailing._create_attachments_from_inline_images",
new=patched_images_to_urls):
mailing = self.env['mailing.mailing'].create({
'name': 'Test',
'subject': 'Test',
'state': 'draft',
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
'body_html': f"""
<html><body>
<img src="data:image/png;base64,{BASE_64_STRING}0">
<img src="data:image/jpg;base64,{BASE_64_STRING}1">
<div style='color: red; background-image:url("data:image/jpg;base64,{BASE_64_STRING}2"); display: block;'/>
<div style="color: red; background-image:url('data:image/jpg;base64,{BASE_64_STRING}3'); display: block;"/>
<div style="color: red; background-image:url(&quot;data:image/jpg;base64,{BASE_64_STRING}4&quot;); display: block;"/>
<div style="color: red; background-image:url(&#34;data:image/jpg;base64,{BASE_64_STRING}5&#34;); display: block;"/>
<div style="color: red; background-image:url(data:image/jpg;base64,{BASE_64_STRING}6); display: block;"/>
<div style="color: red; background-image: url(data:image/jpg;base64,{BASE_64_STRING}7); background: url('data:image/jpg;base64,{BASE_64_STRING}8'); display: block;"/>
<!--[if mso]>
<img src="data:image/png;base64,{BASE_64_STRING}9">Fake url, in text: img src="data:image/png;base64,{BASE_64_STRING}"
Fake url, in text: img src="data:image/png;base64,{BASE_64_STRING}"
<img src="data:image/jpg;base64,{BASE_64_STRING}10">
<div style='color: red; background-image:url("data:image/jpg;base64,{BASE_64_STRING}11"); display: block;'>Fake url, in text: style="background-image:url('data:image/png;base64,{BASE_64_STRING}');"
Fake url, in text: style="background-image:url('data:image/png;base64,{BASE_64_STRING}');"</div>
<div style="color: red; background-image:url('data:image/jpg;base64,{BASE_64_STRING}12'); display: block;"/>
<div style="color: red; background-image:url(&quot;data:image/jpg;base64,{BASE_64_STRING}13&quot;); display: block;"/>
<div style="color: red; background-image:url(&#34;data:image/jpg;base64,{BASE_64_STRING}14&#34;); display: block;"/>
<div style="color: red; background-image:url(data:image/jpg;base64,{BASE_64_STRING}15); display: block;"/>
<div style="color: red; background-image: url(data:image/jpg;base64,{BASE_64_STRING}16); background: url('data:image/jpg;base64,{BASE_64_STRING}17'); display: block;"/>
<![endif]-->
<img src="data:image/png;base64,{BASE_64_STRING}0">
</body></html>
""",
})
self.assertEqual(len(attachments), 19)
self.assertEqual(attachments[0]['id'], attachments[18]['id'])
self.assertEqual(str(mailing.body_html), f"""
<html><body>
<img src="/web/image/{attachments[0]['id']}?access_token={attachments[0]['token']}">
<img src="/web/image/{attachments[1]['id']}?access_token={attachments[1]['token']}">
<div style='color: red; background-image:url("/web/image/{attachments[2]['id']}?access_token={attachments[2]['token']}"); display: block;'></div>
<div style="color: red; background-image:url('/web/image/{attachments[3]['id']}?access_token={attachments[3]['token']}'); display: block;"></div>
<div style='color: red; background-image:url("/web/image/{attachments[4]['id']}?access_token={attachments[4]['token']}"); display: block;'></div>
<div style='color: red; background-image:url("/web/image/{attachments[5]['id']}?access_token={attachments[5]['token']}"); display: block;'></div>
<div style="color: red; background-image:url(/web/image/{attachments[6]['id']}?access_token={attachments[6]['token']}); display: block;"></div>
<div style="color: red; background-image: url(/web/image/{attachments[7]['id']}?access_token={attachments[7]['token']}); background: url('/web/image/{attachments[8]['id']}?access_token={attachments[8]['token']}'); display: block;"></div>
<!--[if mso]>
<img src="/web/image/{attachments[9]['id']}?access_token={attachments[9]['token']}">Fake url, in text: img src="data:image/png;base64,{BASE_64_STRING}"
Fake url, in text: img src="data:image/png;base64,{BASE_64_STRING}"
<img src="/web/image/{attachments[10]['id']}?access_token={attachments[10]['token']}">
<div style='color: red; background-image:url("/web/image/{attachments[11]['id']}?access_token={attachments[11]['token']}"); display: block;'>Fake url, in text: style="background-image:url('data:image/png;base64,{BASE_64_STRING}');"
Fake url, in text: style="background-image:url('data:image/png;base64,{BASE_64_STRING}');"</div>
<div style="color: red; background-image:url('/web/image/{attachments[12]['id']}?access_token={attachments[12]['token']}'); display: block;"/>
<div style="color: red; background-image:url(&quot;/web/image/{attachments[13]['id']}?access_token={attachments[13]['token']}&quot;); display: block;"/>
<div style="color: red; background-image:url(&#34;/web/image/{attachments[14]['id']}?access_token={attachments[14]['token']}&#34;); display: block;"/>
<div style="color: red; background-image:url(/web/image/{attachments[15]['id']}?access_token={attachments[15]['token']}); display: block;"/>
<div style="color: red; background-image: url(/web/image/{attachments[16]['id']}?access_token={attachments[16]['token']}); background: url('/web/image/{attachments[17]['id']}?access_token={attachments[17]['token']}'); display: block;"/>
<![endif]-->
<img src="/web/image/{attachments[18]['id']}?access_token={attachments[18]['token']}">
</body></html>
""".strip())
@users('user_marketing')
def test_mailing_body_responsive(self):
""" Testing mail mailing responsive mail body
Reference: https://litmus.com/community/learning/24-how-to-code-a-responsive-email-from-scratch
https://www.campaignmonitor.com/css/link-element/link-in-head/
This template is meant to put inline CSS into an email's head
"""
recipient = self.env['res.partner'].create({
'name': 'Mass Mail Partner',
'email': 'Customer <test.customer@example.com>',
})
mailing = self.env['mailing.mailing'].create({
'name': 'Test',
'subject': 'Test',
'state': 'draft',
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
})
composer = self.env['mail.compose.message'].with_user(self.user_marketing).with_context({
'default_composition_mode': 'mass_mail',
'default_model': 'res.partner',
'default_res_id': recipient.id,
}).create({
'subject': 'Mass Mail Responsive',
'body': 'I am Responsive body',
'mass_mailing_id': mailing.id
})
mail_values = composer.get_mail_values([recipient.id])
body_html = mail_values[recipient.id]['body_html']
self.assertIn('<!DOCTYPE html>', body_html)
self.assertIn('<head>', body_html)
self.assertIn('viewport', body_html)
# This is important: we need inline css, and not <link/>
self.assertIn('@media', body_html)
self.assertIn('I am Responsive body', body_html)
@users('user_marketing')
def test_mailing_computed_fields(self):
# Create on res.partner, with default values for computed fields
mailing = self.env['mailing.mailing'].create({
'name': 'TestMailing',
'subject': 'Test',
'mailing_type': 'mail',
'body_html': '<p>Hello <t t-out="object.name"/></p>',
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
})
self.assertEqual(mailing.user_id, self.user_marketing)
self.assertEqual(mailing.medium_id, self.env.ref('utm.utm_medium_email'))
self.assertEqual(mailing.mailing_model_name, 'res.partner')
self.assertEqual(mailing.mailing_model_real, 'res.partner')
self.assertEqual(mailing.reply_to_mode, 'new')
self.assertEqual(mailing.reply_to, self.user_marketing.email_formatted)
# default for partner: remove blacklisted
self.assertEqual(literal_eval(mailing.mailing_domain), [('is_blacklisted', '=', False)])
# update domain
mailing.write({
'mailing_domain': [('email', 'ilike', 'test.example.com')]
})
self.assertEqual(literal_eval(mailing.mailing_domain), [('email', 'ilike', 'test.example.com')])
# reset mailing model -> reset domain; set reply_to -> keep it
mailing.write({
'mailing_model_id': self.env['ir.model']._get('mailing.list').id,
'reply_to': self.email_reply_to,
})
self.assertEqual(mailing.mailing_model_name, 'mailing.list')
self.assertEqual(mailing.mailing_model_real, 'mailing.contact')
self.assertEqual(mailing.reply_to_mode, 'new')
self.assertEqual(mailing.reply_to, self.email_reply_to)
# default for mailing list: depends upon contact_list_ids
self.assertEqual(literal_eval(mailing.mailing_domain), [('list_ids', 'in', [])])
mailing.write({
'contact_list_ids': [(4, self.mailing_list_1.id), (4, self.mailing_list_2.id)]
})
self.assertEqual(literal_eval(mailing.mailing_domain), [('list_ids', 'in', (self.mailing_list_1 | self.mailing_list_2).ids)])
# reset mailing model -> reset domain and reply to mode
mailing.write({
'mailing_model_id': self.env['ir.model']._get('mail.channel').id,
})
self.assertEqual(mailing.mailing_model_name, 'mail.channel')
self.assertEqual(mailing.mailing_model_real, 'mail.channel')
self.assertEqual(mailing.reply_to_mode, 'update')
self.assertFalse(mailing.reply_to)
@users('user_marketing')
def test_mailing_computed_fields_domain_w_filter(self):
""" Test domain update, involving mailing.filters added in 15.1. """
# Create on res.partner, with default values for computed fields
mailing = self.env['mailing.mailing'].create({
'name': 'TestMailing',
'subject': 'Test',
'mailing_type': 'mail',
'body_html': '<p>Hello <t t-out="object.name"/></p>',
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
})
# default for partner: remove blacklisted
self.assertEqual(literal_eval(mailing.mailing_domain), [('is_blacklisted', '=', False)])
# prepare initial data
filter_1, filter_2, filter_3 = self.env['mailing.filter'].create([
{'name': 'General channel',
'mailing_domain' : [('name', '=', 'general')],
'mailing_model_id': self.env['ir.model']._get('mail.channel').id,
},
{'name': 'LLN City',
'mailing_domain' : [('city', 'ilike', 'LLN')],
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
},
{'name': 'Email based',
'mailing_domain' : [('email', 'ilike', 'info@odoo.com')],
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
}
])
# check that adding mailing_filter_id updates domain correctly
mailing.mailing_filter_id = filter_2
self.assertEqual(literal_eval(mailing.mailing_domain), literal_eval(filter_2.mailing_domain))
# cannot set a filter linked to another model
with self.assertRaises(ValidationError):
mailing.mailing_filter_id = filter_1
# resetting model should reset domain, even if filter was chosen previously
mailing.mailing_model_id = self.env['ir.model']._get('mail.channel').id
self.assertEqual(literal_eval(mailing.mailing_domain), [])
# changing the filter should update the mailing domain correctly
mailing.mailing_filter_id = filter_1
self.assertEqual(literal_eval(mailing.mailing_domain), literal_eval(filter_1.mailing_domain))
# changing the domain should not empty the mailing_filter_id
mailing.mailing_domain = "[('email', 'ilike', 'info_be@odoo.com')]"
self.assertEqual(mailing.mailing_filter_id, filter_1, "Filter should not be unset even if domain is changed")
# deleting the filter record should not delete the domain on mailing
mailing.mailing_model_id = self.env['ir.model']._get('res.partner').id
mailing.mailing_filter_id = filter_3
filter_3_domain = filter_3.mailing_domain
self.assertEqual(literal_eval(mailing.mailing_domain), literal_eval(filter_3_domain))
filter_3.unlink() # delete the filter record
self.assertFalse(mailing.mailing_filter_id, "Should unset filter if it is deleted")
self.assertEqual(literal_eval(mailing.mailing_domain), literal_eval(filter_3_domain), "Should still have the same domain")
@users('user_marketing')
def test_mailing_computed_fields_default(self):
mailing = self.env['mailing.mailing'].with_context(
default_mailing_domain=repr([('email', 'ilike', 'test.example.com')])
).create({
'name': 'TestMailing',
'subject': 'Test',
'mailing_type': 'mail',
'body_html': '<p>Hello <t t-out="object.name"/></p>',
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
})
self.assertEqual(literal_eval(mailing.mailing_domain), [('email', 'ilike', 'test.example.com')])
@users('user_marketing')
def test_mailing_computed_fields_default_email_from(self):
# Testing if the email_from is correctly computed when an
# alias domain for the company is set
# Setup mail outgoing server for use cases
from_filter_match, from_filter_missmatch = self.env['ir.mail_server'].sudo().create([
# Case where alias domain is set and there is a default outgoing email server
# for mass mailing. from_filter matches domain of company alias domain
# before record creation
{
'name': 'mass_mailing_test_match_from_filter',
'from_filter': self.alias_domain,
'smtp_host': 'not_real@smtp.com',
},
# Case where alias domain is set and there is a default outgoing email server
# for mass mailing. from_filter DOES NOT match domain of company alias domain
# before record creation
{
'name': 'mass_mailing_test_from_missmatch',
'from_filter': 'notcompanydomain.com',
'smtp_host': 'not_real@smtp.com',
},
])
# Expected combos of server vs FROM values
servers = [
self.env['ir.mail_server'],
from_filter_match,
from_filter_missmatch,
]
expected_from_all = [
self.env.user.email_formatted, # default when no server
self.env['ir.mail_server']._get_default_from_address(), # matches company alias domain
self.env.user.email_formatted, # not matching from filter -> back to user from
]
for mail_server, expected_from in zip(servers, expected_from_all):
with self.subTest(server_name=mail_server.name):
# When a mail server is set, we update the mass mailing
# settings to designate a dedicated outgoing email server
if mail_server:
self.env['res.config.settings'].sudo().create({
'mass_mailing_mail_server_id': mail_server.id,
'mass_mailing_outgoing_mail_server': mail_server,
}).execute()
# Create mailing
mailing = self.env['mailing.mailing'].create({
'name': f'TestMailing {mail_server.name}',
'subject': f'Test {mail_server.name}',
})
# Check email_from
self.assertEqual(mailing.email_from, expected_from)
# If configured, check if dedicated email outgoing server is
# on mailing record
self.assertEqual(mailing.mail_server_id, mail_server)
@users('user_marketing')
def test_mailing_computed_fields_form(self):
mailing_form = Form(self.env['mailing.mailing'].with_context(
default_mailing_domain="[('email', 'ilike', 'test.example.com')]",
default_mailing_model_id=self.env['ir.model']._get('res.partner').id,
))
self.assertEqual(
literal_eval(mailing_form.mailing_domain),
[('email', 'ilike', 'test.example.com')],
)
self.assertEqual(mailing_form.mailing_model_real, 'res.partner')
@mute_logger('odoo.sql_db')
@users('user_marketing')
def test_mailing_trace_values(self):
recipient = self.partner_employee
# both void and 0 are invalid, document should have an id != 0
with self.assertRaises(IntegrityError):
self.env['mailing.trace'].create({
'model': recipient._name,
})
with self.assertRaises(IntegrityError):
self.env['mailing.trace'].create({
'model': recipient._name,
'res_id': 0,
})
with self.assertRaises(IntegrityError):
self.env['mailing.trace'].create({
'res_id': 3,
})
activity = self.env['mailing.trace'].create({
'model': recipient._name,
'res_id': recipient.id,
})
with self.assertRaises(IntegrityError):
activity.write({'model': False})
self.env.flush_all()
with self.assertRaises(IntegrityError):
activity.write({'res_id': False})
self.env.flush_all()
with self.assertRaises(IntegrityError):
activity.write({'res_id': 0})
self.env.flush_all()
def test_mailing_editor_created_attachments(self):
mailing = self.env['mailing.mailing'].create({
'name': 'TestMailing',
'subject': 'Test',
'mailing_type': 'mail',
'body_html': '<p>Hello</p>',
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
})
blob_b64 = base64.b64encode(b'blob1')
# Created when uploading an image
original_svg_attachment = self.env['ir.attachment'].create({
"name": "test SVG",
"mimetype": "image/svg+xml",
"datas": blob_b64,
"public": True,
"res_model": "mailing.mailing",
"res_id": mailing.id,
})
# Created when saving the mass_mailing
png_duplicate_of_svg_attachment = self.env['ir.attachment'].create({
"name": "test SVG duplicate",
"mimetype": "image/png",
"datas": blob_b64,
"public": True,
"res_model": "mailing.mailing",
"res_id": mailing.id,
"original_id": original_svg_attachment.id
})
# Created by uploading new image
original_png_attachment = self.env['ir.attachment'].create({
"name": "test PNG",
"mimetype": "image/png",
"datas": blob_b64,
"public": True,
"res_model": "mailing.mailing",
"res_id": mailing.id,
})
# Created by modify_image in editor controller
self.env['ir.attachment'].create({
"name": "test PNG duplicate",
"mimetype": "image/png",
"datas": blob_b64,
"public": True,
"res_model": "mailing.mailing",
"res_id": mailing.id,
"original_id": original_png_attachment.id
})
mail_thread_attachments = mailing._get_mail_thread_data_attachments()
self.assertSetEqual(set(mail_thread_attachments.ids), {png_duplicate_of_svg_attachment.id, original_png_attachment.id})
@users('user_marketing')
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_process_mailing_queue_without_html_body(self):
""" Test mailing with past schedule date and without any html body """
mailing = self.env['mailing.mailing'].create({
'name': 'mailing',
'subject': 'some subject',
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
'preview': "Check it out before its too late",
'body_html': False,
'schedule_date': datetime(2023, 2, 17, 11, 0),
})
mailing.action_put_in_queue()
with self.mock_mail_gateway(mail_unlink_sent=False):
mailing._process_mass_mailing_queue()
self.assertFalse(mailing.body_html)
self.assertEqual(mailing.mailing_model_name, 'res.partner')
@tagged("mass_mailing", "utm")
class TestMassMailUTM(MassMailCommon):
@freeze_time('2022-01-02')
@patch.object(Cursor, 'now', lambda *args, **kwargs: datetime(2022, 1, 2))
@users('user_marketing')
def test_mailing_unique_name(self):
"""Test that the names are generated and unique for each mailing.
If the name is missing, it's generated from the subject. Then we should ensure
that this generated name is unique.
"""
mailing_0 = self.env['mailing.mailing'].create({'subject': 'First subject'})
self.assertEqual(mailing_0.name, 'First subject (Mass Mailing created on 2022-01-02)')
mailing_1, mailing_2, mailing_3, mailing_4, mailing_5, mailing_6 = self.env['mailing.mailing'].create([{
'subject': 'First subject',
}, {
'subject': 'First subject',
}, {
'subject': 'First subject',
'source_id': self.env['utm.source'].create({'name': 'Custom Source'}).id,
}, {
'subject': 'First subject',
'name': 'Mailing',
}, {
'subject': 'Second subject',
'name': 'Mailing',
}, {
'subject': 'Second subject',
}])
self.assertEqual(mailing_0.name, 'First subject (Mass Mailing created on 2022-01-02)')
self.assertEqual(mailing_1.name, 'First subject (Mass Mailing created on 2022-01-02) [2]')
self.assertEqual(mailing_2.name, 'First subject (Mass Mailing created on 2022-01-02) [3]')
self.assertEqual(mailing_3.name, 'Custom Source')
self.assertEqual(mailing_4.name, 'Mailing')
self.assertEqual(mailing_5.name, 'Mailing [2]')
self.assertEqual(mailing_6.name, 'Second subject (Mass Mailing created on 2022-01-02)')
# should generate same name (coming from same subject)
mailing_0.subject = 'First subject'
self.assertEqual(mailing_0.name, 'First subject (Mass Mailing created on 2022-01-02)',
msg='The name should not be updated')
# take a (long) existing name -> should increment
mailing_0.name = 'Second subject (Mass Mailing created on 2022-01-02)'
self.assertEqual(mailing_0.name, 'Second subject (Mass Mailing created on 2022-01-02) [2]',
msg='The name must be unique, it was already taken')
# back to first subject: not linked to any record so should take it back
mailing_0.subject = 'First subject'
self.assertEqual(mailing_0.name, 'First subject (Mass Mailing created on 2022-01-02)',
msg='The name should be back to first one')
def test_mailing_create_with_context(self):
""" Test that the default_name provided via context is ignored to prevent constraint violations."""
mailing_1, mailing_2 = self.env["mailing.mailing"].create([
{
"subject": "First subject",
"name": "Mailing",
},
{
"subject": "Second subject",
"name": "Mailing",
},
])
self.assertEqual(mailing_1.name, "Mailing")
self.assertEqual(mailing_2.name, "Mailing [2]")
mailing_3 = self.env["mailing.mailing"].with_context({"default_name": "Mailing"}).create({"subject": "Third subject"})
self.assertEqual(mailing_3.name, "Mailing [3]")
@tagged('mass_mailing')
class TestMassMailFeatures(MassMailCommon, CronMixinCase):
@classmethod
def setUpClass(cls):
super(TestMassMailFeatures, cls).setUpClass()
cls._create_mailing_list()
@users('user_marketing')
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_mailing_cron_trigger(self):
""" Technical test to ensure the cron is triggered at the correct
time """
cron_id = self.env.ref('mass_mailing.ir_cron_mass_mailing_queue').id
partner = self.env['res.partner'].create({
'name': 'Jean-Alphonce',
'email': 'jeanalph@example.com',
})
common_mailing_values = {
'name': 'Knock knock',
'subject': "Who's there?",
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
'mailing_domain': [('id', '=', partner.id)],
'body_html': 'The marketing mailing test.',
'schedule_type': 'scheduled',
}
now = datetime(2021, 2, 5, 16, 43, 20)
then = datetime(2021, 2, 7, 12, 0, 0)
with freeze_time(now):
for (test, truth) in [(False, now), (then, then)]:
with self.subTest(schedule_date=test):
with self.capture_triggers(cron_id) as capt:
mailing = self.env['mailing.mailing'].create({
**common_mailing_values,
'schedule_date': test,
})
mailing.action_put_in_queue()
capt.records.ensure_one()
self.assertLessEqual(capt.records.call_at, truth)
@users('user_marketing')
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_mailing_deletion(self):
""" Test deletion in various use case, depending on reply-to """
# 1- Keep archives and reply-to set to 'answer = new thread'
mailing = self.env['mailing.mailing'].create({
'name': 'TestSource',
'subject': 'TestDeletion',
'body_html': "<div>Hello {object.name}</div>",
'mailing_model_id': self.env['ir.model']._get('mailing.list').id,
'contact_list_ids': [(6, 0, self.mailing_list_1.ids)],
'keep_archives': True,
'reply_to_mode': 'new',
'reply_to': self.email_reply_to,
})
self.assertEqual(self.mailing_list_1.contact_ids.message_ids, self.env['mail.message'])
with self.mock_mail_gateway(mail_unlink_sent=True):
mailing.action_send_mail()
self.assertEqual(len(self._mails), 3)
self.assertEqual(len(self._new_mails.exists()), 3)
self.assertEqual(len(self.mailing_list_1.contact_ids.message_ids), 3)
# 2- Keep archives and reply-to set to 'answer = update thread'
self.mailing_list_1.contact_ids.message_ids.unlink()
mailing = mailing.copy()
mailing.write({
'reply_to_mode': 'update',
})
self.assertEqual(self.mailing_list_1.contact_ids.message_ids, self.env['mail.message'])
with self.mock_mail_gateway(mail_unlink_sent=True):
mailing.action_send_mail()
self.assertEqual(len(self._mails), 3)
self.assertEqual(len(self._new_mails.exists()), 3)
self.assertEqual(len(self.mailing_list_1.contact_ids.message_ids), 3)
# 3- Remove archives and reply-to set to 'answer = new thread'
self.mailing_list_1.contact_ids.message_ids.unlink()
mailing = mailing.copy()
mailing.write({
'keep_archives': False,
'reply_to_mode': 'new',
'reply_to': self.email_reply_to,
})
self.assertEqual(self.mailing_list_1.contact_ids.message_ids, self.env['mail.message'])
with self.mock_mail_gateway(mail_unlink_sent=True):
mailing.action_send_mail()
self.assertEqual(len(self._mails), 3)
self.assertEqual(len(self._new_mails.exists()), 0)
self.assertEqual(self.mailing_list_1.contact_ids.message_ids, self.env['mail.message'])
# 4- Remove archives and reply-to set to 'answer = update thread'
# Imply keeping mail.message for gateway reply)
self.mailing_list_1.contact_ids.message_ids.unlink()
mailing = mailing.copy()
mailing.write({
'keep_archives': False,
'reply_to_mode': 'update',
})
self.assertEqual(self.mailing_list_1.contact_ids.message_ids, self.env['mail.message'])
with self.mock_mail_gateway(mail_unlink_sent=True):
mailing.action_send_mail()
self.assertEqual(len(self._mails), 3)
self.assertEqual(len(self._new_mails.exists()), 0)
self.assertEqual(len(self.mailing_list_1.contact_ids.message_ids), 3)
@users('user_marketing')
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_mailing_on_res_partner(self):
""" Test mailing on res.partner model: ensure default recipients are
correctly computed """
partner_a = self.env['res.partner'].create({
'name': 'test email 1',
'email': 'test1@example.com',
})
partner_b = self.env['res.partner'].create({
'name': 'test email 2',
'email': 'test2@example.com',
})
self.env['mail.blacklist'].create({'email': 'Test2@example.com',})
mailing = self.env['mailing.mailing'].create({
'name': 'One',
'subject': 'One',
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
'mailing_domain': [('id', 'in', (partner_a | partner_b).ids)],
'body_html': 'This is mass mail marketing demo'
})
mailing.action_put_in_queue()
with self.mock_mail_gateway(mail_unlink_sent=False):
mailing._process_mass_mailing_queue()
self.assertMailTraces(
[{'partner': partner_a},
{'partner': partner_b, 'trace_status': 'cancel', 'failure_type': 'mail_bl'}],
mailing, partner_a + partner_b, check_mail=True
)
@users('user_marketing')
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_mailing_shortener(self):
mailing = self.env['mailing.mailing'].create({
'name': 'TestSource',
'subject': 'TestShortener',
'body_html': """<div>
Hi,
<t t-set="url" t-value="'www.odoo.com'"/>
<t t-set="httpurl" t-value="'https://www.odoo.eu'"/>
Website0: <a id="url0" t-attf-href="https://www.odoo.tz/my/{{object.name}}">https://www.odoo.tz/my/<t t-esc="object.name"/></a>
Website1: <a id="url1" href="https://www.odoo.be">https://www.odoo.be</a>
Website2: <a id="url2" t-attf-href="https://{{url}}">https://<t t-esc="url"/></a>
Website3: <a id="url3" t-att-href="httpurl"><t t-esc="httpurl"/></a>
External1: <a id="url4" href="https://www.example.com/foo/bar?baz=qux">Youpie</a>
Email: <a id="url5" href="mailto:test@odoo.com">test@odoo.com</a></div>""",
'mailing_model_id': self.env['ir.model']._get('mailing.list').id,
'reply_to_mode': 'new',
'reply_to': self.email_reply_to,
'contact_list_ids': [(6, 0, self.mailing_list_1.ids)],
'keep_archives': True,
})
mailing.action_put_in_queue()
with self.mock_mail_gateway(mail_unlink_sent=False):
mailing._process_mass_mailing_queue()
self.assertMailTraces(
[{'email': 'fleurus@example.com'},
{'email': 'gorramts@example.com'},
{'email': 'ybrant@example.com'}],
mailing, self.mailing_list_1.contact_ids, check_mail=True
)
for contact in self.mailing_list_1.contact_ids:
new_mail = self._find_mail_mail_wrecord(contact)
for link_info in [('url0', 'https://www.odoo.tz/my/%s' % contact.name, True),
('url1', 'https://www.odoo.be', True),
('url2', 'https://www.odoo.com', True),
('url3', 'https://www.odoo.eu', True),
('url4', 'https://www.example.com/foo/bar?baz=qux', True),
('url5', 'mailto:test@odoo.com', False)]:
# TDE FIXME: why going to mail message id ? mail.body_html seems to fail, check
link_params = {'utm_medium': 'Email', 'utm_source': mailing.name}
if link_info[0] == 'url4':
link_params['baz'] = 'qux'
self.assertLinkShortenedHtml(
new_mail.mail_message_id.body,
link_info,
link_params=link_params,
)
@tagged("mail_mail")
class TestMailingHeaders(MassMailCommon, HttpCase):
""" Test headers + linked controllers """
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._create_mailing_list()
cls.test_mailing = cls.env['mailing.mailing'].with_user(cls.user_marketing).create({
"body_html": """
<p>Hello <t t-out="object.name"/>
<a href="/unsubscribe_from_list">UNSUBSCRIBE</a>
<a href="/view">VIEW</a>
</p>""",
"contact_list_ids": [(4, cls.mailing_list_1.id)],
"mailing_model_id": cls.env["ir.model"]._get("mailing.list").id,
"mailing_type": "mail",
"name": "TestMailing",
"subject": "Test for {{ object.name }}",
})
@users('user_marketing')
def test_mailing_unsubscribe_headers(self):
""" Check unsubscribe headers are present in outgoing emails and work
as one-click """
test_mailing = self.test_mailing.with_env(self.env)
test_mailing.action_put_in_queue()
with self.mock_mail_gateway(mail_unlink_sent=False):
test_mailing.action_send_mail()
for contact in self.mailing_list_1.contact_ids:
new_mail = self._find_mail_mail_wrecord(contact)
# check mail.mail still have default links
self.assertIn("/unsubscribe_from_list", new_mail.body)
self.assertIn("/view", new_mail.body)
# check outgoing email headers (those are put into outgoing email
# not in the mail.mail record)
email = self._find_sent_mail_wemail(contact.email)
headers = email.get("headers")
unsubscribe_oneclick_url = test_mailing._get_unsubscribe_oneclick_url(contact.email, contact.id)
self.assertTrue(headers, "Mass mailing emails should have headers for unsubscribe")
self.assertEqual(headers.get("List-Unsubscribe"), f"<{unsubscribe_oneclick_url}>")
self.assertEqual(headers.get("List-Unsubscribe-Post"), "List-Unsubscribe=One-Click")
self.assertEqual(headers.get("Precedence"), "list")
# check outgoing email has real links
self.assertNotIn("/unsubscribe_from_list", email["body"])
# unsubscribe in one-click
unsubscribe_oneclick_url = headers["List-Unsubscribe"].strip("<>")
self.opener.post(unsubscribe_oneclick_url)
# should be unsubscribed
self.assertTrue(contact.subscription_list_ids.opt_out)
class TestMailingScheduleDateWizard(MassMailCommon):
@mute_logger('odoo.addons.mail.models.mail_mail')
@users('user_marketing')
def test_mailing_schedule_date(self):
mailing = self.env['mailing.mailing'].create({
'name': 'mailing',
'subject': 'some subject'
})
# create a schedule date wizard
wizard_form = Form(
self.env['mailing.mailing.schedule.date'].with_context(default_mass_mailing_id=mailing.id))
# set a schedule date
wizard_form.schedule_date = datetime(2021, 4, 30, 9, 0)
wizard = wizard_form.save()
wizard.action_schedule_date()
# assert that the schedule_date and schedule_type fields are correct and that the mailing is put in queue
self.assertEqual(mailing.schedule_date, datetime(2021, 4, 30, 9, 0))
self.assertEqual(mailing.schedule_type, 'scheduled')
self.assertEqual(mailing.state, 'in_queue')

View file

@ -0,0 +1,271 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from freezegun import freeze_time
from odoo import exceptions
from odoo.addons.mass_mailing.tests.common import MassMailCommon
from odoo.tests.common import Form, users
class TestMailingContactToList(MassMailCommon):
@users('user_marketing')
def test_mailing_contact_to_list(self):
contacts = self.env['mailing.contact'].create([{
'name': 'Contact %02d',
'email': 'contact_%02d@test.example.com',
} for x in range(30)])
self.assertEqual(len(contacts), 30)
self.assertEqual(contacts.list_ids, self.env['mailing.list'])
mailing = self.env['mailing.list'].create({
'name': 'Contacts Agregator',
})
# create wizard with context values
wizard_form = Form(self.env['mailing.contact.to.list'].with_context(default_contact_ids=contacts.ids))
self.assertEqual(wizard_form.contact_ids._get_ids(), contacts.ids)
# set mailing list and add contacts
wizard_form.mailing_list_id = mailing
wizard = wizard_form.save()
action = wizard.action_add_contacts()
self.assertEqual(contacts.list_ids, mailing)
self.assertEqual(action["type"], "ir.actions.client")
self.assertTrue(action.get("params", {}).get("next"), "Should return a notification with a next action")
subaction = action["params"]["next"]
self.assertEqual(subaction["type"], "ir.actions.act_window_close")
# set mailing list, add contacts and redirect to mailing view
mailing2 = self.env['mailing.list'].create({
'name': 'Contacts Sublimator',
})
wizard_form.mailing_list_id = mailing2
wizard = wizard_form.save()
action = wizard.action_add_contacts_and_send_mailing()
self.assertEqual(contacts.list_ids, mailing + mailing2)
self.assertEqual(action["type"], "ir.actions.client")
self.assertTrue(action.get("params", {}).get("next"), "Should return a notification with a next action")
subaction = action["params"]["next"]
self.assertEqual(subaction["type"], "ir.actions.act_window")
self.assertEqual(subaction["context"]["default_contact_list_ids"], [mailing2.id])
class TestMailingListMerge(MassMailCommon):
@classmethod
def setUpClass(cls):
super(TestMailingListMerge, cls).setUpClass()
cls._create_mailing_list()
cls.mailing_list_3 = cls.env['mailing.list'].with_context(cls._test_context).create({
'name': 'ListC',
'contact_ids': [
(0, 0, {'name': 'Norberto', 'email': 'norbert@example.com'}),
]
})
@users('user_marketing')
def test_mailing_contact_create(self):
default_list_ids = (self.mailing_list_2 | self.mailing_list_3).ids
# simply set default list in context
new = self.env['mailing.contact'].with_context(default_list_ids=default_list_ids).create([{
'name': 'Contact_%d' % x,
'email': 'contact_%d@test.example.com' % x,
} for x in range(0, 5)])
self.assertEqual(new.list_ids, (self.mailing_list_2 | self.mailing_list_3))
# default list and subscriptions should be merged
new = self.env['mailing.contact'].with_context(default_list_ids=default_list_ids).create([{
'name': 'Contact_%d' % x,
'email': 'contact_%d@test.example.com' % x,
'subscription_list_ids': [(0, 0, {
'list_id': self.mailing_list_1.id,
'opt_out': True,
}), (0, 0, {
'list_id': self.mailing_list_2.id,
'opt_out': True,
})],
} for x in range(0, 5)])
self.assertEqual(new.list_ids, (self.mailing_list_1 | self.mailing_list_2 | self.mailing_list_3))
# should correctly take subscription opt_out value
for list_id in (self.mailing_list_1 | self.mailing_list_2).ids:
new = new.with_context(default_list_ids=[list_id])
self.assertTrue(all(contact.opt_out for contact in new))
# not opt_out for new subscription without specific create values
for list_id in self.mailing_list_3.ids:
new = new.with_context(default_list_ids=[list_id])
self.assertFalse(any(contact.opt_out for contact in new))
with freeze_time('2022-01-01 12:00'):
contact_form = Form(self.env['mailing.contact'])
contact_form.name = 'Contact_test'
with contact_form.subscription_list_ids.new() as subscription:
subscription.list_id = self.mailing_list_1
subscription.opt_out = True
with contact_form.subscription_list_ids.new() as subscription:
subscription.list_id = self.mailing_list_2
subscription.opt_out = False
contact = contact_form.save()
self.assertEqual(contact.subscription_list_ids[0].unsubscription_date, datetime(2022, 1, 1, 12, 0, 0))
self.assertFalse(contact.subscription_list_ids[1].unsubscription_date)
@users('user_marketing')
def test_mailing_list_contact_copy_in_context_of_mailing_list(self):
MailingContact = self.env['mailing.contact']
contact_1 = MailingContact.create({
'name': 'Sam',
'email': 'gamgee@shire.com',
'subscription_list_ids': [(0, 0, {'list_id': self.mailing_list_3.id})],
})
# Copy the contact with default_list_ids in context, which should not raise anything
contact_2 = contact_1.with_context(default_list_ids=self.mailing_list_3.ids).copy()
self.assertEqual(contact_1.list_ids, contact_2.list_ids, 'Should copy the existing mailing list(s)')
@users('user_marketing')
def test_mailing_list_merge(self):
# TEST CASE: Merge A,B into the existing mailing list C
# The mailing list C contains the same email address than 'Norbert' in list B
# This test ensure that the mailing lists are correctly merged and no
# duplicates are appearing in C
merge_form = Form(self.env['mailing.list.merge'].with_context(
active_ids=[self.mailing_list_1.id, self.mailing_list_2.id],
active_model='mailing.list'
))
merge_form.new_list_name = False
merge_form.merge_options = 'existing'
# Need to set `merge_options` before `dest_lid_id` so `dest_list_id` is visible
# `'invisible': [('merge_options', '=', 'new')]`
merge_form.dest_list_id = self.mailing_list_3
merge_form.archive_src_lists = False
result_list = merge_form.save().action_mailing_lists_merge()
# Assert the number of contacts is correct
self.assertEqual(
len(result_list.contact_ids.ids), 5,
'The number of contacts on the mailing list C is not equal to 5')
# Assert there's no duplicated email address
self.assertEqual(
len(list(set(result_list.contact_ids.mapped('email')))), 5,
'Duplicates have been merged into the destination mailing list. Check %s' % (result_list.contact_ids.mapped('email')))
@users('user_marketing')
def test_mailing_list_merge_cornercase(self):
""" Check wrong use of merge wizard """
with self.assertRaises(exceptions.UserError):
merge_form = Form(self.env['mailing.list.merge'].with_context(
active_ids=[self.mailing_list_1.id, self.mailing_list_2.id],
))
merge_form = Form(self.env['mailing.list.merge'].with_context(
active_ids=[self.mailing_list_1.id],
active_model='mailing.list',
default_src_list_ids=[self.mailing_list_1.id, self.mailing_list_2.id],
default_dest_list_id=self.mailing_list_3.id,
default_merge_options='existing',
))
merge = merge_form.save()
self.assertEqual(merge.src_list_ids, self.mailing_list_1 + self.mailing_list_2)
self.assertEqual(merge.dest_list_id, self.mailing_list_3)
class TestMailingContactImport(MassMailCommon):
"""Test the transient <mailing.contact.import>."""
@users('user_marketing')
def test_mailing_contact_import(self):
first_list, second_list, third_list = self.env['mailing.list'].create([
{'name': 'First mailing list'},
{'name': 'Second mailing list'},
{'name': 'Third mailing list'},
])
self.env['mailing.contact'].create([
{
'name': 'Already Exists',
'email': 'already_exists_list_1@example.com',
'list_ids': first_list.ids,
}, {
'email': 'already_exists_list_2@example.com',
'list_ids': second_list.ids,
}, {
'email': 'already_exists_list_1_and_2@example.com',
'list_ids': (first_list | second_list).ids,
},
])
self.env['mailing.mailing'].create({
'name': 'Test',
'subject': 'Test',
'contact_list_ids': (first_list | second_list).ids,
})
contact_import = Form(self.env['mailing.contact.import'].with_context(
default_mailing_list_ids=first_list.ids,
))
contact_import.contact_list = '''
invalid line1
alice@example.com
bob@example.com
invalid line2
"Bob" <bob@EXAMPLE.com>
"Test" <bob@example.com>
invalid line3, with a comma
already_exists_list_1@example.com
already_exists_list_2@example.com
"Test" <already_exists_list_1_and_2@example.com>
invalid line4
'''
contact_import = contact_import.save()
self.assertEqual(contact_import.mailing_list_ids, first_list)
# Can not add many2many directly on a Form
contact_import.mailing_list_ids |= third_list
self.assertEqual(len(first_list.contact_ids), 2, 'Should not yet create the contact')
self.assertEqual(len(second_list.contact_ids), 2, 'Should not yet create the contact')
self.assertEqual(len(third_list.contact_ids), 0, 'Should not yet create the contact')
# Test that the context key "default_list_ids" is ignored (because we manually set list_ids)
contact_import.with_context(default_list_ids=(first_list | second_list).ids).action_import()
# Check the contact of the first mailing list
contacts = [
(contact.name, contact.email)
for contact in first_list.contact_ids
]
self.assertIn(('', 'alice@example.com'), contacts, 'Should have imported the right email address')
self.assertIn(('Bob', 'bob@example.com'), contacts, 'Should have imported the name of the contact')
self.assertIn(
('', 'already_exists_list_2@example.com'), contacts,
'The email already exists but in a different list. The contact must be imported.')
self.assertEqual(len(second_list.contact_ids), 2, 'Should have ignored default_list_ids')
self.assertNotIn(('Test', 'bob@example.com'), contacts, 'Should have ignored duplicated')
self.assertNotIn(('', 'bob@example.com'), contacts, 'Should have ignored duplicated')
self.assertNotIn(('Test', 'already_exists_list_1_and_2@example.com'), contacts, 'Should have ignored duplicated')
self.assertEqual(len(contacts), 5, 'Should have imported 2 new contacts')
# Check the contact of the third mailing list
contacts = [
(contact.name, contact.email)
for contact in third_list.contact_ids
]
self.assertIn(('', 'alice@example.com'), contacts, 'Should have imported the right email address')
self.assertIn(('Bob', 'bob@example.com'), contacts, 'Should have imported the name of the contact')
self.assertIn(
('', 'already_exists_list_2@example.com'), contacts,
'The email already exists but in a different list. The contact must be imported.')
self.assertIn(('Already Exists', 'already_exists_list_1@example.com'), contacts, 'This contact exists in the first mailing list, but not in the third mailing list')
self.assertNotIn(('Test', 'already_exists_list_1_and_2@example.com'), contacts, 'Should have ignored duplicated')
contact = self.env['mailing.contact'].search([('email', '=', 'already_exists_list_1@example.com')])
self.assertEqual(len(contact), 1, 'Should have updated the existing contact instead of creating a new one')

View file

@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from freezegun import freeze_time
from odoo.addons.base.tests.test_ir_cron import CronMixinCase
from odoo.addons.mass_mailing.tests.common import MassMailCommon
from odoo.tests import users, Form
from odoo.tools import mute_logger
class TestMailingScheduleDateWizard(MassMailCommon, CronMixinCase):
@mute_logger('odoo.addons.mail.models.mail_mail')
@users('user_marketing')
def test_mailing_next_departure(self):
# test if mailing.mailing.next_departure is correctly set taking into account
# presence of implicitly created cron triggers (since odoo v15). These should
# launch cron job before its schedule nextcall datetime (if scheduled_date < nextcall)
cron_job = self.env.ref('mass_mailing.ir_cron_mass_mailing_queue').sudo()
cron_job.write({'nextcall' : datetime(2023, 2, 18, 9, 0)})
cron_job_id = cron_job.id
# case where user click on "Send" button (action_launch)
with freeze_time(datetime(2023, 2, 17, 9, 0)):
with self.capture_triggers(cron_job_id) as capt:
mailing = self.env['mailing.mailing'].create({
'name': 'mailing',
'subject': 'some subject',
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
'state' : 'draft'
})
mailing.action_launch()
capt.records.ensure_one()
# assert that the schedule_date and schedule_type fields are correct and that the mailing is put in queue
self.assertEqual(mailing.next_departure, datetime(2023, 2, 17, 9, 0))
self.assertIsNot(mailing.schedule_date, cron_job.nextcall)
self.assertEqual(mailing.schedule_type, 'now')
self.assertEqual(mailing.state, 'in_queue')
self.assertEqual(capt.records.call_at, datetime(2023, 2, 17, 9, 0)) #verify that cron.trigger exists
# case where client uses schedule wizard to chose a date between now and cron.job nextcall
with freeze_time(datetime(2023, 2, 17, 9, 0)):
with self.capture_triggers(cron_job_id) as capt:
mailing = self.env['mailing.mailing'].create({
'name': 'mailing',
'subject': 'some subject',
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
'state' : 'draft',
'schedule_date' : datetime(2023, 2, 17, 11, 0),
'schedule_type' : 'scheduled'
})
mailing.action_schedule()
capt.records.ensure_one()
self.assertEqual(mailing.schedule_date, datetime(2023, 2, 17, 11, 0))
self.assertEqual(mailing.next_departure, datetime(2023, 2, 17, 11, 0))
self.assertEqual(mailing.schedule_type, 'scheduled')
self.assertEqual(mailing.state, 'in_queue')
self.assertEqual(capt.records.call_at, datetime(2023, 2, 17, 11, 0)) #verify that cron.trigger exists
# case where client uses schedule wizard to chose a date after cron.job nextcall
# which means mails will get send after that date (datetime(2023, 2, 18, 9, 0))
with freeze_time(datetime(2023, 2, 17, 9, 0)):
with self.capture_triggers(cron_job_id) as capt:
mailing = self.env['mailing.mailing'].create({
'name': 'mailing',
'subject': 'some subject',
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
'state' : 'draft',
'schedule_date' : datetime(2024, 2, 17, 11, 0),
'schedule_type' : 'scheduled'
})
mailing.action_schedule()
capt.records.ensure_one()
self.assertEqual(mailing.schedule_date, datetime(2024, 2, 17, 11, 0))
self.assertEqual(mailing.next_departure, datetime(2024, 2, 17, 11, 0))
self.assertEqual(mailing.schedule_type, 'scheduled')
self.assertEqual(mailing.state, 'in_queue')
self.assertEqual(capt.records.call_at, datetime(2024, 2, 17, 11, 0)) #verify that cron.trigger exists
# case where client uses schedule wizard to chose a date in the past
with freeze_time(datetime(2023, 2, 17, 9, 0)):
with self.capture_triggers(cron_job_id) as capt:
mailing = self.env['mailing.mailing'].create({
'name': 'mailing',
'subject': 'some subject',
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
'state' : 'draft',
'schedule_date' : datetime(2024, 2, 17, 11, 0),
'schedule_type' : 'scheduled'
})
# create a schedule date wizard
# Have to use wizard for this case to simulate schedule date in the past
# Otherwise "state" doesn't get update from draft to'in_queue'
# in test env vs production env (see mailing.mailing.schedule.date wizard)
wizard_form = Form(
self.env['mailing.mailing.schedule.date'].with_context(default_mass_mailing_id=mailing.id))
# set a schedule date
wizard_form.schedule_date = datetime(2022, 2, 17, 11, 0)
wizard = wizard_form.save()
wizard.action_schedule_date()
capt.records.ensure_one()
self.assertEqual(mailing.schedule_date, datetime(2022, 2, 17, 11, 0))
self.assertEqual(mailing.next_departure, datetime(2023, 2, 17, 9, 0)) #now
self.assertEqual(mailing.schedule_type, 'scheduled')
self.assertEqual(mailing.state, 'in_queue')
self.assertEqual(capt.records.call_at, datetime(2022, 2, 17, 11, 0)) #verify that cron.trigger exists
def test_mailing_schedule_date(self):
mailing = self.env['mailing.mailing'].create({
'name': 'mailing',
'subject': 'some subject',
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
})
# create a schedule date wizard
wizard_form = Form(
self.env['mailing.mailing.schedule.date'].with_context(default_mass_mailing_id=mailing.id))
# set a schedule date
wizard_form.schedule_date = datetime(2021, 4, 30, 9, 0)
wizard = wizard_form.save()
wizard.action_schedule_date()
# assert that the schedule_date and schedule_type fields are correct and that the mailing is put in queue
self.assertEqual(mailing.schedule_date, datetime(2021, 4, 30, 9, 0))
self.assertEqual(mailing.schedule_type, 'scheduled')
self.assertEqual(mailing.state, 'in_queue')

View file

@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mass_mailing.tests.common import MassMailCommon
from odoo.addons.base.tests.test_ir_cron import CronMixinCase
from odoo.tests.common import users
from unittest.mock import patch
class TestMailingRetry(MassMailCommon, CronMixinCase):
@classmethod
def setUpClass(cls):
super(TestMailingRetry, cls).setUpClass()
cls._create_mailing_list()
@users('user_marketing')
def test_mailing_retry_immediate_trigger(self):
mailing = self.env['mailing.mailing'].create({
'name': 'TestMailing',
'subject': 'Test',
'mailing_type': 'mail',
'body_html': '<div>Hello</div>',
'mailing_model_id': self.env['ir.model']._get('res.partner').id,
'contact_list_ids': [(4, self.mailing_list_1.id)],
})
mailing.action_launch()
# force email sending to fail to test our retry mechanism
def patched_mail_mail_send(mail_records, auto_commit=False, raise_exception=False, smtp_session=None):
mail_records.write({'state': 'exception', 'failure_reason': 'forced_failure'})
with patch('odoo.addons.mail.models.mail_mail.MailMail._send', patched_mail_mail_send):
self.env.ref('mass_mailing.ir_cron_mass_mailing_queue').sudo().method_direct_trigger()
with self.capture_triggers('mass_mailing.ir_cron_mass_mailing_queue') as captured_triggers:
mailing.action_retry_failed()
self.assertEqual(len(captured_triggers.records), 1, "Should have created an additional trigger immediately")
captured_trigger = captured_triggers.records[0]
self.assertEqual(captured_trigger.cron_id, self.env.ref('mass_mailing.ir_cron_mass_mailing_queue'))

View file

@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo.tests
from odoo.addons.base.tests.common import HttpCaseWithUserDemo
@odoo.tests.tagged('-at_install', 'post_install')
class TestUi(HttpCaseWithUserDemo):
def setUp(self):
super().setUp()
self.user_demo.groups_id |= self.env.ref('mass_mailing.group_mass_mailing_user')
self.user_demo.groups_id |= self.env.ref('mail.group_mail_template_editor')
self.user_demo.groups_id |= self.env.ref('mass_mailing.group_mass_mailing_campaign')
def test_01_mass_mailing_editor_tour(self):
self.start_tour("/web", 'mass_mailing_editor_tour', login="demo")
mail = self.env['mailing.mailing'].search([('subject', '=', 'Test')])[0]
# The tour created and saved an email. The edited version should be
# saved in body_arch, and its transpiled version (see convert_inline)
# for email client compatibility should be saved in body_html. This
# ensures both fields have different values (the mailing body should
# have been converted to a table in body_html).
self.assertIn('data-snippet="s_title"', mail.body_arch)
self.assertTrue(mail.body_arch.startswith('<div'))
self.assertIn('data-snippet="s_title"', mail.body_html)
self.assertTrue(mail.body_html.startswith('<table'))
def test_02_mass_mailing_snippets_menu_tabs(self):
self.start_tour("/web", 'mass_mailing_snippets_menu_tabs', login="demo")
def test_03_mass_mailing_snippets_toolbar_mobile_hide(self):
self.start_tour("/web", 'mass_mailing_snippets_menu_toolbar_new_mailing_mobile', login="demo")
def test_04_mass_mailing_snippets_menu_hide(self):
self.start_tour("/web", 'mass_mailing_snippets_menu_toolbar', login="demo")
def test_05_mass_mailing_basic_theme_toolbar(self):
self.start_tour('/web', 'mass_mailing_basic_theme_toolbar', login="demo")
def test_06_mass_mailing_campaign_new_mailing(self):
self.env.ref('base.group_user').write({'implied_ids': [(4, self.env.ref('mass_mailing.group_mass_mailing_campaign').id)]})
campaign = self.env['utm.campaign'].create({
'name': 'Test Newsletter',
'user_id': self.env.ref("base.user_admin").id,
'tag_ids': [(4, self.env.ref('utm.utm_tag_1').id)],
})
self.env['mailing.mailing'].create({
'name': 'First Mailing to disply x2many',
'subject': 'Bioutifoul mailing',
'state': 'draft',
'campaign_id': campaign.id,
})
self.env['mailing.list'].create({
'name': 'Test Newsletter',
})
self.start_tour("/web", 'mass_mailing_campaing_new_mailing', login="demo")
def test_07_mass_mailing_code_view_tour(self):
self.start_tour("/web?debug=tests", 'mass_mailing_code_view_tour', login="demo")

View file

@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mass_mailing.tests.common import MassMailCommon
from odoo.addons.utm.tests.common import TestUTMCommon
from odoo.exceptions import UserError
from odoo.tests.common import tagged, users
@tagged('post_install', '-at_install', 'utm_consistency')
class TestUTMConsistencyMassMailing(TestUTMCommon, MassMailCommon):
@classmethod
def setUpClass(cls):
super(TestUTMConsistencyMassMailing, cls).setUpClass()
cls._create_mailing_list()
@users('__system__')
def test_utm_consistency(self):
mailing = self.env['mailing.mailing'].create({
'subject': 'Newsletter',
'mailing_model_id': self.env['ir.model']._get('res.partner').id
})
# the source is automatically created when creating a mailing
utm_source = mailing.source_id
with self.assertRaises(UserError):
# can't unlink the source as it's used by a mailing.mailing as its source
# unlinking the source would break all the mailing statistics
utm_source.unlink()
# the medium "Email" (from module XML data) is automatically assigned
# when creating a mailing
utm_medium = mailing.medium_id
with self.assertRaises(UserError):
# can't unlink the medium as it's used by a mailing.mailing as its medium
# unlinking the medium would break all the mailing statistics
utm_medium.unlink()
@users('user_marketing')
def test_utm_consistency_mass_mailing_user(self):
# mass mailing user should be able to unlink all UTM models
self.utm_campaign.unlink()
self.utm_medium.unlink()
self.utm_source.unlink()