summaryrefslogtreecommitdiff
path: root/addons/mail/wizard
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/wizard
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/mail/wizard')
-rw-r--r--addons/mail/wizard/__init__.py11
-rw-r--r--addons/mail/wizard/base_module_uninstall.py13
-rw-r--r--addons/mail/wizard/base_partner_merge.py12
-rw-r--r--addons/mail/wizard/invite.py97
-rw-r--r--addons/mail/wizard/invite_view.xml35
-rw-r--r--addons/mail/wizard/mail_blacklist_remove.py14
-rw-r--r--addons/mail/wizard/mail_blacklist_remove_view.xml19
-rw-r--r--addons/mail/wizard/mail_compose_message.py535
-rw-r--r--addons/mail/wizard/mail_compose_message_view.xml97
-rw-r--r--addons/mail/wizard/mail_resend_cancel.py37
-rw-r--r--addons/mail/wizard/mail_resend_cancel_views.xml27
-rw-r--r--addons/mail/wizard/mail_resend_message.py98
-rw-r--r--addons/mail/wizard/mail_resend_message_views.xml43
-rw-r--r--addons/mail/wizard/mail_template_preview.py86
-rw-r--r--addons/mail/wizard/mail_template_preview_views.xml61
15 files changed, 1185 insertions, 0 deletions
diff --git a/addons/mail/wizard/__init__.py b/addons/mail/wizard/__init__.py
new file mode 100644
index 00000000..9adf18a6
--- /dev/null
+++ b/addons/mail/wizard/__init__.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import invite
+from . import mail_blacklist_remove
+from . import mail_compose_message
+from . import mail_resend_cancel
+from . import mail_resend_message
+from . import mail_template_preview
+from . import base_module_uninstall
+from . import base_partner_merge
diff --git a/addons/mail/wizard/base_module_uninstall.py b/addons/mail/wizard/base_module_uninstall.py
new file mode 100644
index 00000000..43e029ba
--- /dev/null
+++ b/addons/mail/wizard/base_module_uninstall.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+
+class BaseModuleUninstall(models.TransientModel):
+ _inherit = "base.module.uninstall"
+
+ def _get_models(self):
+ # consider mail-thread models only
+ models = super(BaseModuleUninstall, self)._get_models()
+ return models.filtered('is_mail_thread')
diff --git a/addons/mail/wizard/base_partner_merge.py b/addons/mail/wizard/base_partner_merge.py
new file mode 100644
index 00000000..aed2cefd
--- /dev/null
+++ b/addons/mail/wizard/base_partner_merge.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo import api, models, _
+
+
+class MergePartnerAutomatic(models.TransientModel):
+
+ _inherit = 'base.partner.merge.automatic.wizard'
+
+ def _log_merge_operation(self, src_partners, dst_partner):
+ super(MergePartnerAutomatic, self)._log_merge_operation(src_partners, dst_partner)
+ dst_partner.message_post(body='%s %s' % (_("Merged with the following partners:"), ", ".join('%s <%s> (ID %s)' % (p.name, p.email or 'n/a', p.id) for p in src_partners)))
diff --git a/addons/mail/wizard/invite.py b/addons/mail/wizard/invite.py
new file mode 100644
index 00000000..8b6a2de8
--- /dev/null
+++ b/addons/mail/wizard/invite.py
@@ -0,0 +1,97 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from lxml import etree
+from lxml.html import builder as html
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+
+class Invite(models.TransientModel):
+ """ Wizard to invite partners (or channels) and make them followers. """
+ _name = 'mail.wizard.invite'
+ _description = 'Invite wizard'
+
+ @api.model
+ def default_get(self, fields):
+ result = super(Invite, self).default_get(fields)
+ if self._context.get('mail_invite_follower_channel_only'):
+ result['send_mail'] = False
+ if 'message' not in fields:
+ return result
+
+ user_name = self.env.user.display_name
+ model = result.get('res_model')
+ res_id = result.get('res_id')
+ if model and res_id:
+ document = self.env['ir.model']._get(model).display_name
+ title = self.env[model].browse(res_id).display_name
+ msg_fmt = _('%(user_name)s invited you to follow %(document)s document: %(title)s')
+ else:
+ msg_fmt = _('%(user_name)s invited you to follow a new document.')
+
+ text = msg_fmt % locals()
+ message = html.DIV(
+ html.P(_('Hello,')),
+ html.P(text)
+ )
+ result['message'] = etree.tostring(message)
+ return result
+
+ res_model = fields.Char('Related Document Model', required=True, index=True, help='Model of the followed resource')
+ res_id = fields.Integer('Related Document ID', index=True, help='Id of the followed resource')
+ partner_ids = fields.Many2many('res.partner', string='Recipients', help="List of partners that will be added as follower of the current document.",
+ domain=[('type', '!=', 'private')])
+ channel_ids = fields.Many2many('mail.channel', string='Channels', help='List of channels that will be added as listeners of the current document.',
+ domain=[('channel_type', '=', 'channel')])
+ message = fields.Html('Message')
+ send_mail = fields.Boolean('Send Email', default=True, help="If checked, the partners will receive an email warning they have been added in the document's followers.")
+
+ def add_followers(self):
+ if not self.env.user.email:
+ raise UserError(_("Unable to post message, please configure the sender's email address."))
+ email_from = self.env.user.email_formatted
+ for wizard in self:
+ Model = self.env[wizard.res_model]
+ document = Model.browse(wizard.res_id)
+
+ # filter partner_ids to get the new followers, to avoid sending email to already following partners
+ new_partners = wizard.partner_ids - document.sudo().message_partner_ids
+ new_channels = wizard.channel_ids - document.message_channel_ids
+ document.message_subscribe(new_partners.ids, new_channels.ids)
+
+ model_name = self.env['ir.model']._get(wizard.res_model).display_name
+ # send an email if option checked and if a message exists (do not send void emails)
+ if wizard.send_mail and wizard.message and not wizard.message == '<br>': # when deleting the message, cleditor keeps a <br>
+ message = self.env['mail.message'].create({
+ 'subject': _('Invitation to follow %(document_model)s: %(document_name)s', document_model=model_name, document_name=document.display_name),
+ 'body': wizard.message,
+ 'record_name': document.display_name,
+ 'email_from': email_from,
+ 'reply_to': email_from,
+ 'model': wizard.res_model,
+ 'res_id': wizard.res_id,
+ 'no_auto_thread': True,
+ 'add_sign': True,
+ })
+ partners_data = []
+ recipient_data = self.env['mail.followers']._get_recipient_data(document, 'comment', False, pids=new_partners.ids)
+ for pid, cid, active, pshare, ctype, notif, groups in recipient_data:
+ pdata = {'id': pid, 'share': pshare, 'active': active, 'notif': 'email', 'groups': groups or []}
+ if not pshare and notif: # has an user and is not shared, is therefore user
+ partners_data.append(dict(pdata, type='user'))
+ elif pshare and notif: # has an user and is shared, is therefore portal
+ partners_data.append(dict(pdata, type='portal'))
+ else: # has no user, is therefore customer
+ partners_data.append(dict(pdata, type='customer'))
+
+ document._notify_record_by_email(message, {'partners': partners_data, 'channels': []}, send_after_commit=False)
+ # in case of failure, the web client must know the message was
+ # deleted to discard the related failure notification
+ self.env['bus.bus'].sendone(
+ (self._cr.dbname, 'res.partner', self.env.user.partner_id.id),
+ {'type': 'deletion', 'message_ids': message.ids}
+ )
+ message.unlink()
+ return {'type': 'ir.actions.act_window_close'}
diff --git a/addons/mail/wizard/invite_view.xml b/addons/mail/wizard/invite_view.xml
new file mode 100644
index 00000000..aa8633d2
--- /dev/null
+++ b/addons/mail/wizard/invite_view.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data>
+
+ <!-- wizard view -->
+ <record model="ir.ui.view" id="mail_wizard_invite_form">
+ <field name="name">Add Followers</field>
+ <field name="model">mail.wizard.invite</field>
+ <field name="arch" type="xml">
+ <form string="Add Followers">
+ <group>
+ <field name="res_model" invisible="1"/>
+ <field name="res_id" invisible="1"/>
+ <field name="partner_ids" widget="many2many_tags_email"
+ placeholder="Add contacts to notify..."
+ context="{'force_email':True, 'show_email':True}"
+ invisible="context.get('mail_invite_follower_channel_only')"/>
+ <field name="channel_ids" widget="many2many_tags"
+ placeholder="Add channels to notify..."
+ invisible="not context.get('mail_invite_follower_channel_only')"
+ options="{'no_create': True}"/>
+ <field name="send_mail" invisible="context.get('mail_invite_follower_channel_only')"/>
+ <field name="message" attrs="{'invisible': [('send_mail','!=',True)]}" options="{'style-inline': true, 'no-attachment': true}" class="test_message"/>
+ </group>
+ <footer>
+ <button string="Add Followers"
+ name="add_followers" type="object" class="btn-primary"/>
+ <button string="Cancel" class="btn-secondary" special="cancel" />
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ </data>
+</odoo>
diff --git a/addons/mail/wizard/mail_blacklist_remove.py b/addons/mail/wizard/mail_blacklist_remove.py
new file mode 100644
index 00000000..2fc6374c
--- /dev/null
+++ b/addons/mail/wizard/mail_blacklist_remove.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+
+from odoo import fields, models
+
+
+class MailBlacklistRemove(models.TransientModel):
+ _name = 'mail.blacklist.remove'
+ _description = 'Remove email from blacklist wizard'
+
+ email = fields.Char(name="Email", readonly=True, required=True)
+ reason = fields.Char(name="Reason")
+
+ def action_unblacklist_apply(self):
+ return self.env['mail.blacklist'].action_remove_with_reason(self.email, self.reason)
diff --git a/addons/mail/wizard/mail_blacklist_remove_view.xml b/addons/mail/wizard/mail_blacklist_remove_view.xml
new file mode 100644
index 00000000..4bc8edd4
--- /dev/null
+++ b/addons/mail/wizard/mail_blacklist_remove_view.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0"?>
+<odoo>
+ <record id="mail_blacklist_remove_view_form" model="ir.ui.view">
+ <field name="name">mail.blacklist.remove.form</field>
+ <field name="model">mail.blacklist.remove</field>
+ <field name="arch" type="xml">
+ <form string="mail_blacklist_removal">
+ <group class="oe_title">
+ <field name="email" string="Email Address"/>
+ <field name="reason" string="Reason"/>
+ </group>
+ <footer>
+ <button name="action_unblacklist_apply" string="Confirm" type="object" class="btn-primary"/>
+ <button string="Discard" class="btn-secondary" special="cancel"/>
+ </footer>
+ </form>
+ </field>
+ </record>
+</odoo>
diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py
new file mode 100644
index 00000000..327cad2b
--- /dev/null
+++ b/addons/mail/wizard/mail_compose_message.py
@@ -0,0 +1,535 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import ast
+import base64
+import re
+
+from odoo import _, api, fields, models, tools
+from odoo.exceptions import UserError
+
+
+# main mako-like expression pattern
+EXPRESSION_PATTERN = re.compile('(\$\{.+?\})')
+
+
+def _reopen(self, res_id, model, context=None):
+ # save original model in context, because selecting the list of available
+ # templates requires a model in context
+ context = dict(context or {}, default_model=model)
+ return {'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_id': res_id,
+ 'res_model': self._name,
+ 'target': 'new',
+ 'context': context,
+ }
+
+
+class MailComposer(models.TransientModel):
+ """ Generic message composition wizard. You may inherit from this wizard
+ at model and view levels to provide specific features.
+
+ The behavior of the wizard depends on the composition_mode field:
+ - 'comment': post on a record. The wizard is pre-populated via ``get_record_data``
+ - 'mass_mail': wizard in mass mailing mode where the mail details can
+ contain template placeholders that will be merged with actual data
+ before being sent to each recipient.
+ """
+ _name = 'mail.compose.message'
+ _description = 'Email composition wizard'
+ _log_access = True
+ _batch_size = 500
+
+ @api.model
+ def default_get(self, fields):
+ """ Handle composition mode. Some details about context keys:
+ - comment: default mode, model and ID of a record the user comments
+ - default_model or active_model
+ - default_res_id or active_id
+ - mass_mail: model and IDs of records the user mass-mails
+ - active_ids: record IDs
+ - default_model or active_model
+ """
+ result = super(MailComposer, self).default_get(fields)
+
+ # author
+ missing_author = 'author_id' in fields and 'author_id' not in result
+ missing_email_from = 'email_from' in fields and 'email_from' not in result
+ if missing_author or missing_email_from:
+ author_id, email_from = self.env['mail.thread']._message_compute_author(result.get('author_id'), result.get('email_from'), raise_exception=False)
+ if missing_email_from:
+ result['email_from'] = email_from
+ if missing_author:
+ result['author_id'] = author_id
+
+ if 'model' in fields and 'model' not in result:
+ result['model'] = self._context.get('active_model')
+ if 'res_id' in fields and 'res_id' not in result:
+ result['res_id'] = self._context.get('active_id')
+ if 'no_auto_thread' in fields and 'no_auto_thread' not in result and result.get('model'):
+ # doesn't support threading
+ if result['model'] not in self.env or not hasattr(self.env[result['model']], 'message_post'):
+ result['no_auto_thread'] = True
+
+ if 'active_domain' in self._context: # not context.get() because we want to keep global [] domains
+ result['active_domain'] = '%s' % self._context.get('active_domain')
+ if result.get('composition_mode') == 'comment' and (set(fields) & set(['model', 'res_id', 'partner_ids', 'record_name', 'subject'])):
+ result.update(self.get_record_data(result))
+
+ filtered_result = dict((fname, result[fname]) for fname in result if fname in fields)
+ return filtered_result
+
+ # content
+ subject = fields.Char('Subject')
+ body = fields.Html('Contents', default='', sanitize_style=True)
+ parent_id = fields.Many2one(
+ 'mail.message', 'Parent Message', index=True, ondelete='set null',
+ help="Initial thread message.")
+ template_id = fields.Many2one(
+ 'mail.template', 'Use template', index=True,
+ domain="[('model', '=', model)]")
+ attachment_ids = fields.Many2many(
+ 'ir.attachment', 'mail_compose_message_ir_attachments_rel',
+ 'wizard_id', 'attachment_id', 'Attachments')
+ layout = fields.Char('Layout', copy=False) # xml id of layout
+ add_sign = fields.Boolean(default=True)
+ # origin
+ email_from = fields.Char('From', help="Email address of the sender. This field is set when no matching partner is found and replaces the author_id field in the chatter.")
+ author_id = fields.Many2one(
+ 'res.partner', 'Author', index=True,
+ help="Author of the message. If not set, email_from may hold an email address that did not match any partner.")
+ # composition
+ composition_mode = fields.Selection(selection=[
+ ('comment', 'Post on a document'),
+ ('mass_mail', 'Email Mass Mailing'),
+ ('mass_post', 'Post on Multiple Documents')], string='Composition mode', default='comment')
+ model = fields.Char('Related Document Model', index=True)
+ res_id = fields.Integer('Related Document ID', index=True)
+ record_name = fields.Char('Message Record Name', help="Name get of the related document.")
+ use_active_domain = fields.Boolean('Use active domain')
+ active_domain = fields.Text('Active domain', readonly=True)
+ # characteristics
+ message_type = fields.Selection([
+ ('comment', 'Comment'),
+ ('notification', 'System notification')],
+ 'Type', required=True, default='comment',
+ help="Message type: email for email message, notification for system "
+ "message, comment for other messages such as user replies")
+ subtype_id = fields.Many2one(
+ 'mail.message.subtype', 'Subtype', ondelete='set null', index=True,
+ default=lambda self: self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment'))
+ mail_activity_type_id = fields.Many2one(
+ 'mail.activity.type', 'Mail Activity Type',
+ index=True, ondelete='set null')
+ # destination
+ reply_to = fields.Char('Reply-To', help='Reply email address. Setting the reply_to bypasses the automatic thread creation.')
+ no_auto_thread = fields.Boolean(
+ 'No threading for answers',
+ help='Answers do not go in the original document discussion thread. This has an impact on the generated message-id.')
+ is_log = fields.Boolean('Log an Internal Note',
+ help='Whether the message is an internal note (comment mode only)')
+ partner_ids = fields.Many2many(
+ 'res.partner', 'mail_compose_message_res_partner_rel',
+ 'wizard_id', 'partner_id', 'Additional Contacts',
+ domain=[('type', '!=', 'private')])
+ # mass mode options
+ notify = fields.Boolean('Notify followers', help='Notify followers of the document (mass post only)')
+ auto_delete = fields.Boolean('Delete Emails',
+ help='This option permanently removes any track of email after it\'s been sent, including from the Technical menu in the Settings, in order to preserve storage space of your Odoo database.')
+ auto_delete_message = fields.Boolean('Delete Message Copy', help='Do not keep a copy of the email in the document communication history (mass mailing only)')
+ mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing mail server')
+
+ @api.model
+ def get_record_data(self, values):
+ """ Returns a defaults-like dict with initial values for the composition
+ wizard when sending an email related a previous email (parent_id) or
+ a document (model, res_id). This is based on previously computed default
+ values. """
+ result, subject = {}, False
+ if values.get('parent_id'):
+ parent = self.env['mail.message'].browse(values.get('parent_id'))
+ result['record_name'] = parent.record_name,
+ subject = tools.ustr(parent.subject or parent.record_name or '')
+ if not values.get('model'):
+ result['model'] = parent.model
+ if not values.get('res_id'):
+ result['res_id'] = parent.res_id
+ partner_ids = values.get('partner_ids', list()) + parent.partner_ids.ids
+ result['partner_ids'] = partner_ids
+ elif values.get('model') and values.get('res_id'):
+ doc_name_get = self.env[values.get('model')].browse(values.get('res_id')).name_get()
+ result['record_name'] = doc_name_get and doc_name_get[0][1] or ''
+ subject = tools.ustr(result['record_name'])
+
+ re_prefix = _('Re:')
+ if subject and not (subject.startswith('Re:') or subject.startswith(re_prefix)):
+ subject = "%s %s" % (re_prefix, subject)
+ result['subject'] = subject
+
+ return result
+
+ # ------------------------------------------------------------
+ # ACTIONS
+ # ------------------------------------------------------------
+ # action buttons call with positionnal arguments only, so we need an intermediary function
+ # to ensure the context is passed correctly
+ def action_send_mail(self):
+ self.send_mail()
+ return {'type': 'ir.actions.act_window_close'}
+
+ def send_mail(self, auto_commit=False):
+ """ Process the wizard content and proceed with sending the related
+ email(s), rendering any template patterns on the fly if needed. """
+ notif_layout = self._context.get('custom_layout')
+ # Several custom layouts make use of the model description at rendering, e.g. in the
+ # 'View <document>' button. Some models are used for different business concepts, such as
+ # 'purchase.order' which is used for a RFQ and and PO. To avoid confusion, we must use a
+ # different wording depending on the state of the object.
+ # Therefore, we can set the description in the context from the beginning to avoid falling
+ # back on the regular display_name retrieved in '_notify_prepare_template_context'.
+ model_description = self._context.get('model_description')
+ for wizard in self:
+ # Duplicate attachments linked to the email.template.
+ # Indeed, basic mail.compose.message wizard duplicates attachments in mass
+ # mailing mode. But in 'single post' mode, attachments of an email template
+ # also have to be duplicated to avoid changing their ownership.
+ if wizard.attachment_ids and wizard.composition_mode != 'mass_mail' and wizard.template_id:
+ new_attachment_ids = []
+ for attachment in wizard.attachment_ids:
+ if attachment in wizard.template_id.attachment_ids:
+ new_attachment_ids.append(attachment.copy({'res_model': 'mail.compose.message', 'res_id': wizard.id}).id)
+ else:
+ new_attachment_ids.append(attachment.id)
+ new_attachment_ids.reverse()
+ wizard.write({'attachment_ids': [(6, 0, new_attachment_ids)]})
+
+ # Mass Mailing
+ mass_mode = wizard.composition_mode in ('mass_mail', 'mass_post')
+
+ ActiveModel = self.env[wizard.model] if wizard.model and hasattr(self.env[wizard.model], 'message_post') else self.env['mail.thread']
+ if wizard.composition_mode == 'mass_post':
+ # do not send emails directly but use the queue instead
+ # add context key to avoid subscribing the author
+ ActiveModel = ActiveModel.with_context(mail_notify_force_send=False, mail_create_nosubscribe=True)
+ # wizard works in batch mode: [res_id] or active_ids or active_domain
+ if mass_mode and wizard.use_active_domain and wizard.model:
+ res_ids = self.env[wizard.model].search(ast.literal_eval(wizard.active_domain)).ids
+ elif mass_mode and wizard.model and self._context.get('active_ids'):
+ res_ids = self._context['active_ids']
+ else:
+ res_ids = [wizard.res_id]
+
+ batch_size = int(self.env['ir.config_parameter'].sudo().get_param('mail.batch_size')) or self._batch_size
+ sliced_res_ids = [res_ids[i:i + batch_size] for i in range(0, len(res_ids), batch_size)]
+
+ if wizard.composition_mode == 'mass_mail' or wizard.is_log or (wizard.composition_mode == 'mass_post' and not wizard.notify): # log a note: subtype is False
+ subtype_id = False
+ elif wizard.subtype_id:
+ subtype_id = wizard.subtype_id.id
+ else:
+ subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment')
+
+ for res_ids in sliced_res_ids:
+ # mass mail mode: mail are sudo-ed, as when going through get_mail_values
+ # standard access rights on related records will be checked when browsing them
+ # to compute mail values. If people have access to the records they have rights
+ # to create lots of emails in sudo as it is consdiered as a technical model.
+ batch_mails_sudo = self.env['mail.mail'].sudo()
+ all_mail_values = wizard.get_mail_values(res_ids)
+ for res_id, mail_values in all_mail_values.items():
+ if wizard.composition_mode == 'mass_mail':
+ batch_mails_sudo |= self.env['mail.mail'].sudo().create(mail_values)
+ else:
+ post_params = dict(
+ message_type=wizard.message_type,
+ subtype_id=subtype_id,
+ email_layout_xmlid=notif_layout,
+ add_sign=not bool(wizard.template_id),
+ mail_auto_delete=wizard.template_id.auto_delete if wizard.template_id else self._context.get('mail_auto_delete', True),
+ model_description=model_description)
+ post_params.update(mail_values)
+ if ActiveModel._name == 'mail.thread':
+ if wizard.model:
+ post_params['model'] = wizard.model
+ post_params['res_id'] = res_id
+ if not ActiveModel.message_notify(**post_params):
+ # if message_notify returns an empty record set, no recipients where found.
+ raise UserError(_("No recipient found."))
+ else:
+ ActiveModel.browse(res_id).message_post(**post_params)
+
+ if wizard.composition_mode == 'mass_mail':
+ batch_mails_sudo.send(auto_commit=auto_commit)
+
+ def get_mail_values(self, res_ids):
+ """Generate the values that will be used by send_mail to create mail_messages
+ or mail_mails. """
+ self.ensure_one()
+ results = dict.fromkeys(res_ids, False)
+ rendered_values = {}
+ mass_mail_mode = self.composition_mode == 'mass_mail'
+
+ # render all template-based value at once
+ if mass_mail_mode and self.model:
+ rendered_values = self.render_message(res_ids)
+ # compute alias-based reply-to in batch
+ reply_to_value = dict.fromkeys(res_ids, None)
+ if mass_mail_mode and not self.no_auto_thread:
+ records = self.env[self.model].browse(res_ids)
+ reply_to_value = records._notify_get_reply_to(default=self.email_from)
+
+ blacklisted_rec_ids = set()
+ if mass_mail_mode and issubclass(type(self.env[self.model]), self.pool['mail.thread.blacklist']):
+ self.env['mail.blacklist'].flush(['email'])
+ self._cr.execute("SELECT email FROM mail_blacklist WHERE active=true")
+ blacklist = {x[0] for x in self._cr.fetchall()}
+ if blacklist:
+ targets = self.env[self.model].browse(res_ids).read(['email_normalized'])
+ # First extract email from recipient before comparing with blacklist
+ blacklisted_rec_ids.update(target['id'] for target in targets
+ if target['email_normalized'] in blacklist)
+
+ for res_id in res_ids:
+ # static wizard (mail.message) values
+ mail_values = {
+ 'subject': self.subject,
+ 'body': self.body or '',
+ 'parent_id': self.parent_id and self.parent_id.id,
+ 'partner_ids': [partner.id for partner in self.partner_ids],
+ 'attachment_ids': [attach.id for attach in self.attachment_ids],
+ 'author_id': self.author_id.id,
+ 'email_from': self.email_from,
+ 'record_name': self.record_name,
+ 'no_auto_thread': self.no_auto_thread,
+ 'mail_server_id': self.mail_server_id.id,
+ 'mail_activity_type_id': self.mail_activity_type_id.id,
+ }
+
+ # mass mailing: rendering override wizard static values
+ if mass_mail_mode and self.model:
+ record = self.env[self.model].browse(res_id)
+ mail_values['headers'] = record._notify_email_headers()
+ # keep a copy unless specifically requested, reset record name (avoid browsing records)
+ mail_values.update(notification=not self.auto_delete_message, model=self.model, res_id=res_id, record_name=False)
+ # auto deletion of mail_mail
+ if self.auto_delete or self.template_id.auto_delete:
+ mail_values['auto_delete'] = True
+ # rendered values using template
+ email_dict = rendered_values[res_id]
+ mail_values['partner_ids'] += email_dict.pop('partner_ids', [])
+ mail_values.update(email_dict)
+ if not self.no_auto_thread:
+ mail_values.pop('reply_to')
+ if reply_to_value.get(res_id):
+ mail_values['reply_to'] = reply_to_value[res_id]
+ if self.no_auto_thread and not mail_values.get('reply_to'):
+ mail_values['reply_to'] = mail_values['email_from']
+ # mail_mail values: body -> body_html, partner_ids -> recipient_ids
+ mail_values['body_html'] = mail_values.get('body', '')
+ mail_values['recipient_ids'] = [(4, id) for id in mail_values.pop('partner_ids', [])]
+
+ # process attachments: should not be encoded before being processed by message_post / mail_mail create
+ mail_values['attachments'] = [(name, base64.b64decode(enc_cont)) for name, enc_cont in email_dict.pop('attachments', list())]
+ attachment_ids = []
+ for attach_id in mail_values.pop('attachment_ids'):
+ new_attach_id = self.env['ir.attachment'].browse(attach_id).copy({'res_model': self._name, 'res_id': self.id})
+ attachment_ids.append(new_attach_id.id)
+ attachment_ids.reverse()
+ mail_values['attachment_ids'] = self.env['mail.thread'].with_context(attached_to=record)._message_post_process_attachments(
+ mail_values.pop('attachments', []),
+ attachment_ids,
+ {'model': 'mail.message', 'res_id': 0}
+ )['attachment_ids']
+ # Filter out the blacklisted records by setting the mail state to cancel -> Used for Mass Mailing stats
+ if res_id in blacklisted_rec_ids:
+ mail_values['state'] = 'cancel'
+ # Do not post the mail into the recipient's chatter
+ mail_values['notification'] = False
+
+ results[res_id] = mail_values
+ return results
+
+ # ------------------------------------------------------------
+ # TEMPLATES
+ # ------------------------------------------------------------
+
+ @api.onchange('template_id')
+ def onchange_template_id_wrapper(self):
+ self.ensure_one()
+ values = self.onchange_template_id(self.template_id.id, self.composition_mode, self.model, self.res_id)['value']
+ for fname, value in values.items():
+ setattr(self, fname, value)
+
+ def onchange_template_id(self, template_id, composition_mode, model, res_id):
+ """ - mass_mailing: we cannot render, so return the template values
+ - normal mode: return rendered values
+ /!\ for x2many field, this onchange return command instead of ids
+ """
+ if template_id and composition_mode == 'mass_mail':
+ template = self.env['mail.template'].browse(template_id)
+ fields = ['subject', 'body_html', 'email_from', 'reply_to', 'mail_server_id']
+ values = dict((field, getattr(template, field)) for field in fields if getattr(template, field))
+ if template.attachment_ids:
+ values['attachment_ids'] = [att.id for att in template.attachment_ids]
+ if template.mail_server_id:
+ values['mail_server_id'] = template.mail_server_id.id
+ elif template_id:
+ values = self.generate_email_for_composer(
+ template_id, [res_id],
+ ['subject', 'body_html', 'email_from', 'email_to', 'partner_to', 'email_cc', 'reply_to', 'attachment_ids', 'mail_server_id']
+ )[res_id]
+ # transform attachments into attachment_ids; not attached to the document because this will
+ # be done further in the posting process, allowing to clean database if email not send
+ attachment_ids = []
+ Attachment = self.env['ir.attachment']
+ for attach_fname, attach_datas in values.pop('attachments', []):
+ data_attach = {
+ 'name': attach_fname,
+ 'datas': attach_datas,
+ 'res_model': 'mail.compose.message',
+ 'res_id': 0,
+ 'type': 'binary', # override default_type from context, possibly meant for another model!
+ }
+ attachment_ids.append(Attachment.create(data_attach).id)
+ if values.get('attachment_ids', []) or attachment_ids:
+ values['attachment_ids'] = [(6, 0, values.get('attachment_ids', []) + attachment_ids)]
+ else:
+ default_values = self.with_context(default_composition_mode=composition_mode, default_model=model, default_res_id=res_id).default_get(['composition_mode', 'model', 'res_id', 'parent_id', 'partner_ids', 'subject', 'body', 'email_from', 'reply_to', 'attachment_ids', 'mail_server_id'])
+ values = dict((key, default_values[key]) for key in ['subject', 'body', 'partner_ids', 'email_from', 'reply_to', 'attachment_ids', 'mail_server_id'] if key in default_values)
+
+ if values.get('body_html'):
+ values['body'] = values.pop('body_html')
+
+ # This onchange should return command instead of ids for x2many field.
+ values = self._convert_to_write(values)
+
+ return {'value': values}
+
+ def save_as_template(self):
+ """ hit save as template button: current form value will be a new
+ template attached to the current document. """
+ for record in self:
+ model = self.env['ir.model']._get(record.model or 'mail.message')
+ model_name = model.name or ''
+ template_name = "%s: %s" % (model_name, tools.ustr(record.subject))
+ values = {
+ 'name': template_name,
+ 'subject': record.subject or False,
+ 'body_html': record.body or False,
+ 'model_id': model.id or False,
+ 'attachment_ids': [(6, 0, [att.id for att in record.attachment_ids])],
+ }
+ template = self.env['mail.template'].create(values)
+ # generate the saved template
+ record.write({'template_id': template.id})
+ record.onchange_template_id_wrapper()
+ return _reopen(self, record.id, record.model, context=self._context)
+
+ # ------------------------------------------------------------
+ # RENDERING
+ # ------------------------------------------------------------
+
+ def render_message(self, res_ids):
+ """Generate template-based values of wizard, for the document records given
+ by res_ids. This method is meant to be inherited by email_template that
+ will produce a more complete dictionary, using Jinja2 templates.
+
+ Each template is generated for all res_ids, allowing to parse the template
+ once, and render it multiple times. This is useful for mass mailing where
+ template rendering represent a significant part of the process.
+
+ Default recipients are also computed, based on mail_thread method
+ _message_get_default_recipients. This allows to ensure a mass mailing has
+ always some recipients specified.
+
+ :param browse wizard: current mail.compose.message browse record
+ :param list res_ids: list of record ids
+
+ :return dict results: for each res_id, the generated template values for
+ subject, body, email_from and reply_to
+ """
+ self.ensure_one()
+ multi_mode = True
+ if isinstance(res_ids, int):
+ multi_mode = False
+ res_ids = [res_ids]
+
+ subjects = self.env['mail.render.mixin']._render_template(self.subject, self.model, res_ids)
+ bodies = self.env['mail.render.mixin']._render_template(self.body, self.model, res_ids, post_process=True)
+ emails_from = self.env['mail.render.mixin']._render_template(self.email_from, self.model, res_ids)
+ replies_to = self.env['mail.render.mixin']._render_template(self.reply_to, self.model, res_ids)
+ default_recipients = {}
+ if not self.partner_ids:
+ records = self.env[self.model].browse(res_ids).sudo()
+ default_recipients = records._message_get_default_recipients()
+
+ results = dict.fromkeys(res_ids, False)
+ for res_id in res_ids:
+ results[res_id] = {
+ 'subject': subjects[res_id],
+ 'body': bodies[res_id],
+ 'email_from': emails_from[res_id],
+ 'reply_to': replies_to[res_id],
+ }
+ results[res_id].update(default_recipients.get(res_id, dict()))
+
+ # generate template-based values
+ if self.template_id:
+ template_values = self.generate_email_for_composer(
+ self.template_id.id, res_ids,
+ ['email_to', 'partner_to', 'email_cc', 'attachment_ids', 'mail_server_id'])
+ else:
+ template_values = {}
+
+ for res_id in res_ids:
+ if template_values.get(res_id):
+ # recipients are managed by the template
+ results[res_id].pop('partner_ids', None)
+ results[res_id].pop('email_to', None)
+ results[res_id].pop('email_cc', None)
+ # remove attachments from template values as they should not be rendered
+ template_values[res_id].pop('attachment_ids', None)
+ else:
+ template_values[res_id] = dict()
+ # update template values by composer values
+ template_values[res_id].update(results[res_id])
+
+ return multi_mode and template_values or template_values[res_ids[0]]
+
+ @api.model
+ def generate_email_for_composer(self, template_id, res_ids, fields):
+ """ Call email_template.generate_email(), get fields relevant for
+ mail.compose.message, transform email_cc and email_to into partner_ids """
+ multi_mode = True
+ if isinstance(res_ids, int):
+ multi_mode = False
+ res_ids = [res_ids]
+
+ returned_fields = fields + ['partner_ids', 'attachments']
+ values = dict.fromkeys(res_ids, False)
+
+ template_values = self.env['mail.template'].with_context(tpl_partners_only=True).browse(template_id).generate_email(res_ids, fields)
+ for res_id in res_ids:
+ res_id_values = dict((field, template_values[res_id][field]) for field in returned_fields if template_values[res_id].get(field))
+ res_id_values['body'] = res_id_values.pop('body_html', '')
+ values[res_id] = res_id_values
+
+ return multi_mode and values or values[res_ids[0]]
+
+ @api.autovacuum
+ def _gc_lost_attachments(self):
+ """ Garbage collect lost mail attachments. Those are attachments
+ - linked to res_model 'mail.compose.message', the composer wizard
+ - with res_id 0, because they were created outside of an existing
+ wizard (typically user input through Chatter or reports
+ created on-the-fly by the templates)
+ - unused since at least one day (create_date and write_date)
+ """
+ limit_date = fields.Datetime.subtract(fields.Datetime.now(), days=1)
+ self.env['ir.attachment'].search([
+ ('res_model', '=', self._name),
+ ('res_id', '=', 0),
+ ('create_date', '<', limit_date),
+ ('write_date', '<', limit_date)]
+ ).unlink()
diff --git a/addons/mail/wizard/mail_compose_message_view.xml b/addons/mail/wizard/mail_compose_message_view.xml
new file mode 100644
index 00000000..1e9b0a69
--- /dev/null
+++ b/addons/mail/wizard/mail_compose_message_view.xml
@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data>
+ <record model="ir.ui.view" id="email_compose_message_wizard_form">
+ <field name="name">mail.compose.message.form</field>
+ <field name="model">mail.compose.message</field>
+ <field name="groups_id" eval="[(4,ref('base.group_user'))]"/>
+ <field name="arch" type="xml">
+ <form string="Compose Email">
+ <group>
+ <!-- truly invisible fields for control and options -->
+ <field name="composition_mode" invisible="1"/>
+ <field name="model" invisible="1"/>
+ <field name="res_id" invisible="1"/>
+ <field name="is_log" invisible="1"/>
+ <field name="parent_id" invisible="1"/>
+ <field name="mail_server_id" invisible="1"/>
+ <field name="active_domain" invisible="1"/>
+
+ <!-- Various warnings -->
+ <div colspan="2" class="oe_form_box_info bg-info oe_text_center"
+ attrs="{'invisible': [('active_domain', '=', False)]}">
+ <p attrs="{'invisible': [('use_active_domain', '=', False)]}">
+ <strong>
+ All records matching your current search filter will be mailed,
+ not only the ids selected in the list view.
+ </strong><br />
+ The email will be sent for all the records selected in the list.<br />
+ Confirming this wizard will probably take a few minutes blocking your browser.
+ </p>
+ <p attrs="{'invisible': [('use_active_domain', '=', True)]}">
+ <strong>Only records checked in list view will be used.</strong><br />
+ The email will be sent for all the records selected in the list.
+ </p>
+ <p class="mt8">
+ <span attrs="{'invisible': [('use_active_domain', '=', True)]}">
+ If you want to send it for all the records matching your search criterion, check this box :
+ </span>
+ <span attrs="{'invisible': [('use_active_domain', '=', False)]}">
+ If you want to use only selected records please uncheck this selection box :
+ </span>
+ <field class="oe_inline" name="use_active_domain"/>
+ </p>
+ </div>
+ <!-- visible wizard -->
+ <field name="email_from"
+ attrs="{'invisible':[('composition_mode', '!=', 'mass_mail')]}"/>
+ <label for="partner_ids" string="Recipients" attrs="{'invisible': [('is_log', '=', True)]}" groups="base.group_user"/>
+ <div groups="base.group_user" attrs="{'invisible': [('is_log', '=', True)]}">
+ <span attrs="{'invisible': [('composition_mode', '!=', 'mass_mail')]}">
+ <strong>Email mass mailing</strong> on
+ <span attrs="{'invisible': [('use_active_domain', '=', True)]}">the selected records</span>
+ <span attrs="{'invisible': [('use_active_domain', '=', False)]}">the current search filter</span>.
+ </span>
+ <span name="document_followers_text" attrs="{'invisible':['|', ('model', '=', False), ('composition_mode', '=', 'mass_mail')]}">Followers of the document and</span>
+ <field name="partner_ids" widget="many2many_tags_email" placeholder="Add contacts to notify..."
+ context="{'force_email':True, 'show_email':True}"
+ attrs="{'invisible': [('composition_mode', '!=', 'comment')]}"/>
+ </div>
+ <field name="subject" placeholder="Subject..." required="True"/>
+ <!-- mass post -->
+ <field name="notify"
+ attrs="{'invisible':[('composition_mode', '!=', 'mass_post')]}"/>
+ <!-- mass mailing -->
+ <field name="no_auto_thread" attrs="{'invisible':[('composition_mode', '!=', 'mass_mail')]}"/>
+ <field name="reply_to" placeholder="Email address to redirect replies..."
+ attrs="{'invisible':['|', ('no_auto_thread', '=', False), ('composition_mode', '!=', 'mass_mail')],
+ 'required':[('no_auto_thread', '=', True), ('composition_mode', '=', 'mass_mail')]}"/>
+ </group>
+ <field name="body" options="{'style-inline': true}"/>
+ <group col="4">
+ <field name="attachment_ids" widget="many2many_binary" string="Attach a file" nolabel="1" colspan="2"/>
+ <field name="template_id" options="{'no_create': True}"
+ context="{'default_model': model, 'default_body_html': body, 'default_subject': subject}"/>
+ </group>
+ <footer>
+ <button string="Send" attrs="{'invisible': [('is_log', '=', True)]}" name="action_send_mail" type="object" class="btn-primary o_mail_send"/>
+ <button string="Log" attrs="{'invisible': [('is_log', '=', False)]}" name="action_send_mail" type="object" class="btn-primary"/>
+ <button string="Cancel" class="btn-secondary" special="cancel" />
+
+ <button icon="fa-lg fa-save" type="object" name="save_as_template" string="Save as new template"
+ class="float-right btn-secondary" help="Save as a new template"/>
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="action_email_compose_message_wizard" model="ir.actions.act_window">
+ <field name="name">Compose Email</field>
+ <field name="res_model">mail.compose.message</field>
+ <field name="binding_model_id" ref="mail.model_mail_compose_message"/>
+ <field name="type">ir.actions.act_window</field>
+ <field name="view_mode">form</field>
+ <field name="target">new</field>
+ </record>
+ </data>
+</odoo>
diff --git a/addons/mail/wizard/mail_resend_cancel.py b/addons/mail/wizard/mail_resend_cancel.py
new file mode 100644
index 00000000..dfeef296
--- /dev/null
+++ b/addons/mail/wizard/mail_resend_cancel.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import _, api, fields, models
+
+
+class MailResendCancel(models.TransientModel):
+ _name = 'mail.resend.cancel'
+ _description = 'Dismiss notification for resend by model'
+
+ model = fields.Char(string='Model')
+ help_message = fields.Char(string='Help message', compute='_compute_help_message')
+
+ @api.depends('model')
+ def _compute_help_message(self):
+ for wizard in self:
+ wizard.help_message = _("Are you sure you want to discard %s mail delivery failures? You won't be able to re-send these mails later!") % (wizard._context.get('unread_counter'))
+
+ def cancel_resend_action(self):
+ author_id = self.env.user.partner_id.id
+ for wizard in self:
+ self._cr.execute("""
+ SELECT notif.id, mes.id
+ FROM mail_message_res_partner_needaction_rel notif
+ JOIN mail_message mes
+ ON notif.mail_message_id = mes.id
+ WHERE notif.notification_type = 'email' AND notif.notification_status IN ('bounce', 'exception')
+ AND mes.model = %s
+ AND mes.author_id = %s
+ """, (wizard.model, author_id))
+ res = self._cr.fetchall()
+ notif_ids = [row[0] for row in res]
+ messages_ids = list(set([row[1] for row in res]))
+ if notif_ids:
+ self.env["mail.notification"].browse(notif_ids).sudo().write({'notification_status': 'canceled'})
+ self.env["mail.message"].browse(messages_ids)._notify_message_notification_update()
+ return {'type': 'ir.actions.act_window_close'}
diff --git a/addons/mail/wizard/mail_resend_cancel_views.xml b/addons/mail/wizard/mail_resend_cancel_views.xml
new file mode 100644
index 00000000..c8cc6632
--- /dev/null
+++ b/addons/mail/wizard/mail_resend_cancel_views.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo><data>
+ <record id="mail_resend_cancel_view_form" model="ir.ui.view">
+ <field name="name">mail.resend.cancel.view.form</field>
+ <field name="model">mail.resend.cancel</field>
+ <field name="groups_id" eval="[(4,ref('base.group_user'))]"/>
+ <field name="arch" type="xml">
+ <form string="Cancel notification in failure">
+ <field name="model" invisible='1'/>
+ <field name="help_message"/>
+ <p>If you want to re-send them, click Cancel now, then click on the notification and review them one by one by clicking on the red envelope next to each message.</p>
+ <img src="/mail/static/img/red_envelope.png" alt="Envelope Example"/>
+ <footer>
+ <button string="Discard delivery failures" name="cancel_resend_action" type="object" class="btn-primary" />
+ <button string="Cancel" class="btn-secondary" special="cancel" />
+ </footer>
+ </form>
+ </field>
+ </record>
+ <record id="mail_resend_cancel_action" model="ir.actions.act_window">
+ <field name="name">Discard mail delivery failures</field>
+ <field name="res_model">mail.resend.cancel</field>
+ <field name="type">ir.actions.act_window</field>
+ <field name="view_mode">form</field>
+ <field name="target">new</field>
+ </record>
+</data></odoo>
diff --git a/addons/mail/wizard/mail_resend_message.py b/addons/mail/wizard/mail_resend_message.py
new file mode 100644
index 00000000..b1c6304b
--- /dev/null
+++ b/addons/mail/wizard/mail_resend_message.py
@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+
+
+class MailResendMessage(models.TransientModel):
+ _name = 'mail.resend.message'
+ _description = 'Email resend wizard'
+
+ mail_message_id = fields.Many2one('mail.message', 'Message', readonly=True)
+ partner_ids = fields.One2many('mail.resend.partner', 'resend_wizard_id', string='Recipients')
+ notification_ids = fields.Many2many('mail.notification', string='Notifications', readonly=True)
+ has_cancel = fields.Boolean(compute='_compute_has_cancel')
+ partner_readonly = fields.Boolean(compute='_compute_partner_readonly')
+
+ @api.depends("partner_ids")
+ def _compute_has_cancel(self):
+ self.has_cancel = self.partner_ids.filtered(lambda p: not p.resend)
+
+ def _compute_partner_readonly(self):
+ self.partner_readonly = not self.env['res.partner'].check_access_rights('write', raise_exception=False)
+
+ @api.model
+ def default_get(self, fields):
+ rec = super(MailResendMessage, self).default_get(fields)
+ message_id = self._context.get('mail_message_to_resend')
+ if message_id:
+ mail_message_id = self.env['mail.message'].browse(message_id)
+ notification_ids = mail_message_id.notification_ids.filtered(lambda notif: notif.notification_type == 'email' and notif.notification_status in ('exception', 'bounce'))
+ partner_ids = [(0, 0, {
+ "partner_id": notif.res_partner_id.id,
+ "name": notif.res_partner_id.name,
+ "email": notif.res_partner_id.email,
+ "resend": True,
+ "message": notif.format_failure_reason(),
+ }) for notif in notification_ids]
+ has_user = any(notif.res_partner_id.user_ids for notif in notification_ids)
+ if has_user:
+ partner_readonly = not self.env['res.users'].check_access_rights('write', raise_exception=False)
+ else:
+ partner_readonly = not self.env['res.partner'].check_access_rights('write', raise_exception=False)
+ rec['partner_readonly'] = partner_readonly
+ rec['notification_ids'] = [(6, 0, notification_ids.ids)]
+ rec['mail_message_id'] = mail_message_id.id
+ rec['partner_ids'] = partner_ids
+ else:
+ raise UserError(_('No message_id found in context'))
+ return rec
+
+ def resend_mail_action(self):
+ """ Process the wizard content and proceed with sending the related
+ email(s), rendering any template patterns on the fly if needed. """
+ for wizard in self:
+ "If a partner disappeared from partner list, we cancel the notification"
+ to_cancel = wizard.partner_ids.filtered(lambda p: not p.resend).mapped("partner_id")
+ to_send = wizard.partner_ids.filtered(lambda p: p.resend).mapped("partner_id")
+ notif_to_cancel = wizard.notification_ids.filtered(lambda notif: notif.notification_type == 'email' and notif.res_partner_id in to_cancel and notif.notification_status in ('exception', 'bounce'))
+ notif_to_cancel.sudo().write({'notification_status': 'canceled'})
+ if to_send:
+ message = wizard.mail_message_id
+ record = self.env[message.model].browse(message.res_id) if message.is_thread_message() else self.env['mail.thread']
+
+ email_partners_data = []
+ for pid, cid, active, pshare, ctype, notif, groups in self.env['mail.followers']._get_recipient_data(None, 'comment', False, pids=to_send.ids):
+ if pid and notif == 'email' or not notif:
+ pdata = {'id': pid, 'share': pshare, 'active': active, 'notif': 'email', 'groups': groups or []}
+ if not pshare and notif: # has an user and is not shared, is therefore user
+ email_partners_data.append(dict(pdata, type='user'))
+ elif pshare and notif: # has an user and is shared, is therefore portal
+ email_partners_data.append(dict(pdata, type='portal'))
+ else: # has no user, is therefore customer
+ email_partners_data.append(dict(pdata, type='customer'))
+
+ record._notify_record_by_email(message, {'partners': email_partners_data}, check_existing=True, send_after_commit=False)
+
+ self.mail_message_id._notify_message_notification_update()
+ return {'type': 'ir.actions.act_window_close'}
+
+ def cancel_mail_action(self):
+ for wizard in self:
+ for notif in wizard.notification_ids:
+ notif.filtered(lambda notif: notif.notification_type == 'email' and notif.notification_status in ('exception', 'bounce')).sudo().write({'notification_status': 'canceled'})
+ wizard.mail_message_id._notify_message_notification_update()
+ return {'type': 'ir.actions.act_window_close'}
+
+
+class PartnerResend(models.TransientModel):
+ _name = 'mail.resend.partner'
+ _description = 'Partner with additional information for mail resend'
+
+ partner_id = fields.Many2one('res.partner', string='Partner', required=True, ondelete='cascade')
+ name = fields.Char(related="partner_id.name", related_sudo=False, readonly=False)
+ email = fields.Char(related="partner_id.email", related_sudo=False, readonly=False)
+ resend = fields.Boolean(string="Send Again", default=True)
+ resend_wizard_id = fields.Many2one('mail.resend.message', string="Resend wizard")
+ message = fields.Char(string="Help message")
diff --git a/addons/mail/wizard/mail_resend_message_views.xml b/addons/mail/wizard/mail_resend_message_views.xml
new file mode 100644
index 00000000..f9fb4ef5
--- /dev/null
+++ b/addons/mail/wizard/mail_resend_message_views.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data>
+ <record id="mail_resend_message_view_form" model="ir.ui.view">
+ <field name="name">mail.resend.message.view.form</field>
+ <field name="model">mail.resend.message</field>
+ <field name="groups_id" eval="[(4,ref('base.group_user'))]"/>
+ <field name="arch" type="xml">
+ <form string="Edit Partners">
+ <field name="mail_message_id" invisible="1"/>
+ <field name="notification_ids" invisible="1"/>
+ <field name="has_cancel" invisible="1"/>
+ <field name="partner_readonly" invisible="1"/>
+ <p>Select the action to do on each mail and correct the email address if needed. The modified address will be saved on the corresponding contact.</p>
+ <field name="partner_ids">
+ <tree string="Recipient" editable="top" create="0" delete="0">
+ <field name="name" readonly="1"/>
+ <field name="email" attrs="{'readonly': [('parent.partner_readonly', '=', True)]}"/>
+ <field name="message" readonly="1"/>
+ <field name="partner_id" invisible="1"/>
+ <field name="resend" widget="boolean_toggle"/>
+ </tree>
+ </field>
+ <div class="alert alert-warning" role="alert" attrs="{'invisible': [('has_cancel', '=', False)]}">
+ <span class="fa fa-info-circle"/> Caution: It won't be possible to send this mail again to the recipients you did not select.
+ </div>
+ <footer>
+ <button string="Resend to selected" name="resend_mail_action" type="object" class="btn-primary o_mail_send"/>
+ <button string="Ignore all failures" name="cancel_mail_action" type="object" class="btn-secondary" />
+ <button string="Cancel" class="btn-secondary" special="cancel" />
+ </footer>
+ </form>
+ </field>
+ </record>
+ <record id="mail_resend_message_action" model="ir.actions.act_window">
+ <field name="name">Resend mail</field>
+ <field name="res_model">mail.resend.message</field>
+ <field name="type">ir.actions.act_window</field>
+ <field name="view_mode">form</field>
+ <field name="target">new</field>
+ </record>
+ </data>
+</odoo>
diff --git a/addons/mail/wizard/mail_template_preview.py b/addons/mail/wizard/mail_template_preview.py
new file mode 100644
index 00000000..03062bee
--- /dev/null
+++ b/addons/mail/wizard/mail_template_preview.py
@@ -0,0 +1,86 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+from odoo.exceptions import UserError
+
+
+class MailTemplatePreview(models.TransientModel):
+ _name = 'mail.template.preview'
+ _description = 'Email Template Preview'
+ _MAIL_TEMPLATE_FIELDS = ['subject', 'body_html', 'email_from', 'email_to',
+ 'email_cc', 'reply_to', 'scheduled_date', 'attachment_ids']
+
+ @api.model
+ def _selection_target_model(self):
+ return [(model.model, model.name) for model in self.env['ir.model'].search([])]
+
+ @api.model
+ def _selection_languages(self):
+ return self.env['res.lang'].get_installed()
+
+ @api.model
+ def default_get(self, fields):
+ result = super(MailTemplatePreview, self).default_get(fields)
+ if not result.get('mail_template_id') or 'resource_ref' not in fields:
+ return result
+ mail_template = self.env['mail.template'].browse(result['mail_template_id'])
+ res = self.env[mail_template.model_id.model].search([], limit=1)
+ if res:
+ result['resource_ref'] = '%s,%s' % (mail_template.model_id.model, res.id)
+ return result
+
+ mail_template_id = fields.Many2one('mail.template', string='Related Mail Template', required=True)
+ model_id = fields.Many2one('ir.model', string='Targeted model', related="mail_template_id.model_id")
+ resource_ref = fields.Reference(string='Record', selection='_selection_target_model')
+ lang = fields.Selection(_selection_languages, string='Template Preview Language')
+ no_record = fields.Boolean('No Record', compute='_compute_no_record')
+ error_msg = fields.Char('Error Message', readonly=True)
+ # Fields same than the mail.template model, computed with resource_ref and lang
+ subject = fields.Char('Subject', compute='_compute_mail_template_fields')
+ email_from = fields.Char('From', compute='_compute_mail_template_fields', help="Sender address")
+ email_to = fields.Char('To', compute='_compute_mail_template_fields',
+ help="Comma-separated recipient addresses")
+ email_cc = fields.Char('Cc', compute='_compute_mail_template_fields', help="Carbon copy recipients")
+ reply_to = fields.Char('Reply-To', compute='_compute_mail_template_fields', help="Preferred response address")
+ scheduled_date = fields.Char('Scheduled Date', compute='_compute_mail_template_fields',
+ help="The queue manager will send the email after the date")
+ body_html = fields.Html('Body', compute='_compute_mail_template_fields', sanitize=False)
+ attachment_ids = fields.Many2many('ir.attachment', 'Attachments', compute='_compute_mail_template_fields')
+ # Extra fields info generated by generate_email
+ partner_ids = fields.Many2many('res.partner', string='Recipients', compute='_compute_mail_template_fields')
+
+ @api.depends('model_id')
+ def _compute_no_record(self):
+ for preview in self:
+ preview.no_record = (self.env[preview.model_id.model].search_count([]) == 0) if preview.model_id else True
+
+ @api.depends('lang', 'resource_ref')
+ def _compute_mail_template_fields(self):
+ """ Preview the mail template (body, subject, ...) depending of the language and
+ the record reference, more precisely the record id for the defined model of the mail template.
+ If no record id is selectable/set, the jinja placeholders won't be replace in the display information. """
+ copy_depends_values = {'lang': self.lang}
+ mail_template = self.mail_template_id.with_context(lang=self.lang)
+ try:
+ if not self.resource_ref:
+ self._set_mail_attributes()
+ else:
+ copy_depends_values['resource_ref'] = '%s,%s' % (self.resource_ref._name, self.resource_ref.id)
+ mail_values = mail_template.with_context(template_preview_lang=self.lang).generate_email(
+ self.resource_ref.id, self._MAIL_TEMPLATE_FIELDS)
+ self._set_mail_attributes(values=mail_values)
+ self.error_msg = False
+ except UserError as user_error:
+ self._set_mail_attributes()
+ self.error_msg = user_error.args[0]
+ finally:
+ # Avoid to be change by a invalidate_cache call (in generate_mail), e.g. Quotation / Order report
+ for key, value in copy_depends_values.items():
+ self[key] = value
+
+ def _set_mail_attributes(self, values=None):
+ for field in self._MAIL_TEMPLATE_FIELDS:
+ field_value = values.get(field, False) if values else self.mail_template_id[field]
+ self[field] = field_value
+ self.partner_ids = values.get('partner_ids', False) if values else False
diff --git a/addons/mail/wizard/mail_template_preview_views.xml b/addons/mail/wizard/mail_template_preview_views.xml
new file mode 100644
index 00000000..ab3d0bd5
--- /dev/null
+++ b/addons/mail/wizard/mail_template_preview_views.xml
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data>
+ <record id="mail_template_preview_view_form" model="ir.ui.view">
+ <field name="name">mail.template.preview.view.form</field>
+ <field name="model">mail.template.preview</field>
+ <field name="arch" type="xml">
+ <form string="Email Preview">
+ <h3>Preview of <field name="mail_template_id" readonly="1" nolabel="1" options="{'no_open' : True}"/></h3>
+ <div class="alert alert-danger" role="alert" attrs="{'invisible' : [('error_msg', '=', False)]}">
+ <field name="error_msg" />
+ </div>
+ <field name="no_record" invisible="1"/>
+ <div class="container">
+ <div class="row">
+ <span class="col-md-5 col-lg-4 col-sm-12 pl-0">Choose an example <field name="model_id" readonly="1"/> record:</span>
+ <div class="col-md-7 col-lg-6 col-sm-12 pl-0">
+ <field name="resource_ref" readonly="False"
+ options="{'hide_model': True, 'no_create': True, 'no_edit': True, 'no_open': True}"
+ attrs="{'invisible': [('no_record', '=', True)]}"/>
+ <b attrs="{'invisible': [('no_record', '=', False)]}" class="text-warning">No record for this model</b>
+ </div>
+ </div>
+ <div class="row">
+ <span class="col-md-5 col-lg-4 col-sm-12 pl-0">Force a language: </span>
+ <div class="col-md-7 col-lg-6 col-sm-12 pl-0">
+ <field name="lang"/>
+ </div>
+ </div>
+ </div>
+ <group>
+ <field name="subject"/>
+ <field name="email_from" attrs="{'invisible':[('email_from','=', False)]}"/>
+ <field name="partner_ids" widget="many2many_tags" attrs="{'invisible':[('partner_ids', '=', [])]}"/>
+ <field name="email_to" attrs="{'invisible':[('email_to','=', False)]}"/>
+ <field name="email_cc" attrs="{'invisible':[('email_cc','=', False)]}"/>
+ <field name="reply_to" attrs="{'invisible':[('reply_to','=', False)]}"/>
+ <field name="scheduled_date" attrs="{'invisible':[('scheduled_date','=', False)]}"/>
+ </group>
+ <field name="body_html" widget="html" nolabel="1" options='{"safe": True}'/>
+ <field name="attachment_ids" widget="many2many_binary"/>
+ <footer>
+ <button string="Close" class="btn-secondary" special="cancel"/>
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="mail_template_preview_action" model="ir.actions.act_window">
+ <field name="name">Template Preview</field>
+ <field name="res_model">mail.template.preview</field>
+ <field name="binding_model_id" eval="False"/>
+ <field name="type">ir.actions.act_window</field>
+ <field name="view_mode">form</field>
+ <field name="view_id" ref="mail_template_preview_view_form"/>
+ <field name="target">new</field>
+ <field name="context">{'default_mail_template_id':active_id}</field>
+ </record>
+
+ </data>
+</odoo>