19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:39 +01:00
parent 5df8c07b59
commit daa394e8b0
2114 changed files with 564841 additions and 299642 deletions

View file

@ -1,7 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from markupsafe import Markup
from odoo import _, fields, models
from odoo.tools.misc import file_open
class MailComposeMessage(models.TransientModel):
@ -12,77 +15,142 @@ class MailComposeMessage(models.TransientModel):
mass_mailing_name = fields.Char(string='Mass Mailing Name', help='If set, a mass mailing will be created so that you can track its results in the Email Marketing app.')
mailing_list_ids = fields.Many2many('mailing.list', string='Mailing List')
def get_mail_values(self, res_ids):
""" Override method that generated the mail content by creating the
mailing.trace values in the o2m of mail_mail, when doing pure
email mass mailing. """
now = fields.Datetime.now()
self.ensure_one()
res = super(MailComposeMessage, self).get_mail_values(res_ids)
# use only for allowed models in mass mailing
def _action_send_mail(self, auto_commit=False):
""" Override to generate the mass mailing in case only the name was
given. It is used afterwards for traces generation. """
if self.composition_mode == 'mass_mail' and \
(self.mass_mailing_name or self.mass_mailing_id) and \
self.env['ir.model'].sudo().search_count([('model', '=', self.model), ('is_mail_thread', '=', True)]):
mass_mailing = self.mass_mailing_id
if not mass_mailing:
mass_mailing = self.env['mailing.mailing'].create({
'campaign_id': self.campaign_id.id,
'name': self.mass_mailing_name,
'subject': self.subject,
'state': 'done',
'reply_to_mode': self.reply_to_mode,
'reply_to': self.reply_to if self.reply_to_mode == 'new' else False,
'sent_date': now,
'body_html': self.body,
'mailing_model_id': self.env['ir.model']._get(self.model).id,
'mailing_domain': self.active_domain,
'attachment_ids': [(6, 0, self.attachment_ids.ids)],
})
self.mass_mailing_id = mass_mailing.id
self.mass_mailing_name and not self.mass_mailing_id and \
self.model_is_thread:
mass_mailing = self.env['mailing.mailing'].create(self._prepare_mailing_values())
self.mass_mailing_id = mass_mailing.id
return super()._action_send_mail(auto_commit=auto_commit)
recipients_info = self._process_recipient_values(res)
for res_id in res_ids:
mail_values = res[res_id]
if mail_values.get('body_html'):
body = self.env['ir.qweb']._render('mass_mailing.mass_mailing_mail_layout',
{'body': mail_values['body_html']},
minimal_qcontext=True, raise_if_not_found=False)
if body:
mail_values['body_html'] = body
def _invalid_email_state(self):
"""Always cancel invalid emails for mailings due to likely untractable number of failures."""
if self.mass_mailing_name or self.mass_mailing_id:
return 'cancel'
return super()._invalid_email_state()
trace_vals = {
'message_id': mail_values['message_id'],
'model': self.model,
'res_id': res_id,
'mass_mailing_id': mass_mailing.id,
# if mail_to is void, keep falsy values to allow searching / debugging traces
'email': recipients_info[res_id]['mail_to'][0] if recipients_info[res_id]['mail_to'] else '',
}
# propagate failed states to trace when still-born
if mail_values.get('state') == 'cancel':
trace_vals['trace_status'] = 'cancel'
elif mail_values.get('state') == 'exception':
trace_vals['trace_status'] = 'error'
if mail_values.get('failure_type'):
trace_vals['failure_type'] = mail_values['failure_type']
def _generate_mail_notification_values(self, mails):
"""Prevent notification creation as traces are generated."""
if self.mass_mailing_name or self.mass_mailing_id:
return []
return super()._generate_mail_notification_values(mails)
mail_values.update({
'mailing_id': mass_mailing.id,
'mailing_trace_ids': [(0, 0, trace_vals)],
# email-mode: keep original message for routing
'is_notification': mass_mailing.reply_to_mode == 'update',
'auto_delete': not mass_mailing.keep_archives,
})
return res
def _prepare_mail_values(self, res_ids):
# When being in mass mailing mode, add 'mailing.trace' values directly in the o2m field of mail.mail.
mail_values_all = super()._prepare_mail_values(res_ids)
if not self._is_mass_mailing():
return mail_values_all
trace_values_all = self._prepare_mail_values_mailing_traces(mail_values_all)
with file_open("mass_mailing/static/src/scss/mass_mailing_mail.scss", "r") as fd:
styles = fd.read()
for res_id, mail_values in mail_values_all.items():
if mail_values.get('body_html'):
body = self.env['ir.qweb']._render(
'mass_mailing.mass_mailing_mail_layout',
{'body': mail_values['body_html'], 'mailing_style': Markup(f'<style>{styles}</style>')},
minimal_qcontext=True,
raise_if_not_found=False
)
if body:
mail_values['body_html'] = body
if mail_values.get('body'):
mail_values['body'] = Markup(
'<div><span>{mailing_sent_message}</span></div>'
'<blockquote class="border-start" data-o-mail-quote="1" data-o-mail-quote-node="1">'
'{original_body}'
'</blockquote>'
).format(
mailing_sent_message=Markup(_(
'Received the mailing <b>{mailing_name}</b>',
)).format(
mailing_name=self.mass_mailing_name or self.mass_mailing_id.display_name
),
original_body=mail_values['body'],
)
mail_values.update({
'mailing_id': self.mass_mailing_id.id,
'mailing_trace_ids': [(0, 0, trace_values_all[res_id])] if res_id in trace_values_all else False,
})
return mail_values_all
def _get_done_emails(self, mail_values_dict):
seen_list = super(MailComposeMessage, self)._get_done_emails(mail_values_dict)
seen_list = super()._get_done_emails(mail_values_dict)
if self.mass_mailing_id:
seen_list += self.mass_mailing_id._get_seen_list()
return seen_list
def _get_optout_emails(self, mail_values_dict):
opt_out_list = super(MailComposeMessage, self)._get_optout_emails(mail_values_dict)
opt_out_list = super()._get_optout_emails(mail_values_dict)
if self.mass_mailing_id:
opt_out_list += self.mass_mailing_id._get_opt_out_list()
return opt_out_list
def _prepare_mail_values_mailing_traces(self, mail_values_all):
trace_values_all = dict.fromkeys(mail_values_all.keys(), False)
recipients_info = self._get_recipients_data(mail_values_all)
for res_id, mail_values in mail_values_all.items():
emails = recipients_info[res_id]['mail_to_normalized']
# if mail_to is void, keep falsy values to allow searching / debugging traces
if not emails:
emails = recipients_info[res_id]['mail_to']
email = emails[0] if emails else ''
trace_vals = {
'email': email,
'mass_mailing_id': self.mass_mailing_id.id,
'message_id': mail_values['message_id'],
'model': self.model,
'res_id': res_id,
}
# propagate failed states to trace when still-born
if mail_values.get('state') == 'cancel':
trace_vals['trace_status'] = 'cancel'
elif mail_values.get('state') == 'exception':
trace_vals['trace_status'] = 'error'
if mail_values.get('failure_type'):
trace_vals['failure_type'] = mail_values['failure_type']
trace_values_all[res_id] = trace_vals
return trace_values_all
def _prepare_mailing_values(self):
now = fields.Datetime.now()
return {
'attachment_ids': [(6, 0, self.attachment_ids.ids)],
'body_html': self.body,
'campaign_id': self.campaign_id.id,
'mailing_model_id': self.env['ir.model']._get(self.model).id,
'mailing_domain': self.res_domain if self.res_domain else f"[('id', 'in', {self.res_ids})]",
'name': self.mass_mailing_name,
'reply_to': self.reply_to if self.reply_to_mode == 'new' else False,
'reply_to_mode': self.reply_to_mode,
'sent_date': now,
'state': 'done',
'subject': self.subject,
'use_exclusion_list': self.use_exclusion_list,
}
def _manage_mail_values(self, mail_values_all):
# Filter out canceled messages of mass mailing and create traces for canceled ones.
results = super()._manage_mail_values(mail_values_all)
if not self._is_mass_mailing():
return results
self.env['mailing.trace'].sudo().create([
trace_commands[0][2]
for mail_values in results.values()
if (mail_values.get('state') == 'cancel' and (trace_commands := mail_values['mailing_trace_ids'])
# Ensure it is a create command
and len(trace_commands) == 1 and len(trace_commands[0]) == 3 and trace_commands[0][0] == 0)
])
return {
res_id: mail_values
for res_id, mail_values in results.items()
if mail_values.get('state') != 'cancel'
}
def _is_mass_mailing(self):
# allowed models in mass mailing
return self.composition_mode == 'mass_mail' and self.mass_mailing_id and self.model_is_thread

View file

@ -7,39 +7,23 @@
<field name="model">mail.compose.message</field>
<field name="inherit_id" ref="mail.email_compose_message_wizard_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='notify']" position="after">
<xpath expr="//field[@name='auto_delete_keep_log']" position="after">
<field name="campaign_id" groups="mass_mailing.group_mass_mailing_campaign"
attrs="{'invisible': [('composition_mode', '!=', 'mass_mail')]}"/>
invisible="composition_mode != 'mass_mail' or not model_is_thread"/>
<field name="mass_mailing_name"
attrs="{'invisible': [('composition_mode', '!=', 'mass_mail')]}"/>
invisible="composition_mode != 'mass_mail' or not model_is_thread"/>
</xpath>
<xpath expr="//button[@name='action_send_mail'][not(hasclass('o_mail_send'))]" position="attributes">
<!-- 'Log' button -->
<attribute name="attrs">
{'invisible': [
'|',
('is_log', '=', False),
'&amp;',
('mass_mailing_name', '!=', ''),
('mass_mailing_name', '!=', False)
]}
</attribute>
<attribute name="invisible" add="mass_mailing_name != '' and mass_mailing_name" separator="or"/>
</xpath>
<xpath expr="//button[hasclass('o_mail_send')]" position="attributes">
<!-- 'Send' button -->
<attribute name="attrs">
{'invisible': [
'|',
('is_log', '=', True),
'&amp;',
('mass_mailing_name', '!=', ''),
('mass_mailing_name', '!=', False)
]}
</attribute>
<attribute name="invisible" add="mass_mailing_name != '' and mass_mailing_name" separator="or"/>
</xpath>
<xpath expr="//button[@name='action_send_mail']" position="after">
<button string="Send Mass Mailing" name="action_send_mail" type="object" class="btn-primary o_mail_send"
attrs="{'invisible': ['|', ('mass_mailing_name', '==', ''), ('mass_mailing_name', '==', False)]}" data-hotkey="q"/>
invisible="mass_mailing_name == '' or not mass_mailing_name" data-hotkey="q"/>
</xpath>
</field>
</record>

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, tools, _
from odoo import fields, models, tools, Command, _
from odoo.tools.misc import clean_context
@ -15,7 +15,7 @@ class MailingContactImport(models.TransientModel):
def action_import(self):
"""Import each lines of "contact_list" as a new contact."""
self.ensure_one()
contacts = tools.email_split_tuples(', '.join((self.contact_list or '').splitlines()))
contacts = tools.mail.email_split_tuples(', '.join((self.contact_list or '').splitlines()))
if not contacts:
return {
'type': 'ir.actions.client',
@ -63,7 +63,10 @@ class MailingContactImport(models.TransientModel):
if email not in existing_contacts:
unique_contacts[email] = {
'name': name,
'list_ids': self.mailing_list_ids.ids,
'subscription_ids': [
Command.create({'list_id': mailing_list_id.id})
for mailing_list_id in self.mailing_list_ids
],
}
if not unique_contacts:
@ -86,16 +89,20 @@ class MailingContactImport(models.TransientModel):
for email, values in unique_contacts.items()
])
ignored = len(contacts) - len(unique_contacts)
if ignored := len(contacts) - len(unique_contacts):
message = _(
"Contacts successfully imported. Number of contacts imported: %(imported_count)s. Number of duplicates ignored: %(duplicate_count)s",
imported_count=len(unique_contacts),
duplicate_count=ignored,
)
else:
message = _("Contacts successfully imported. Number of contacts imported: %(imported_count)s", imported_count=len(unique_contacts))
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': (
_('%i Contacts have been imported.', len(unique_contacts))
+ (_(' %i duplicates have been ignored.', ignored) if ignored else '')
),
'message': message,
'type': 'success',
'sticky': False,
'next': {
@ -120,6 +127,6 @@ class MailingContactImport(models.TransientModel):
'name': _('Import Mailing Contacts'),
'params': {
'context': self.env.context,
'model': 'mailing.contact',
'active_model': 'mailing.contact',
}
}

View file

@ -27,9 +27,9 @@
</p>
<footer>
<button string="Import" type="object" name="action_import"
class="btn-primary" data-hotkey="i"/>
class="btn-primary" data-hotkey="q"/>
<button string="Discard" class="btn-secondary"
special="cancel" data-hotkey="z"/>
special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>

View file

@ -3,8 +3,9 @@
from odoo import fields, models, _
class MailingContactToList(models.TransientModel):
_name = "mailing.contact.to.list"
_name = 'mailing.contact.to.list'
_description = "Add Contacts to Mailing List"
contact_ids = fields.Many2many('mailing.contact', string='Contacts')
@ -30,13 +31,15 @@ class MailingContactToList(models.TransientModel):
def _add_contacts_to_mailing_list(self, action):
self.ensure_one()
previous_count = len(self.mailing_list_id.contact_ids)
contacts_to_add = self.contact_ids.filtered(lambda c: c not in self.mailing_list_id.contact_ids)
self.mailing_list_id.write({
'contact_ids': [
(4, contact.id)
for contact in self.contact_ids
if contact not in self.mailing_list_id.contact_ids]
})
'subscription_ids': [
(0, 0, {
'contact_id': contact.id,
'list_id': self.mailing_list_id.id,
}) for contact in contacts_to_add
]
})
return {
'type': 'ir.actions.client',
@ -44,7 +47,7 @@ class MailingContactToList(models.TransientModel):
'params': {
'type': 'info',
'message': _("%s Mailing Contacts have been added. ",
len(self.mailing_list_id.contact_ids) - previous_count
len(contacts_to_add)
),
'sticky': False,
'next': action,

View file

@ -13,7 +13,7 @@
<footer>
<button string="Add" name="action_add_contacts" type="object" class="btn-primary" data-hotkey="q"/>
<button string="Add and Send Mailing" name="action_add_contacts_and_send_mailing" type="object" class="btn-primary" data-hotkey="w"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z" />
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="x" />
</footer>
</form>
</field>

View file

@ -5,13 +5,13 @@ from odoo import api, fields, models, _
from odoo.exceptions import UserError
class MassMailingListMerge(models.TransientModel):
class MailingListMerge(models.TransientModel):
_name = 'mailing.list.merge'
_description = 'Merge Mass Mailing List'
@api.model
def default_get(self, fields):
res = super(MassMailingListMerge, self).default_get(fields)
res = super().default_get(fields)
if not res.get('src_list_ids') and 'src_list_ids' in fields:
if self.env.context.get('active_model') != 'mailing.list':

View file

@ -8,19 +8,19 @@
<form string="Merge Mass Mailing List">
<group>
<field name="merge_options" widget="selection"/>
<field name="new_list_name" attrs="{'invisible': [('merge_options', '=', 'existing')], 'required': [('merge_options', '=', 'new')]}"/>
<field name="dest_list_id" attrs="{'invisible': [('merge_options', '=', 'new')], 'required': [('merge_options', '=', 'existing')]}"/>
<field name="new_list_name" invisible="merge_options == 'existing'" required="merge_options == 'new'"/>
<field name="dest_list_id" invisible="merge_options == 'new'" required="merge_options == 'existing'"/>
<field name="archive_src_lists"/>
</group>
<field name="src_list_ids">
<tree>
<list>
<field name="name"/>
<field name="contact_count" string="Number of Recipients"/>
</tree>
</list>
</field>
<footer>
<button name="action_mailing_lists_merge" type="object" string="Merge" class="btn-primary" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
@ -32,6 +32,6 @@
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="model_mailing_list"/>
<field name="binding_view_types">list</field>
<field name="binding_view_types">list,kanban</field>
</record>
</odoo>

View file

@ -5,7 +5,7 @@ from odoo import fields, models
class MailingMailingScheduleDate(models.TransientModel):
_name = "mailing.mailing.schedule.date"
_name = 'mailing.mailing.schedule.date'
_description = "schedule a mailing"
schedule_date = fields.Datetime(string='Scheduled for')

View file

@ -7,12 +7,12 @@
<form string="Take Future Schedule Date">
<group>
<group>
<field name="schedule_date" string="Send on" required="1"/>
<field name="schedule_date" string="Send on" placeholder="Choose a date" required="1"/>
</group>
</group>
<footer>
<button string="Schedule" name="action_schedule_date" type="object" class="btn-primary" data-hotkey="q"/>
<button string="Discard " class="btn-secondary" special="cancel" data-hotkey="z" />
<button string="Discard " class="btn-secondary" special="cancel" data-hotkey="x" />
</footer>
</form>
</field>
@ -21,7 +21,6 @@
<record id="mailing_mailing_schedule_date_action" model="ir.actions.act_window">
<field name="name">When do you want to send your mailing?</field>
<field name="res_model">mailing.mailing.schedule.date</field>
<field name="type">ir.actions.act_window</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>

View file

@ -4,14 +4,27 @@
from markupsafe import Markup
from odoo import _, fields, models, tools
from odoo.tools.misc import file_open
class TestMassMailing(models.TransientModel):
class MailingMailingTest(models.TransientModel):
_name = 'mailing.mailing.test'
_description = 'Sample Mail Wizard'
# allow mailing.mailing.test records to live for 10h (instead of 1h default)
# used for quality of life in combination with '_default_email_to'
_transient_max_hours = 10.0
def _default_email_to(self):
""" Fetch the last used 'email_to' to populate the email_to value, fallback to user email.
This enables a user to do quick successive tests without having to type it every time.
As this is a transient model, it will not always work, but is sufficient as just a default
value. """
return self.env['mailing.mailing.test'].search([
('create_uid', '=', self.env.uid),
], order='create_date desc', limit=1).email_to or self.env.user.email_formatted
email_to = fields.Text(string='Recipients', required=True,
help='Carriage-return-separated list of email addresses.', default=lambda self: self.env.user.email_formatted)
help='Carriage-return-separated list of email addresses.', default=_default_email_to)
mass_mailing_id = fields.Many2one('mailing.mailing', string='Mailing', required=True, ondelete='cascade')
def send_mail_test(self):
@ -37,33 +50,42 @@ class TestMassMailing(models.TransientModel):
# Downside: Qweb syntax is only tested when there is atleast one record of the mailing's model
if record:
# Returns a proper error if there is a syntax error with Qweb
body = mailing.with_context(preserve_comments=True)._render_field('body_html', record.ids, post_process=True)[record.id]
preview = mailing._render_field('preview', record.ids, post_process=True)[record.id]
# do not force lang, will simply use user context
body = mailing._render_field('body_html', record.ids, compute_lang=False, options={'preserve_comments': True})[record.id]
preview = mailing._render_field('preview', record.ids, compute_lang=False)[record.id]
full_body = mailing._prepend_preview(Markup(body), preview)
subject = mailing._render_field('subject', record.ids)[record.id]
subject = mailing._render_field('subject', record.ids, compute_lang=False)[record.id]
else:
full_body = mailing._prepend_preview(mailing.body_html, mailing.preview)
subject = mailing.subject
subject = _('[TEST] %(mailing_subject)s', mailing_subject=subject)
# Convert links in absolute URLs before the application of the shortener
full_body = self.env['mail.render.mixin']._replace_local_links(full_body)
with file_open("mass_mailing/static/src/scss/mass_mailing_mail.scss", "r") as fd:
styles = fd.read()
for valid_email in valid_emails:
mail_values = {
'email_from': mailing.email_from,
'reply_to': mailing.reply_to,
'email_to': valid_email,
'subject': subject,
'body_html': self.env['ir.qweb']._render('mass_mailing.mass_mailing_mail_layout', {'body': full_body}, minimal_qcontext=True),
'is_notification': True,
'body_html': self.env['ir.qweb']._render('mass_mailing.mass_mailing_mail_layout', {
'body': full_body,
'mailing_style': Markup(f'<style>{styles}</style>'),
}, minimal_qcontext=True),
'is_notification': False,
'mailing_id': mailing.id,
'attachment_ids': [(4, attachment.id) for attachment in mailing.attachment_ids],
'auto_delete': False, # they are manually deleted after notifying the document
'mail_server_id': mailing.mail_server_id.id,
'model': record._name,
'res_id': record.id,
}
mail = self.env['mail.mail'].sudo().create(mail_values)
mails_sudo |= mail
mails_sudo.send()
mails_sudo.with_context({'mailing_test_mail': True}).send()
notification_messages = []
if invalid_candidates:
@ -76,17 +98,16 @@ class TestMassMailing(models.TransientModel):
_('Test mailing successfully sent to %s', mail_sudo.email_to))
elif mail_sudo.state == 'exception':
notification_messages.append(
_('Test mailing could not be sent to %s:<br>%s',
mail_sudo.email_to,
mail_sudo.failure_reason)
_('Test mailing could not be sent to %s:', mail_sudo.email_to) +
(Markup("<br/>") + mail_sudo.failure_reason)
)
# manually delete the emails since we passed 'auto_delete: False'
mails_sudo.unlink()
if notification_messages:
self.mass_mailing_id._message_log(body='<ul>%s</ul>' % ''.join(
['<li>%s</li>' % notification_message for notification_message in notification_messages]
self.mass_mailing_id._message_log(body=Markup('<ul>%s</ul>') % Markup().join(
[Markup('<li>%s</li>') % notification_message for notification_message in notification_messages]
))
return True

View file

@ -13,8 +13,8 @@
<field name="email_to" placeholder="email1@example.com&#10;email2@example.com"/>
</group>
<footer>
<button string="Send" name="send_mail_test" type="object" class="btn-primary" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
<button string="Send test" name="send_mail_test" type="object" class="btn-primary" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>