summaryrefslogtreecommitdiff
path: root/addons/mail/models
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/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/mail/models')
-rw-r--r--addons/mail/models/__init__.py34
-rw-r--r--addons/mail/models/ir_action_act_window.py10
-rw-r--r--addons/mail/models/ir_actions.py148
-rw-r--r--addons/mail/models/ir_attachment.py36
-rw-r--r--addons/mail/models/ir_config_parameter.py21
-rw-r--r--addons/mail/models/ir_http.py16
-rw-r--r--addons/mail/models/ir_model.py110
-rw-r--r--addons/mail/models/ir_model_fields.py32
-rw-r--r--addons/mail/models/ir_ui_view.py8
-rw-r--r--addons/mail/models/mail_activity.py1048
-rw-r--r--addons/mail/models/mail_alias.py205
-rw-r--r--addons/mail/models/mail_alias_mixin.py130
-rw-r--r--addons/mail/models/mail_blacklist.py107
-rw-r--r--addons/mail/models/mail_channel.py1149
-rw-r--r--addons/mail/models/mail_followers.py397
-rw-r--r--addons/mail/models/mail_mail.py446
-rw-r--r--addons/mail/models/mail_message.py1251
-rw-r--r--addons/mail/models/mail_message_subtype.py110
-rw-r--r--addons/mail/models/mail_notification.py110
-rw-r--r--addons/mail/models/mail_render_mixin.py482
-rw-r--r--addons/mail/models/mail_shortcode.py19
-rw-r--r--addons/mail/models/mail_template.py296
-rw-r--r--addons/mail/models/mail_thread.py2883
-rw-r--r--addons/mail/models/mail_thread_blacklist.py126
-rw-r--r--addons/mail/models/mail_thread_cc.py50
-rw-r--r--addons/mail/models/mail_tracking_value.py118
-rw-r--r--addons/mail/models/models.py210
-rw-r--r--addons/mail/models/res_company.py37
-rw-r--r--addons/mail/models/res_config_settings.py36
-rw-r--r--addons/mail/models/res_partner.py184
-rw-r--r--addons/mail/models/res_users.py176
-rw-r--r--addons/mail/models/update.py125
32 files changed, 10110 insertions, 0 deletions
diff --git a/addons/mail/models/__init__.py b/addons/mail/models/__init__.py
new file mode 100644
index 00000000..38b80e6d
--- /dev/null
+++ b/addons/mail/models/__init__.py
@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import mail_message_subtype
+from . import mail_tracking_value
+from . import mail_alias
+from . import mail_alias_mixin
+from . import mail_followers
+from . import mail_notification
+from . import mail_render_mixin
+from . import mail_message
+from . import mail_activity
+from . import mail_mail
+from . import mail_thread
+from . import mail_thread_blacklist
+from . import mail_thread_cc
+from . import mail_blacklist
+from . import mail_channel
+from . import mail_template
+from . import mail_shortcode
+from . import models
+from . import res_partner
+from . import res_users
+from . import res_company
+from . import res_config_settings
+from . import update
+from . import ir_action_act_window
+from . import ir_actions
+from . import ir_attachment
+from . import ir_config_parameter
+from . import ir_http
+from . import ir_model
+from . import ir_model_fields
+from . import ir_ui_view
diff --git a/addons/mail/models/ir_action_act_window.py b/addons/mail/models/ir_action_act_window.py
new file mode 100644
index 00000000..b95068cc
--- /dev/null
+++ b/addons/mail/models/ir_action_act_window.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+from odoo import fields, models
+
+
+class ActWindowView(models.Model):
+ _inherit = 'ir.actions.act_window.view'
+
+ view_mode = fields.Selection(selection_add=[
+ ('activity', 'Activity')
+ ], ondelete={'activity': 'cascade'})
diff --git a/addons/mail/models/ir_actions.py b/addons/mail/models/ir_actions.py
new file mode 100644
index 00000000..eaf5af8c
--- /dev/null
+++ b/addons/mail/models/ir_actions.py
@@ -0,0 +1,148 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from dateutil.relativedelta import relativedelta
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError, ValidationError
+
+
+class ServerActions(models.Model):
+ """ Add email option in server actions. """
+ _name = 'ir.actions.server'
+ _description = 'Server Action'
+ _inherit = ['ir.actions.server']
+
+ state = fields.Selection(selection_add=[
+ ('email', 'Send Email'),
+ ('followers', 'Add Followers'),
+ ('next_activity', 'Create Next Activity'),
+ ], ondelete={'email': 'cascade', 'followers': 'cascade', 'next_activity': 'cascade'})
+ # Followers
+ partner_ids = fields.Many2many('res.partner', string='Add Followers')
+ channel_ids = fields.Many2many('mail.channel', string='Add Channels')
+ # Template
+ template_id = fields.Many2one(
+ 'mail.template', 'Email Template', ondelete='set null',
+ domain="[('model_id', '=', model_id)]",
+ )
+ # Next Activity
+ activity_type_id = fields.Many2one(
+ 'mail.activity.type', string='Activity',
+ domain="['|', ('res_model_id', '=', False), ('res_model_id', '=', model_id)]",
+ ondelete='restrict')
+ activity_summary = fields.Char('Summary')
+ activity_note = fields.Html('Note')
+ activity_date_deadline_range = fields.Integer(string='Due Date In')
+ activity_date_deadline_range_type = fields.Selection([
+ ('days', 'Days'),
+ ('weeks', 'Weeks'),
+ ('months', 'Months'),
+ ], string='Due type', default='days')
+ activity_user_type = fields.Selection([
+ ('specific', 'Specific User'),
+ ('generic', 'Generic User From Record')], default="specific", required=True,
+ help="Use 'Specific User' to always assign the same user on the next activity. Use 'Generic User From Record' to specify the field name of the user to choose on the record.")
+ activity_user_id = fields.Many2one('res.users', string='Responsible')
+ activity_user_field_name = fields.Char('User field name', help="Technical name of the user on the record", default="user_id")
+
+ @api.onchange('activity_date_deadline_range')
+ def _onchange_activity_date_deadline_range(self):
+ if self.activity_date_deadline_range < 0:
+ raise UserError(_("The 'Due Date In' value can't be negative."))
+
+ @api.constrains('state', 'model_id')
+ def _check_mail_thread(self):
+ for action in self:
+ if action.state == 'followers' and not action.model_id.is_mail_thread:
+ raise ValidationError(_("Add Followers can only be done on a mail thread model"))
+
+ @api.constrains('state', 'model_id')
+ def _check_activity_mixin(self):
+ for action in self:
+ if action.state == 'next_activity' and not action.model_id.is_mail_thread:
+ raise ValidationError(_("A next activity can only be planned on models that use the chatter"))
+
+ def _run_action_followers_multi(self, eval_context=None):
+ Model = self.env[self.model_name]
+ if self.partner_ids or self.channel_ids and hasattr(Model, 'message_subscribe'):
+ records = Model.browse(self._context.get('active_ids', self._context.get('active_id')))
+ records.message_subscribe(self.partner_ids.ids, self.channel_ids.ids)
+ return False
+
+ def _is_recompute(self):
+ """When an activity is set on update of a record,
+ update might be triggered many times by recomputes.
+ When need to know it to skip these steps.
+ Except if the computed field is supposed to trigger the action
+ """
+ records = self.env[self.model_name].browse(
+ self._context.get('active_ids', self._context.get('active_id')))
+ old_values = self._context.get('old_values')
+ if old_values:
+ domain_post = self._context.get('domain_post')
+ tracked_fields = []
+ if domain_post:
+ for leaf in domain_post:
+ if isinstance(leaf, (tuple, list)):
+ tracked_fields.append(leaf[0])
+ fields_to_check = [field for record, field_names in old_values.items() for field in field_names if field not in tracked_fields]
+ if fields_to_check:
+ field = records._fields[fields_to_check[0]]
+ # Pick an arbitrary field; if it is marked to be recomputed,
+ # it means we are in an extraneous write triggered by the recompute.
+ # In this case, we should not create a new activity.
+ if records & self.env.records_to_compute(field):
+ return True
+ return False
+
+ def _run_action_email(self, eval_context=None):
+ # TDE CLEANME: when going to new api with server action, remove action
+ if not self.template_id or not self._context.get('active_id') or self._is_recompute():
+ return False
+ # Clean context from default_type to avoid making attachment
+ # with wrong values in subsequent operations
+ cleaned_ctx = dict(self.env.context)
+ cleaned_ctx.pop('default_type', None)
+ cleaned_ctx.pop('default_parent_id', None)
+ self.template_id.with_context(cleaned_ctx).send_mail(self._context.get('active_id'), force_send=False,
+ raise_exception=False)
+ return False
+
+ def _run_action_next_activity(self, eval_context=None):
+ if not self.activity_type_id or not self._context.get('active_id') or self._is_recompute():
+ return False
+
+ records = self.env[self.model_name].browse(self._context.get('active_ids', self._context.get('active_id')))
+
+ vals = {
+ 'summary': self.activity_summary or '',
+ 'note': self.activity_note or '',
+ 'activity_type_id': self.activity_type_id.id,
+ }
+ if self.activity_date_deadline_range > 0:
+ vals['date_deadline'] = fields.Date.context_today(self) + relativedelta(**{
+ self.activity_date_deadline_range_type: self.activity_date_deadline_range})
+ for record in records:
+ user = False
+ if self.activity_user_type == 'specific':
+ user = self.activity_user_id
+ elif self.activity_user_type == 'generic' and self.activity_user_field_name in record:
+ user = record[self.activity_user_field_name]
+ if user:
+ vals['user_id'] = user.id
+ record.activity_schedule(**vals)
+ return False
+
+ @api.model
+ def _get_eval_context(self, action=None):
+ """ Override the method giving the evaluation context but also the
+ context used in all subsequent calls. Add the mail_notify_force_send
+ key set to False in the context. This way all notification emails linked
+ to the currently executed action will be set in the queue instead of
+ sent directly. This will avoid possible break in transactions. """
+ eval_context = super(ServerActions, self)._get_eval_context(action=action)
+ ctx = dict(eval_context['env'].context)
+ ctx['mail_notify_force_send'] = False
+ eval_context['env'].context = ctx
+ return eval_context
diff --git a/addons/mail/models/ir_attachment.py b/addons/mail/models/ir_attachment.py
new file mode 100644
index 00000000..d1279c26
--- /dev/null
+++ b/addons/mail/models/ir_attachment.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models
+from odoo.exceptions import AccessError
+
+class IrAttachment(models.Model):
+ _inherit = 'ir.attachment'
+
+ def _post_add_create(self):
+ """ Overrides behaviour when the attachment is created through the controller
+ """
+ super(IrAttachment, self)._post_add_create()
+ for record in self:
+ record.register_as_main_attachment(force=False)
+
+ def register_as_main_attachment(self, force=True):
+ """ Registers this attachment as the main one of the model it is
+ attached to.
+ """
+ self.ensure_one()
+ if not self.res_model:
+ return
+ related_record = self.env[self.res_model].browse(self.res_id)
+ if not related_record.check_access_rights('write', raise_exception=False):
+ return
+ # message_main_attachment_id field can be empty, that's why we compare to False;
+ # we are just checking that it exists on the model before writing it
+ if related_record and hasattr(related_record, 'message_main_attachment_id'):
+ if force or not related_record.message_main_attachment_id:
+ #Ignore AccessError, if you don't have access to modify the document
+ #Just don't set the value
+ try:
+ related_record.message_main_attachment_id = self
+ except AccessError:
+ pass
diff --git a/addons/mail/models/ir_config_parameter.py b/addons/mail/models/ir_config_parameter.py
new file mode 100644
index 00000000..40e153ba
--- /dev/null
+++ b/addons/mail/models/ir_config_parameter.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models
+
+
+class IrConfigParameter(models.Model):
+ _inherit = 'ir.config_parameter'
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if vals.get('key') in ['mail.bounce.alias', 'mail.catchall.alias']:
+ vals['value'] = self.env['mail.alias']._clean_and_check_unique(vals.get('value'))
+ return super().create(vals_list)
+
+ def write(self, vals):
+ for parameter in self:
+ if 'value' in vals and parameter.key in ['mail.bounce.alias', 'mail.catchall.alias'] and vals['value'] != parameter.value:
+ vals['value'] = self.env['mail.alias']._clean_and_check_unique(vals.get('value'))
+ return super().write(vals)
diff --git a/addons/mail/models/ir_http.py b/addons/mail/models/ir_http.py
new file mode 100644
index 00000000..400b8dee
--- /dev/null
+++ b/addons/mail/models/ir_http.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+from odoo.http import request
+
+
+class IrHttp(models.AbstractModel):
+ _inherit = 'ir.http'
+
+ def session_info(self):
+ user = request.env.user
+ result = super(IrHttp, self).session_info()
+ if self.env.user.has_group('base.group_user'):
+ result['notification_type'] = user.notification_type
+ return result
diff --git a/addons/mail/models/ir_model.py b/addons/mail/models/ir_model.py
new file mode 100644
index 00000000..386ff376
--- /dev/null
+++ b/addons/mail/models/ir_model.py
@@ -0,0 +1,110 @@
+# -*- 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 Base(models.AbstractModel):
+ _inherit = 'base'
+
+ def _valid_field_parameter(self, field, name):
+ # allow tracking on abstract models; see also 'mail.thread'
+ return (
+ name == 'tracking' and self._abstract
+ or super()._valid_field_parameter(field, name)
+ )
+
+
+class IrModel(models.Model):
+ _inherit = 'ir.model'
+ _order = 'is_mail_thread DESC, name ASC'
+
+ is_mail_thread = fields.Boolean(
+ string="Mail Thread", default=False,
+ help="Whether this model supports messages and notifications.",
+ )
+ is_mail_activity = fields.Boolean(
+ string="Mail Activity", default=False,
+ help="Whether this model supports activities.",
+ )
+ is_mail_blacklist = fields.Boolean(
+ string="Mail Blacklist", default=False,
+ help="Whether this model supports blacklist.",
+ )
+
+ def unlink(self):
+ # Delete followers, messages and attachments for models that will be unlinked.
+ models = tuple(self.mapped('model'))
+
+ query = "DELETE FROM mail_followers WHERE res_model IN %s"
+ self.env.cr.execute(query, [models])
+
+ query = "DELETE FROM mail_message WHERE model in %s"
+ self.env.cr.execute(query, [models])
+
+ # Get files attached solely by the models
+ query = """
+ SELECT DISTINCT store_fname
+ FROM ir_attachment
+ WHERE res_model IN %s
+ EXCEPT
+ SELECT store_fname
+ FROM ir_attachment
+ WHERE res_model not IN %s;
+ """
+ self.env.cr.execute(query, [models, models])
+ fnames = self.env.cr.fetchall()
+
+ query = """DELETE FROM ir_attachment WHERE res_model in %s"""
+ self.env.cr.execute(query, [models])
+
+ for (fname,) in fnames:
+ self.env['ir.attachment']._file_delete(fname)
+
+ return super(IrModel, self).unlink()
+
+ def write(self, vals):
+ if self and ('is_mail_thread' in vals or 'is_mail_activity' in vals or 'is_mail_blacklist' in vals):
+ if any(rec.state != 'manual' for rec in self):
+ raise UserError(_('Only custom models can be modified.'))
+ if 'is_mail_thread' in vals and any(rec.is_mail_thread > vals['is_mail_thread'] for rec in self):
+ raise UserError(_('Field "Mail Thread" cannot be changed to "False".'))
+ if 'is_mail_activity' in vals and any(rec.is_mail_activity > vals['is_mail_activity'] for rec in self):
+ raise UserError(_('Field "Mail Activity" cannot be changed to "False".'))
+ if 'is_mail_blacklist' in vals and any(rec.is_mail_blacklist > vals['is_mail_blacklist'] for rec in self):
+ raise UserError(_('Field "Mail Blacklist" cannot be changed to "False".'))
+ res = super(IrModel, self).write(vals)
+ self.flush()
+ # setup models; this reloads custom models in registry
+ self.pool.setup_models(self._cr)
+ # update database schema of models
+ models = self.pool.descendants(self.mapped('model'), '_inherits')
+ self.pool.init_models(self._cr, models, dict(self._context, update_custom_fields=True))
+ else:
+ res = super(IrModel, self).write(vals)
+ return res
+
+ def _reflect_model_params(self, model):
+ vals = super(IrModel, self)._reflect_model_params(model)
+ vals['is_mail_thread'] = issubclass(type(model), self.pool['mail.thread'])
+ vals['is_mail_activity'] = issubclass(type(model), self.pool['mail.activity.mixin'])
+ vals['is_mail_blacklist'] = issubclass(type(model), self.pool['mail.thread.blacklist'])
+ return vals
+
+ @api.model
+ def _instanciate(self, model_data):
+ model_class = super(IrModel, self)._instanciate(model_data)
+ if model_data.get('is_mail_thread') and model_class._name != 'mail.thread':
+ parents = model_class._inherit or []
+ parents = [parents] if isinstance(parents, str) else parents
+ model_class._inherit = parents + ['mail.thread']
+ if model_data.get('is_mail_activity') and model_class._name != 'mail.activity.mixin':
+ parents = model_class._inherit or []
+ parents = [parents] if isinstance(parents, str) else parents
+ model_class._inherit = parents + ['mail.activity.mixin']
+ if model_data.get('is_mail_blacklist') and model_class._name != 'mail.thread.blacklist':
+ parents = model_class._inherit or []
+ parents = [parents] if isinstance(parents, str) else parents
+ model_class._inherit = parents + ['mail.thread.blacklist']
+ return model_class
diff --git a/addons/mail/models/ir_model_fields.py b/addons/mail/models/ir_model_fields.py
new file mode 100644
index 00000000..e2bed7af
--- /dev/null
+++ b/addons/mail/models/ir_model_fields.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class IrModelField(models.Model):
+ _inherit = 'ir.model.fields'
+
+ tracking = fields.Integer(
+ string="Enable Ordered Tracking",
+ help="If set every modification done to this field is tracked in the chatter. Value is used to order tracking values.",
+ )
+
+ def _reflect_field_params(self, field, model_id):
+ """ Tracking value can be either a boolean enabling tracking mechanism
+ on field, either an integer giving the sequence. Default sequence is
+ set to 100. """
+ vals = super(IrModelField, self)._reflect_field_params(field, model_id)
+ tracking = getattr(field, 'tracking', None)
+ if tracking is True:
+ tracking = 100
+ elif tracking is False:
+ tracking = None
+ vals['tracking'] = tracking
+ return vals
+
+ def _instanciate_attrs(self, field_data):
+ attrs = super(IrModelField, self)._instanciate_attrs(field_data)
+ if attrs and field_data.get('tracking'):
+ attrs['tracking'] = field_data['tracking']
+ return attrs
diff --git a/addons/mail/models/ir_ui_view.py b/addons/mail/models/ir_ui_view.py
new file mode 100644
index 00000000..90fdea59
--- /dev/null
+++ b/addons/mail/models/ir_ui_view.py
@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+from odoo import fields, models
+
+
+class View(models.Model):
+ _inherit = 'ir.ui.view'
+
+ type = fields.Selection(selection_add=[('activity', 'Activity')])
diff --git a/addons/mail/models/mail_activity.py b/addons/mail/models/mail_activity.py
new file mode 100644
index 00000000..6d979088
--- /dev/null
+++ b/addons/mail/models/mail_activity.py
@@ -0,0 +1,1048 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from collections import defaultdict
+from datetime import date, datetime
+from dateutil.relativedelta import relativedelta
+import logging
+import pytz
+
+from odoo import api, exceptions, fields, models, _
+from odoo.osv import expression
+
+from odoo.tools.misc import clean_context
+from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
+
+_logger = logging.getLogger(__name__)
+
+
+class MailActivityType(models.Model):
+ """ Activity Types are used to categorize activities. Each type is a different
+ kind of activity e.g. call, mail, meeting. An activity can be generic i.e.
+ available for all models using activities; or specific to a model in which
+ case res_model_id field should be used. """
+ _name = 'mail.activity.type'
+ _description = 'Activity Type'
+ _rec_name = 'name'
+ _order = 'sequence, id'
+
+ @api.model
+ def default_get(self, fields):
+ if not self.env.context.get('default_res_model_id') and self.env.context.get('default_res_model'):
+ self = self.with_context(
+ default_res_model_id=self.env['ir.model']._get(self.env.context.get('default_res_model'))
+ )
+ return super(MailActivityType, self).default_get(fields)
+
+ name = fields.Char('Name', required=True, translate=True)
+ summary = fields.Char('Default Summary', translate=True)
+ sequence = fields.Integer('Sequence', default=10)
+ active = fields.Boolean(default=True)
+ create_uid = fields.Many2one('res.users', index=True)
+ delay_count = fields.Integer(
+ 'Scheduled Date', default=0,
+ help='Number of days/week/month before executing the action. It allows to plan the action deadline.')
+ delay_unit = fields.Selection([
+ ('days', 'days'),
+ ('weeks', 'weeks'),
+ ('months', 'months')], string="Delay units", help="Unit of delay", required=True, default='days')
+ delay_label = fields.Char(compute='_compute_delay_label')
+ delay_from = fields.Selection([
+ ('current_date', 'after validation date'),
+ ('previous_activity', 'after previous activity deadline')], string="Delay Type", help="Type of delay", required=True, default='previous_activity')
+ icon = fields.Char('Icon', help="Font awesome icon e.g. fa-tasks")
+ decoration_type = fields.Selection([
+ ('warning', 'Alert'),
+ ('danger', 'Error')], string="Decoration Type",
+ help="Change the background color of the related activities of this type.")
+ res_model_id = fields.Many2one(
+ 'ir.model', 'Model', index=True,
+ domain=['&', ('is_mail_thread', '=', True), ('transient', '=', False)],
+ help='Specify a model if the activity should be specific to a model'
+ ' and not available when managing activities for other models.')
+ default_next_type_id = fields.Many2one('mail.activity.type', 'Default Next Activity',
+ domain="['|', ('res_model_id', '=', False), ('res_model_id', '=', res_model_id)]", ondelete='restrict')
+ force_next = fields.Boolean("Trigger Next Activity", default=False)
+ next_type_ids = fields.Many2many(
+ 'mail.activity.type', 'mail_activity_rel', 'activity_id', 'recommended_id',
+ domain="['|', ('res_model_id', '=', False), ('res_model_id', '=', res_model_id)]",
+ string='Recommended Next Activities')
+ previous_type_ids = fields.Many2many(
+ 'mail.activity.type', 'mail_activity_rel', 'recommended_id', 'activity_id',
+ domain="['|', ('res_model_id', '=', False), ('res_model_id', '=', res_model_id)]",
+ string='Preceding Activities')
+ category = fields.Selection([
+ ('default', 'None'), ('upload_file', 'Upload Document')
+ ], default='default', string='Action to Perform',
+ help='Actions may trigger specific behavior like opening calendar view or automatically mark as done when a document is uploaded')
+ mail_template_ids = fields.Many2many('mail.template', string='Email templates')
+ default_user_id = fields.Many2one("res.users", string="Default User")
+ default_description = fields.Html(string="Default Description", translate=True)
+
+ #Fields for display purpose only
+ initial_res_model_id = fields.Many2one('ir.model', 'Initial model', compute="_compute_initial_res_model_id", store=False,
+ help='Technical field to keep track of the model at the start of editing to support UX related behaviour')
+ res_model_change = fields.Boolean(string="Model has change", help="Technical field for UX related behaviour", default=False, store=False)
+
+ @api.onchange('res_model_id')
+ def _onchange_res_model_id(self):
+ self.mail_template_ids = self.mail_template_ids.filtered(lambda template: template.model_id == self.res_model_id)
+ self.res_model_change = self.initial_res_model_id and self.initial_res_model_id != self.res_model_id
+
+ def _compute_initial_res_model_id(self):
+ for activity_type in self:
+ activity_type.initial_res_model_id = activity_type.res_model_id
+
+ @api.depends('delay_unit', 'delay_count')
+ def _compute_delay_label(self):
+ selection_description_values = {
+ e[0]: e[1] for e in self._fields['delay_unit']._description_selection(self.env)}
+ for activity_type in self:
+ unit = selection_description_values[activity_type.delay_unit]
+ activity_type.delay_label = '%s %s' % (activity_type.delay_count, unit)
+
+
+class MailActivity(models.Model):
+ """ An actual activity to perform. Activities are linked to
+ documents using res_id and res_model_id fields. Activities have a deadline
+ that can be used in kanban view to display a status. Once done activities
+ are unlinked and a message is posted. This message has a new activity_type_id
+ field that indicates the activity linked to the message. """
+ _name = 'mail.activity'
+ _description = 'Activity'
+ _order = 'date_deadline ASC'
+ _rec_name = 'summary'
+
+ @api.model
+ def default_get(self, fields):
+ res = super(MailActivity, self).default_get(fields)
+ if not fields or 'res_model_id' in fields and res.get('res_model'):
+ res['res_model_id'] = self.env['ir.model']._get(res['res_model']).id
+ return res
+
+ @api.model
+ def _default_activity_type_id(self):
+ ActivityType = self.env["mail.activity.type"]
+ activity_type_todo = self.env.ref('mail.mail_activity_data_todo', raise_if_not_found=False)
+ default_vals = self.default_get(['res_model_id', 'res_model'])
+ if not default_vals.get('res_model_id'):
+ return ActivityType
+ current_model_id = default_vals['res_model_id']
+ if activity_type_todo and activity_type_todo.active and (activity_type_todo.res_model_id.id == current_model_id or not activity_type_todo.res_model_id):
+ return activity_type_todo
+ activity_type_model = ActivityType.search([('res_model_id', '=', current_model_id)], limit=1)
+ if activity_type_model:
+ return activity_type_model
+ activity_type_generic = ActivityType.search([('res_model_id','=', False)], limit=1)
+ return activity_type_generic
+
+ # owner
+ res_model_id = fields.Many2one(
+ 'ir.model', 'Document Model',
+ index=True, ondelete='cascade', required=True)
+ res_model = fields.Char(
+ 'Related Document Model',
+ index=True, related='res_model_id.model', compute_sudo=True, store=True, readonly=True)
+ res_id = fields.Many2oneReference(string='Related Document ID', index=True, required=True, model_field='res_model')
+ res_name = fields.Char(
+ 'Document Name', compute='_compute_res_name', compute_sudo=True, store=True,
+ help="Display name of the related document.", readonly=True)
+ # activity
+ activity_type_id = fields.Many2one(
+ 'mail.activity.type', string='Activity Type',
+ domain="['|', ('res_model_id', '=', False), ('res_model_id', '=', res_model_id)]", ondelete='restrict',
+ default=_default_activity_type_id)
+ activity_category = fields.Selection(related='activity_type_id.category', readonly=True)
+ activity_decoration = fields.Selection(related='activity_type_id.decoration_type', readonly=True)
+ icon = fields.Char('Icon', related='activity_type_id.icon', readonly=True)
+ summary = fields.Char('Summary')
+ note = fields.Html('Note', sanitize_style=True)
+ date_deadline = fields.Date('Due Date', index=True, required=True, default=fields.Date.context_today)
+ automated = fields.Boolean(
+ 'Automated activity', readonly=True,
+ help='Indicates this activity has been created automatically and not by any user.')
+ # description
+ user_id = fields.Many2one(
+ 'res.users', 'Assigned to',
+ default=lambda self: self.env.user,
+ index=True, required=True)
+ request_partner_id = fields.Many2one('res.partner', string='Requesting Partner')
+ state = fields.Selection([
+ ('overdue', 'Overdue'),
+ ('today', 'Today'),
+ ('planned', 'Planned')], 'State',
+ compute='_compute_state')
+ recommended_activity_type_id = fields.Many2one('mail.activity.type', string="Recommended Activity Type")
+ previous_activity_type_id = fields.Many2one('mail.activity.type', string='Previous Activity Type', readonly=True)
+ has_recommended_activities = fields.Boolean(
+ 'Next activities available',
+ compute='_compute_has_recommended_activities',
+ help='Technical field for UX purpose')
+ mail_template_ids = fields.Many2many(related='activity_type_id.mail_template_ids', readonly=True)
+ force_next = fields.Boolean(related='activity_type_id.force_next', readonly=True)
+ # access
+ can_write = fields.Boolean(compute='_compute_can_write', help='Technical field to hide buttons if the current user has no access.')
+
+ @api.onchange('previous_activity_type_id')
+ def _compute_has_recommended_activities(self):
+ for record in self:
+ record.has_recommended_activities = bool(record.previous_activity_type_id.next_type_ids)
+
+ @api.onchange('previous_activity_type_id')
+ def _onchange_previous_activity_type_id(self):
+ for record in self:
+ if record.previous_activity_type_id.default_next_type_id:
+ record.activity_type_id = record.previous_activity_type_id.default_next_type_id
+
+ @api.depends('res_model', 'res_id')
+ def _compute_res_name(self):
+ for activity in self:
+ activity.res_name = activity.res_model and \
+ self.env[activity.res_model].browse(activity.res_id).display_name
+
+ @api.depends('date_deadline')
+ def _compute_state(self):
+ for record in self.filtered(lambda activity: activity.date_deadline):
+ tz = record.user_id.sudo().tz
+ date_deadline = record.date_deadline
+ record.state = self._compute_state_from_date(date_deadline, tz)
+
+ @api.model
+ def _compute_state_from_date(self, date_deadline, tz=False):
+ date_deadline = fields.Date.from_string(date_deadline)
+ today_default = date.today()
+ today = today_default
+ if tz:
+ today_utc = pytz.UTC.localize(datetime.utcnow())
+ today_tz = today_utc.astimezone(pytz.timezone(tz))
+ today = date(year=today_tz.year, month=today_tz.month, day=today_tz.day)
+ diff = (date_deadline - today)
+ if diff.days == 0:
+ return 'today'
+ elif diff.days < 0:
+ return 'overdue'
+ else:
+ return 'planned'
+
+ @api.depends('res_model', 'res_id', 'user_id')
+ def _compute_can_write(self):
+ valid_records = self._filter_access_rules('write')
+ for record in self:
+ record.can_write = record in valid_records
+
+ @api.onchange('activity_type_id')
+ def _onchange_activity_type_id(self):
+ if self.activity_type_id:
+ if self.activity_type_id.summary:
+ self.summary = self.activity_type_id.summary
+ self.date_deadline = self._calculate_date_deadline(self.activity_type_id)
+ self.user_id = self.activity_type_id.default_user_id or self.env.user
+ if self.activity_type_id.default_description:
+ self.note = self.activity_type_id.default_description
+
+ def _calculate_date_deadline(self, activity_type):
+ # Date.context_today is correct because date_deadline is a Date and is meant to be
+ # expressed in user TZ
+ base = fields.Date.context_today(self)
+ if activity_type.delay_from == 'previous_activity' and 'activity_previous_deadline' in self.env.context:
+ base = fields.Date.from_string(self.env.context.get('activity_previous_deadline'))
+ return base + relativedelta(**{activity_type.delay_unit: activity_type.delay_count})
+
+ @api.onchange('recommended_activity_type_id')
+ def _onchange_recommended_activity_type_id(self):
+ if self.recommended_activity_type_id:
+ self.activity_type_id = self.recommended_activity_type_id
+
+ def _filter_access_rules(self, operation):
+ # write / unlink: valid for creator / assigned
+ if operation in ('write', 'unlink'):
+ valid = super(MailActivity, self)._filter_access_rules(operation)
+ if valid and valid == self:
+ return self
+ else:
+ valid = self.env[self._name]
+ return self._filter_access_rules_remaining(valid, operation, '_filter_access_rules')
+
+ def _filter_access_rules_python(self, operation):
+ # write / unlink: valid for creator / assigned
+ if operation in ('write', 'unlink'):
+ valid = super(MailActivity, self)._filter_access_rules_python(operation)
+ if valid and valid == self:
+ return self
+ else:
+ valid = self.env[self._name]
+ return self._filter_access_rules_remaining(valid, operation, '_filter_access_rules_python')
+
+ def _filter_access_rules_remaining(self, valid, operation, filter_access_rules_method):
+ """ Return the subset of ``self`` for which ``operation`` is allowed.
+ A custom implementation is done on activities as this document has some
+ access rules and is based on related document for activities that are
+ not covered by those rules.
+
+ Access on activities are the following :
+
+ * create: (``mail_post_access`` or write) right on related documents;
+ * read: read rights on related documents;
+ * write: access rule OR
+ (``mail_post_access`` or write) rights on related documents);
+ * unlink: access rule OR
+ (``mail_post_access`` or write) rights on related documents);
+ """
+ # compute remaining for hand-tailored rules
+ remaining = self - valid
+ remaining_sudo = remaining.sudo()
+
+ # fall back on related document access right checks. Use the same as defined for mail.thread
+ # if available; otherwise fall back on read for read, write for other operations.
+ activity_to_documents = dict()
+ for activity in remaining_sudo:
+ # write / unlink: if not updating self or assigned, limit to automated activities to avoid
+ # updating other people's activities. As unlinking a document bypasses access rights checks
+ # on related activities this will not prevent people from deleting documents with activities
+ # create / read: just check rights on related document
+ activity_to_documents.setdefault(activity.res_model, list()).append(activity.res_id)
+ for doc_model, doc_ids in activity_to_documents.items():
+ if hasattr(self.env[doc_model], '_mail_post_access'):
+ doc_operation = self.env[doc_model]._mail_post_access
+ elif operation == 'read':
+ doc_operation = 'read'
+ else:
+ doc_operation = 'write'
+ right = self.env[doc_model].check_access_rights(doc_operation, raise_exception=False)
+ if right:
+ valid_doc_ids = getattr(self.env[doc_model].browse(doc_ids), filter_access_rules_method)(doc_operation)
+ valid += remaining.filtered(lambda activity: activity.res_model == doc_model and activity.res_id in valid_doc_ids.ids)
+
+ return valid
+
+ def _check_access_assignation(self):
+ """ Check assigned user (user_id field) has access to the document. Purpose
+ is to allow assigned user to handle their activities. For that purpose
+ assigned user should be able to at least read the document. We therefore
+ raise an UserError if the assigned user has no access to the document. """
+ for activity in self:
+ model = self.env[activity.res_model].with_user(activity.user_id).with_context(allowed_company_ids=activity.user_id.company_ids.ids)
+ try:
+ model.check_access_rights('read')
+ except exceptions.AccessError:
+ raise exceptions.UserError(
+ _('Assigned user %s has no access to the document and is not able to handle this activity.') %
+ activity.user_id.display_name)
+ else:
+ try:
+ target_user = activity.user_id
+ target_record = self.env[activity.res_model].browse(activity.res_id)
+ if hasattr(target_record, 'company_id') and (
+ target_record.company_id != target_user.company_id and (
+ len(target_user.sudo().company_ids) > 1)):
+ return # in that case we skip the check, assuming it would fail because of the company
+ model.browse(activity.res_id).check_access_rule('read')
+ except exceptions.AccessError:
+ raise exceptions.UserError(
+ _('Assigned user %s has no access to the document and is not able to handle this activity.') %
+ activity.user_id.display_name)
+
+ # ------------------------------------------------------
+ # ORM overrides
+ # ------------------------------------------------------
+
+ @api.model
+ def create(self, values):
+ activity = super(MailActivity, self).create(values)
+ need_sudo = False
+ try: # in multicompany, reading the partner might break
+ partner_id = activity.user_id.partner_id.id
+ except exceptions.AccessError:
+ need_sudo = True
+ partner_id = activity.user_id.sudo().partner_id.id
+
+ # send a notification to assigned user; in case of manually done activity also check
+ # target has rights on document otherwise we prevent its creation. Automated activities
+ # are checked since they are integrated into business flows that should not crash.
+ if activity.user_id != self.env.user:
+ if not activity.automated:
+ activity._check_access_assignation()
+ if not self.env.context.get('mail_activity_quick_update', False):
+ if need_sudo:
+ activity.sudo().action_notify()
+ else:
+ activity.action_notify()
+
+ self.env[activity.res_model].browse(activity.res_id).message_subscribe(partner_ids=[partner_id])
+ if activity.date_deadline <= fields.Date.today():
+ self.env['bus.bus'].sendone(
+ (self._cr.dbname, 'res.partner', activity.user_id.partner_id.id),
+ {'type': 'activity_updated', 'activity_created': True})
+ return activity
+
+ def write(self, values):
+ if values.get('user_id'):
+ user_changes = self.filtered(lambda activity: activity.user_id.id != values.get('user_id'))
+ pre_responsibles = user_changes.mapped('user_id.partner_id')
+ res = super(MailActivity, self).write(values)
+
+ if values.get('user_id'):
+ if values['user_id'] != self.env.uid:
+ to_check = user_changes.filtered(lambda act: not act.automated)
+ to_check._check_access_assignation()
+ if not self.env.context.get('mail_activity_quick_update', False):
+ user_changes.action_notify()
+ for activity in user_changes:
+ self.env[activity.res_model].browse(activity.res_id).message_subscribe(partner_ids=[activity.user_id.partner_id.id])
+ if activity.date_deadline <= fields.Date.today():
+ self.env['bus.bus'].sendone(
+ (self._cr.dbname, 'res.partner', activity.user_id.partner_id.id),
+ {'type': 'activity_updated', 'activity_created': True})
+ for activity in user_changes:
+ if activity.date_deadline <= fields.Date.today():
+ for partner in pre_responsibles:
+ self.env['bus.bus'].sendone(
+ (self._cr.dbname, 'res.partner', partner.id),
+ {'type': 'activity_updated', 'activity_deleted': True})
+ return res
+
+ def unlink(self):
+ for activity in self:
+ if activity.date_deadline <= fields.Date.today():
+ self.env['bus.bus'].sendone(
+ (self._cr.dbname, 'res.partner', activity.user_id.partner_id.id),
+ {'type': 'activity_updated', 'activity_deleted': True})
+ return super(MailActivity, self).unlink()
+
+ def name_get(self):
+ res = []
+ for record in self:
+ name = record.summary or record.activity_type_id.display_name
+ res.append((record.id, name))
+ return res
+
+ # ------------------------------------------------------
+ # Business Methods
+ # ------------------------------------------------------
+
+ def action_notify(self):
+ if not self:
+ return
+ original_context = self.env.context
+ body_template = self.env.ref('mail.message_activity_assigned')
+ for activity in self:
+ if activity.user_id.lang:
+ # Send the notification in the assigned user's language
+ self = self.with_context(lang=activity.user_id.lang)
+ body_template = body_template.with_context(lang=activity.user_id.lang)
+ activity = activity.with_context(lang=activity.user_id.lang)
+ model_description = self.env['ir.model']._get(activity.res_model).display_name
+ body = body_template._render(
+ dict(
+ activity=activity,
+ model_description=model_description,
+ access_link=self.env['mail.thread']._notify_get_action_link('view', model=activity.res_model, res_id=activity.res_id),
+ ),
+ engine='ir.qweb',
+ minimal_qcontext=True
+ )
+ record = self.env[activity.res_model].browse(activity.res_id)
+ if activity.user_id:
+ record.message_notify(
+ partner_ids=activity.user_id.partner_id.ids,
+ body=body,
+ subject=_('%(activity_name)s: %(summary)s assigned to you',
+ activity_name=activity.res_name,
+ summary=activity.summary or activity.activity_type_id.name),
+ record_name=activity.res_name,
+ model_description=model_description,
+ email_layout_xmlid='mail.mail_notification_light',
+ )
+ body_template = body_template.with_context(original_context)
+ self = self.with_context(original_context)
+
+ def action_done(self):
+ """ Wrapper without feedback because web button add context as
+ parameter, therefore setting context to feedback """
+ messages, next_activities = self._action_done()
+ return messages.ids and messages.ids[0] or False
+
+ def action_feedback(self, feedback=False, attachment_ids=None):
+ self = self.with_context(clean_context(self.env.context))
+ messages, next_activities = self._action_done(feedback=feedback, attachment_ids=attachment_ids)
+ return messages.ids and messages.ids[0] or False
+
+ def action_done_schedule_next(self):
+ """ Wrapper without feedback because web button add context as
+ parameter, therefore setting context to feedback """
+ return self.action_feedback_schedule_next()
+
+ def action_feedback_schedule_next(self, feedback=False):
+ ctx = dict(
+ clean_context(self.env.context),
+ default_previous_activity_type_id=self.activity_type_id.id,
+ activity_previous_deadline=self.date_deadline,
+ default_res_id=self.res_id,
+ default_res_model=self.res_model,
+ )
+ messages, next_activities = self._action_done(feedback=feedback) # will unlink activity, dont access self after that
+ if next_activities:
+ return False
+ return {
+ 'name': _('Schedule an Activity'),
+ 'context': ctx,
+ 'view_mode': 'form',
+ 'res_model': 'mail.activity',
+ 'views': [(False, 'form')],
+ 'type': 'ir.actions.act_window',
+ 'target': 'new',
+ }
+
+ def _action_done(self, feedback=False, attachment_ids=None):
+ """ Private implementation of marking activity as done: posting a message, deleting activity
+ (since done), and eventually create the automatical next activity (depending on config).
+ :param feedback: optional feedback from user when marking activity as done
+ :param attachment_ids: list of ir.attachment ids to attach to the posted mail.message
+ :returns (messages, activities) where
+ - messages is a recordset of posted mail.message
+ - activities is a recordset of mail.activity of forced automically created activities
+ """
+ # marking as 'done'
+ messages = self.env['mail.message']
+ next_activities_values = []
+
+ # Search for all attachments linked to the activities we are about to unlink. This way, we
+ # can link them to the message posted and prevent their deletion.
+ attachments = self.env['ir.attachment'].search_read([
+ ('res_model', '=', self._name),
+ ('res_id', 'in', self.ids),
+ ], ['id', 'res_id'])
+
+ activity_attachments = defaultdict(list)
+ for attachment in attachments:
+ activity_id = attachment['res_id']
+ activity_attachments[activity_id].append(attachment['id'])
+
+ for activity in self:
+ # extract value to generate next activities
+ if activity.force_next:
+ Activity = self.env['mail.activity'].with_context(activity_previous_deadline=activity.date_deadline) # context key is required in the onchange to set deadline
+ vals = Activity.default_get(Activity.fields_get())
+
+ vals.update({
+ 'previous_activity_type_id': activity.activity_type_id.id,
+ 'res_id': activity.res_id,
+ 'res_model': activity.res_model,
+ 'res_model_id': self.env['ir.model']._get(activity.res_model).id,
+ })
+ virtual_activity = Activity.new(vals)
+ virtual_activity._onchange_previous_activity_type_id()
+ virtual_activity._onchange_activity_type_id()
+ next_activities_values.append(virtual_activity._convert_to_write(virtual_activity._cache))
+
+ # post message on activity, before deleting it
+ record = self.env[activity.res_model].browse(activity.res_id)
+ record.message_post_with_view(
+ 'mail.message_activity_done',
+ values={
+ 'activity': activity,
+ 'feedback': feedback,
+ 'display_assignee': activity.user_id != self.env.user
+ },
+ subtype_id=self.env['ir.model.data'].xmlid_to_res_id('mail.mt_activities'),
+ mail_activity_type_id=activity.activity_type_id.id,
+ attachment_ids=[(4, attachment_id) for attachment_id in attachment_ids] if attachment_ids else [],
+ )
+
+ # Moving the attachments in the message
+ # TODO: Fix void res_id on attachment when you create an activity with an image
+ # directly, see route /web_editor/attachment/add
+ activity_message = record.message_ids[0]
+ message_attachments = self.env['ir.attachment'].browse(activity_attachments[activity.id])
+ if message_attachments:
+ message_attachments.write({
+ 'res_id': activity_message.id,
+ 'res_model': activity_message._name,
+ })
+ activity_message.attachment_ids = message_attachments
+ messages |= activity_message
+
+ next_activities = self.env['mail.activity'].create(next_activities_values)
+ self.unlink() # will unlink activity, dont access `self` after that
+
+ return messages, next_activities
+
+ def action_close_dialog(self):
+ return {'type': 'ir.actions.act_window_close'}
+
+ def activity_format(self):
+ activities = self.read()
+ mail_template_ids = set([template_id for activity in activities for template_id in activity["mail_template_ids"]])
+ mail_template_info = self.env["mail.template"].browse(mail_template_ids).read(['id', 'name'])
+ mail_template_dict = dict([(mail_template['id'], mail_template) for mail_template in mail_template_info])
+ for activity in activities:
+ activity['mail_template_ids'] = [mail_template_dict[mail_template_id] for mail_template_id in activity['mail_template_ids']]
+ return activities
+
+ @api.model
+ def get_activity_data(self, res_model, domain):
+ activity_domain = [('res_model', '=', res_model)]
+ if domain:
+ res = self.env[res_model].search(domain)
+ activity_domain.append(('res_id', 'in', res.ids))
+ grouped_activities = self.env['mail.activity'].read_group(
+ activity_domain,
+ ['res_id', 'activity_type_id', 'ids:array_agg(id)', 'date_deadline:min(date_deadline)'],
+ ['res_id', 'activity_type_id'],
+ lazy=False)
+ # filter out unreadable records
+ if not domain:
+ res_ids = tuple(a['res_id'] for a in grouped_activities)
+ res = self.env[res_model].search([('id', 'in', res_ids)])
+ grouped_activities = [a for a in grouped_activities if a['res_id'] in res.ids]
+ res_id_to_deadline = {}
+ activity_data = defaultdict(dict)
+ for group in grouped_activities:
+ res_id = group['res_id']
+ activity_type_id = (group.get('activity_type_id') or (False, False))[0]
+ res_id_to_deadline[res_id] = group['date_deadline'] if (res_id not in res_id_to_deadline or group['date_deadline'] < res_id_to_deadline[res_id]) else res_id_to_deadline[res_id]
+ state = self._compute_state_from_date(group['date_deadline'], self.user_id.sudo().tz)
+ activity_data[res_id][activity_type_id] = {
+ 'count': group['__count'],
+ 'ids': group['ids'],
+ 'state': state,
+ 'o_closest_deadline': group['date_deadline'],
+ }
+ activity_type_infos = []
+ activity_type_ids = self.env['mail.activity.type'].search(['|', ('res_model_id.model', '=', res_model), ('res_model_id', '=', False)])
+ for elem in sorted(activity_type_ids, key=lambda item: item.sequence):
+ mail_template_info = []
+ for mail_template_id in elem.mail_template_ids:
+ mail_template_info.append({"id": mail_template_id.id, "name": mail_template_id.name})
+ activity_type_infos.append([elem.id, elem.name, mail_template_info])
+
+ return {
+ 'activity_types': activity_type_infos,
+ 'activity_res_ids': sorted(res_id_to_deadline, key=lambda item: res_id_to_deadline[item]),
+ 'grouped_activities': activity_data,
+ }
+
+
+class MailActivityMixin(models.AbstractModel):
+ """ Mail Activity Mixin is a mixin class to use if you want to add activities
+ management on a model. It works like the mail.thread mixin. It defines
+ an activity_ids one2many field toward activities using res_id and res_model_id.
+ Various related / computed fields are also added to have a global status of
+ activities on documents.
+
+ Activities come with a new JS widget for the form view. It is integrated in the
+ Chatter widget although it is a separate widget. It displays activities linked
+ to the current record and allow to schedule, edit and mark done activities.
+ Just include field activity_ids in the div.oe-chatter to use it.
+
+ There is also a kanban widget defined. It defines a small widget to integrate
+ in kanban vignettes. It allow to manage activities directly from the kanban
+ view. Use widget="kanban_activity" on activitiy_ids field in kanban view to
+ use it.
+
+ Some context keys allow to control the mixin behavior. Use those in some
+ specific cases like import
+
+ * ``mail_activity_automation_skip``: skip activities automation; it means
+ no automated activities will be generated, updated or unlinked, allowing
+ to save computation and avoid generating unwanted activities;
+ """
+ _name = 'mail.activity.mixin'
+ _description = 'Activity Mixin'
+
+ def _default_activity_type(self):
+ """Define a default fallback activity type when requested xml id wasn't found.
+
+ Can be overriden to specify the default activity type of a model.
+ It is only called in in activity_schedule() for now.
+ """
+ return self.env.ref('mail.mail_activity_data_todo', raise_if_not_found=False) \
+ or self.env['mail.activity.type'].search([('res_model', '=', self._name)], limit=1) \
+ or self.env['mail.activity.type'].search([('res_model_id', '=', False)], limit=1)
+
+ activity_ids = fields.One2many(
+ 'mail.activity', 'res_id', 'Activities',
+ auto_join=True,
+ groups="base.group_user",)
+ activity_state = fields.Selection([
+ ('overdue', 'Overdue'),
+ ('today', 'Today'),
+ ('planned', 'Planned')], string='Activity State',
+ compute='_compute_activity_state',
+ groups="base.group_user",
+ help='Status based on activities\nOverdue: Due date is already passed\n'
+ 'Today: Activity date is today\nPlanned: Future activities.')
+ activity_user_id = fields.Many2one(
+ 'res.users', 'Responsible User',
+ related='activity_ids.user_id', readonly=False,
+ search='_search_activity_user_id',
+ groups="base.group_user")
+ activity_type_id = fields.Many2one(
+ 'mail.activity.type', 'Next Activity Type',
+ related='activity_ids.activity_type_id', readonly=False,
+ search='_search_activity_type_id',
+ groups="base.group_user")
+ activity_type_icon = fields.Char('Activity Type Icon', related='activity_ids.icon')
+ activity_date_deadline = fields.Date(
+ 'Next Activity Deadline',
+ compute='_compute_activity_date_deadline', search='_search_activity_date_deadline',
+ compute_sudo=False, readonly=True, store=False,
+ groups="base.group_user")
+ my_activity_date_deadline = fields.Date(
+ 'My Activity Deadline',
+ compute='_compute_my_activity_date_deadline', search='_search_my_activity_date_deadline',
+ compute_sudo=False, readonly=True, groups="base.group_user")
+ activity_summary = fields.Char(
+ 'Next Activity Summary',
+ related='activity_ids.summary', readonly=False,
+ search='_search_activity_summary',
+ groups="base.group_user",)
+ activity_exception_decoration = fields.Selection([
+ ('warning', 'Alert'),
+ ('danger', 'Error')],
+ compute='_compute_activity_exception_type',
+ search='_search_activity_exception_decoration',
+ help="Type of the exception activity on record.")
+ activity_exception_icon = fields.Char('Icon', help="Icon to indicate an exception activity.",
+ compute='_compute_activity_exception_type')
+
+ @api.depends('activity_ids.activity_type_id.decoration_type', 'activity_ids.activity_type_id.icon')
+ def _compute_activity_exception_type(self):
+ # prefetch all activity types for all activities, this will avoid any query in loops
+ self.mapped('activity_ids.activity_type_id.decoration_type')
+
+ for record in self:
+ activity_type_ids = record.activity_ids.mapped('activity_type_id')
+ exception_activity_type_id = False
+ for activity_type_id in activity_type_ids:
+ if activity_type_id.decoration_type == 'danger':
+ exception_activity_type_id = activity_type_id
+ break
+ if activity_type_id.decoration_type == 'warning':
+ exception_activity_type_id = activity_type_id
+ record.activity_exception_decoration = exception_activity_type_id and exception_activity_type_id.decoration_type
+ record.activity_exception_icon = exception_activity_type_id and exception_activity_type_id.icon
+
+ def _search_activity_exception_decoration(self, operator, operand):
+ return [('activity_ids.activity_type_id.decoration_type', operator, operand)]
+
+ @api.depends('activity_ids.state')
+ def _compute_activity_state(self):
+ for record in self:
+ states = record.activity_ids.mapped('state')
+ if 'overdue' in states:
+ record.activity_state = 'overdue'
+ elif 'today' in states:
+ record.activity_state = 'today'
+ elif 'planned' in states:
+ record.activity_state = 'planned'
+ else:
+ record.activity_state = False
+
+ @api.depends('activity_ids.date_deadline')
+ def _compute_activity_date_deadline(self):
+ for record in self:
+ record.activity_date_deadline = record.activity_ids[:1].date_deadline
+
+ def _search_activity_date_deadline(self, operator, operand):
+ if operator == '=' and not operand:
+ return [('activity_ids', '=', False)]
+ return [('activity_ids.date_deadline', operator, operand)]
+
+ @api.model
+ def _search_activity_user_id(self, operator, operand):
+ return [('activity_ids.user_id', operator, operand)]
+
+ @api.model
+ def _search_activity_type_id(self, operator, operand):
+ return [('activity_ids.activity_type_id', operator, operand)]
+
+ @api.model
+ def _search_activity_summary(self, operator, operand):
+ return [('activity_ids.summary', operator, operand)]
+
+ @api.depends('activity_ids.date_deadline', 'activity_ids.user_id')
+ @api.depends_context('uid')
+ def _compute_my_activity_date_deadline(self):
+ for record in self:
+ record.my_activity_date_deadline = next((
+ activity.date_deadline
+ for activity in record.activity_ids
+ if activity.user_id.id == record.env.uid
+ ), False)
+
+ def _search_my_activity_date_deadline(self, operator, operand):
+ activity_ids = self.env['mail.activity']._search([
+ ('date_deadline', operator, operand),
+ ('res_model', '=', self._name),
+ ('user_id', '=', self.env.user.id)
+ ])
+ return [('activity_ids', 'in', activity_ids)]
+
+ def write(self, vals):
+ # Delete activities of archived record.
+ if 'active' in vals and vals['active'] is False:
+ self.env['mail.activity'].sudo().search(
+ [('res_model', '=', self._name), ('res_id', 'in', self.ids)]
+ ).unlink()
+ return super(MailActivityMixin, self).write(vals)
+
+ def unlink(self):
+ """ Override unlink to delete records activities through (res_model, res_id). """
+ record_ids = self.ids
+ result = super(MailActivityMixin, self).unlink()
+ self.env['mail.activity'].sudo().search(
+ [('res_model', '=', self._name), ('res_id', 'in', record_ids)]
+ ).unlink()
+ return result
+
+ def _read_progress_bar(self, domain, group_by, progress_bar):
+ group_by_fname = group_by.partition(':')[0]
+ if not (progress_bar['field'] == 'activity_state' and self._fields[group_by_fname].store):
+ return super()._read_progress_bar(domain, group_by, progress_bar)
+
+ # optimization for 'activity_state'
+
+ # explicitly check access rights, since we bypass the ORM
+ self.check_access_rights('read')
+ self._flush_search(domain, fields=[group_by_fname], order='id')
+ self.env['mail.activity'].flush(['res_model', 'res_id', 'user_id', 'date_deadline'])
+
+ query = self._where_calc(domain)
+ self._apply_ir_rules(query, 'read')
+ gb = group_by.partition(':')[0]
+ annotated_groupbys = [
+ self._read_group_process_groupby(gb, query)
+ for gb in [group_by, 'activity_state']
+ ]
+ groupby_dict = {gb['groupby']: gb for gb in annotated_groupbys}
+ for gb in annotated_groupbys:
+ if gb['field'] == 'activity_state':
+ gb['qualified_field'] = '"_last_activity_state"."activity_state"'
+ groupby_terms, orderby_terms = self._read_group_prepare('activity_state', [], annotated_groupbys, query)
+ select_terms = [
+ '%s as "%s"' % (gb['qualified_field'], gb['groupby'])
+ for gb in annotated_groupbys
+ ]
+ from_clause, where_clause, where_params = query.get_sql()
+ tz = self._context.get('tz') or self.env.user.tz or 'UTC'
+ select_query = """
+ SELECT 1 AS id, count(*) AS "__count", {fields}
+ FROM {from_clause}
+ JOIN (
+ SELECT res_id,
+ CASE
+ WHEN min(date_deadline - (now() AT TIME ZONE COALESCE(res_partner.tz, %s))::date) > 0 THEN 'planned'
+ WHEN min(date_deadline - (now() AT TIME ZONE COALESCE(res_partner.tz, %s))::date) < 0 THEN 'overdue'
+ WHEN min(date_deadline - (now() AT TIME ZONE COALESCE(res_partner.tz, %s))::date) = 0 THEN 'today'
+ ELSE null
+ END AS activity_state
+ FROM mail_activity
+ JOIN res_users ON (res_users.id = mail_activity.user_id)
+ JOIN res_partner ON (res_partner.id = res_users.partner_id)
+ WHERE res_model = '{model}'
+ GROUP BY res_id
+ ) AS "_last_activity_state" ON ("{table}".id = "_last_activity_state".res_id)
+ WHERE {where_clause}
+ GROUP BY {group_by}
+ """.format(
+ fields=', '.join(select_terms),
+ from_clause=from_clause,
+ model=self._name,
+ table=self._table,
+ where_clause=where_clause or '1=1',
+ group_by=', '.join(groupby_terms),
+ )
+ self.env.cr.execute(select_query, [tz] * 3 + where_params)
+ fetched_data = self.env.cr.dictfetchall()
+ self._read_group_resolve_many2one_fields(fetched_data, annotated_groupbys)
+ data = [
+ {key: self._read_group_prepare_data(key, val, groupby_dict)
+ for key, val in row.items()}
+ for row in fetched_data
+ ]
+ return [
+ self._read_group_format_result(vals, annotated_groupbys, [group_by], domain)
+ for vals in data
+ ]
+
+ def toggle_active(self):
+ """ Before archiving the record we should also remove its ongoing
+ activities. Otherwise they stay in the systray and concerning archived
+ records it makes no sense. """
+ record_to_deactivate = self.filtered(lambda rec: rec[rec._active_name])
+ if record_to_deactivate:
+ # use a sudo to bypass every access rights; all activities should be removed
+ self.env['mail.activity'].sudo().search([
+ ('res_model', '=', self._name),
+ ('res_id', 'in', record_to_deactivate.ids)
+ ]).unlink()
+ return super(MailActivityMixin, self).toggle_active()
+
+ def activity_send_mail(self, template_id):
+ """ Automatically send an email based on the given mail.template, given
+ its ID. """
+ template = self.env['mail.template'].browse(template_id).exists()
+ if not template:
+ return False
+ for record in self.with_context(mail_post_autofollow=True):
+ record.message_post_with_template(
+ template_id,
+ composition_mode='comment'
+ )
+ return True
+
+ def activity_search(self, act_type_xmlids='', user_id=None, additional_domain=None):
+ """ Search automated activities on current record set, given a list of activity
+ types xml IDs. It is useful when dealing with specific types involved in automatic
+ activities management.
+
+ :param act_type_xmlids: list of activity types xml IDs
+ :param user_id: if set, restrict to activities of that user_id;
+ :param additional_domain: if set, filter on that domain;
+ """
+ if self.env.context.get('mail_activity_automation_skip'):
+ return False
+
+ Data = self.env['ir.model.data'].sudo()
+ activity_types_ids = [type_id for type_id in (Data.xmlid_to_res_id(xmlid, raise_if_not_found=False) for xmlid in act_type_xmlids) if type_id]
+ if not any(activity_types_ids):
+ return False
+
+ domain = [
+ '&', '&', '&',
+ ('res_model', '=', self._name),
+ ('res_id', 'in', self.ids),
+ ('automated', '=', True),
+ ('activity_type_id', 'in', activity_types_ids)
+ ]
+
+ if user_id:
+ domain = expression.AND([domain, [('user_id', '=', user_id)]])
+ if additional_domain:
+ domain = expression.AND([domain, additional_domain])
+
+ return self.env['mail.activity'].search(domain)
+
+ def activity_schedule(self, act_type_xmlid='', date_deadline=None, summary='', note='', **act_values):
+ """ Schedule an activity on each record of the current record set.
+ This method allow to provide as parameter act_type_xmlid. This is an
+ xml_id of activity type instead of directly giving an activity_type_id.
+ It is useful to avoid having various "env.ref" in the code and allow
+ to let the mixin handle access rights.
+
+ :param date_deadline: the day the activity must be scheduled on
+ the timezone of the user must be considered to set the correct deadline
+ """
+ if self.env.context.get('mail_activity_automation_skip'):
+ return False
+
+ if not date_deadline:
+ date_deadline = fields.Date.context_today(self)
+ if isinstance(date_deadline, datetime):
+ _logger.warning("Scheduled deadline should be a date (got %s)", date_deadline)
+ if act_type_xmlid:
+ activity_type = self.env.ref(act_type_xmlid, raise_if_not_found=False) or self._default_activity_type()
+ else:
+ activity_type_id = act_values.get('activity_type_id', False)
+ activity_type = activity_type_id and self.env['mail.activity.type'].sudo().browse(activity_type_id)
+
+ model_id = self.env['ir.model']._get(self._name).id
+ activities = self.env['mail.activity']
+ for record in self:
+ create_vals = {
+ 'activity_type_id': activity_type and activity_type.id,
+ 'summary': summary or activity_type.summary,
+ 'automated': True,
+ 'note': note or activity_type.default_description,
+ 'date_deadline': date_deadline,
+ 'res_model_id': model_id,
+ 'res_id': record.id,
+ 'user_id': act_values.get('user_id') or activity_type.default_user_id.id or self.env.uid
+ }
+ create_vals.update(act_values)
+ activities |= self.env['mail.activity'].create(create_vals)
+ return activities
+
+ def _activity_schedule_with_view(self, act_type_xmlid='', date_deadline=None, summary='', views_or_xmlid='', render_context=None, **act_values):
+ """ Helper method: Schedule an activity on each record of the current record set.
+ This method allow to the same mecanism as `activity_schedule`, but provide
+ 2 additionnal parameters:
+ :param views_or_xmlid: record of ir.ui.view or string representing the xmlid
+ of the qweb template to render
+ :type views_or_xmlid: string or recordset
+ :param render_context: the values required to render the given qweb template
+ :type render_context: dict
+ """
+ if self.env.context.get('mail_activity_automation_skip'):
+ return False
+
+ render_context = render_context or dict()
+ if isinstance(views_or_xmlid, str):
+ views = self.env.ref(views_or_xmlid, raise_if_not_found=False)
+ else:
+ views = views_or_xmlid
+ if not views:
+ return
+ activities = self.env['mail.activity']
+ for record in self:
+ render_context['object'] = record
+ note = views._render(render_context, engine='ir.qweb', minimal_qcontext=True)
+ activities |= record.activity_schedule(act_type_xmlid=act_type_xmlid, date_deadline=date_deadline, summary=summary, note=note, **act_values)
+ return activities
+
+ def activity_reschedule(self, act_type_xmlids, user_id=None, date_deadline=None, new_user_id=None):
+ """ Reschedule some automated activities. Activities to reschedule are
+ selected based on type xml ids and optionally by user. Purpose is to be
+ able to
+
+ * update the deadline to date_deadline;
+ * update the responsible to new_user_id;
+ """
+ if self.env.context.get('mail_activity_automation_skip'):
+ return False
+
+ Data = self.env['ir.model.data'].sudo()
+ activity_types_ids = [Data.xmlid_to_res_id(xmlid, raise_if_not_found=False) for xmlid in act_type_xmlids]
+ activity_types_ids = [act_type_id for act_type_id in activity_types_ids if act_type_id]
+ if not any(activity_types_ids):
+ return False
+ activities = self.activity_search(act_type_xmlids, user_id=user_id)
+ if activities:
+ write_vals = {}
+ if date_deadline:
+ write_vals['date_deadline'] = date_deadline
+ if new_user_id:
+ write_vals['user_id'] = new_user_id
+ activities.write(write_vals)
+ return activities
+
+ def activity_feedback(self, act_type_xmlids, user_id=None, feedback=None):
+ """ Set activities as done, limiting to some activity types and
+ optionally to a given user. """
+ if self.env.context.get('mail_activity_automation_skip'):
+ return False
+
+ Data = self.env['ir.model.data'].sudo()
+ activity_types_ids = [Data.xmlid_to_res_id(xmlid, raise_if_not_found=False) for xmlid in act_type_xmlids]
+ activity_types_ids = [act_type_id for act_type_id in activity_types_ids if act_type_id]
+ if not any(activity_types_ids):
+ return False
+ activities = self.activity_search(act_type_xmlids, user_id=user_id)
+ if activities:
+ activities.action_feedback(feedback=feedback)
+ return True
+
+ def activity_unlink(self, act_type_xmlids, user_id=None):
+ """ Unlink activities, limiting to some activity types and optionally
+ to a given user. """
+ if self.env.context.get('mail_activity_automation_skip'):
+ return False
+
+ Data = self.env['ir.model.data'].sudo()
+ activity_types_ids = [Data.xmlid_to_res_id(xmlid, raise_if_not_found=False) for xmlid in act_type_xmlids]
+ activity_types_ids = [act_type_id for act_type_id in activity_types_ids if act_type_id]
+ if not any(activity_types_ids):
+ return False
+ self.activity_search(act_type_xmlids, user_id=user_id).unlink()
+ return True
diff --git a/addons/mail/models/mail_alias.py b/addons/mail/models/mail_alias.py
new file mode 100644
index 00000000..7ccb1f13
--- /dev/null
+++ b/addons/mail/models/mail_alias.py
@@ -0,0 +1,205 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import ast
+import re
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError, UserError
+from odoo.tools import remove_accents, is_html_empty
+
+# see rfc5322 section 3.2.3
+atext = r"[a-zA-Z0-9!#$%&'*+\-/=?^_`{|}~]"
+dot_atom_text = re.compile(r"^%s+(\.%s+)*$" % (atext, atext))
+
+
+class Alias(models.Model):
+ """A Mail Alias is a mapping of an email address with a given Odoo Document
+ model. It is used by Odoo's mail gateway when processing incoming emails
+ sent to the system. If the recipient address (To) of the message matches
+ a Mail Alias, the message will be either processed following the rules
+ of that alias. If the message is a reply it will be attached to the
+ existing discussion on the corresponding record, otherwise a new
+ record of the corresponding model will be created.
+
+ This is meant to be used in combination with a catch-all email configuration
+ on the company's mail server, so that as soon as a new mail.alias is
+ created, it becomes immediately usable and Odoo will accept email for it.
+ """
+ _name = 'mail.alias'
+ _description = "Email Aliases"
+ _rec_name = 'alias_name'
+ _order = 'alias_model_id, alias_name'
+
+ def _default_alias_domain(self):
+ return self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain")
+
+ alias_name = fields.Char('Alias Name', copy=False, help="The name of the email alias, e.g. 'jobs' if you want to catch emails for <jobs@example.odoo.com>")
+ alias_model_id = fields.Many2one('ir.model', 'Aliased Model', required=True, ondelete="cascade",
+ help="The model (Odoo Document Kind) to which this alias "
+ "corresponds. Any incoming email that does not reply to an "
+ "existing record will cause the creation of a new record "
+ "of this model (e.g. a Project Task)",
+ # hack to only allow selecting mail_thread models (we might
+ # (have a few false positives, though)
+ domain="[('field_id.name', '=', 'message_ids')]")
+ alias_user_id = fields.Many2one('res.users', 'Owner', default=lambda self: self.env.user,
+ help="The owner of records created upon receiving emails on this alias. "
+ "If this field is not set the system will attempt to find the right owner "
+ "based on the sender (From) address, or will use the Administrator account "
+ "if no system user is found for that address.")
+ alias_defaults = fields.Text('Default Values', required=True, default='{}',
+ help="A Python dictionary that will be evaluated to provide "
+ "default values when creating new records for this alias.")
+ alias_force_thread_id = fields.Integer(
+ 'Record Thread ID',
+ help="Optional ID of a thread (record) to which all incoming messages will be attached, even "
+ "if they did not reply to it. If set, this will disable the creation of new records completely.")
+ alias_domain = fields.Char('Alias domain', compute='_compute_alias_domain', default=_default_alias_domain)
+ alias_parent_model_id = fields.Many2one(
+ 'ir.model', 'Parent Model',
+ help="Parent model holding the alias. The model holding the alias reference "
+ "is not necessarily the model given by alias_model_id "
+ "(example: project (parent_model) and task (model))")
+ alias_parent_thread_id = fields.Integer('Parent Record Thread ID', help="ID of the parent record holding the alias (example: project holding the task creation alias)")
+ alias_contact = fields.Selection([
+ ('everyone', 'Everyone'),
+ ('partners', 'Authenticated Partners'),
+ ('followers', 'Followers only')], default='everyone',
+ string='Alias Contact Security', required=True,
+ help="Policy to post a message on the document using the mailgateway.\n"
+ "- everyone: everyone can post\n"
+ "- partners: only authenticated partners\n"
+ "- followers: only followers of the related document or members of following channels\n")
+ alias_bounced_content = fields.Html(
+ "Custom Bounced Message", translate=True,
+ help="If set, this content will automatically be sent out to unauthorized users instead of the default message.")
+
+ _sql_constraints = [
+ ('alias_unique', 'UNIQUE(alias_name)', 'Unfortunately this email alias is already used, please choose a unique one')
+ ]
+
+ @api.constrains('alias_name')
+ def _alias_is_ascii(self):
+ """ The local-part ("display-name" <local-part@domain>) of an
+ address only contains limited range of ascii characters.
+ We DO NOT allow anything else than ASCII dot-atom formed
+ local-part. Quoted-string and internationnal characters are
+ to be rejected. See rfc5322 sections 3.4.1 and 3.2.3
+ """
+ if any(alias.alias_name and not dot_atom_text.match(alias.alias_name) for alias in self):
+ raise ValidationError(_("You cannot use anything else than unaccented latin characters in the alias address."))
+
+ def _compute_alias_domain(self):
+ alias_domain = self._default_alias_domain()
+ for record in self:
+ record.alias_domain = alias_domain
+
+ @api.constrains('alias_defaults')
+ def _check_alias_defaults(self):
+ for alias in self:
+ try:
+ dict(ast.literal_eval(alias.alias_defaults))
+ except Exception:
+ raise ValidationError(_('Invalid expression, it must be a literal python dictionary definition e.g. "{\'field\': \'value\'}"'))
+
+ @api.model
+ def create(self, vals):
+ """ Creates an email.alias record according to the values provided in ``vals``,
+ with 2 alterations: the ``alias_name`` value may be cleaned by replacing
+ certain unsafe characters, and the ``alias_model_id`` value will set to the
+ model ID of the ``model_name`` context value, if provided. Also, it raises
+ UserError if given alias name is already assigned.
+ """
+ if vals.get('alias_name'):
+ vals['alias_name'] = self._clean_and_check_unique(vals.get('alias_name'))
+ return super(Alias, self).create(vals)
+
+ def write(self, vals):
+ """"Raises UserError if given alias name is already assigned"""
+ if vals.get('alias_name') and self.ids:
+ vals['alias_name'] = self._clean_and_check_unique(vals.get('alias_name'))
+ return super(Alias, self).write(vals)
+
+ def name_get(self):
+ """Return the mail alias display alias_name, including the implicit
+ mail catchall domain if exists from config otherwise "New Alias".
+ e.g. `jobs@mail.odoo.com` or `jobs` or 'New Alias'
+ """
+ res = []
+ for record in self:
+ if record.alias_name and record.alias_domain:
+ res.append((record['id'], "%s@%s" % (record.alias_name, record.alias_domain)))
+ elif record.alias_name:
+ res.append((record['id'], "%s" % (record.alias_name)))
+ else:
+ res.append((record['id'], _("Inactive Alias")))
+ return res
+
+ def _clean_and_check_unique(self, name):
+ """When an alias name appears to already be an email, we keep the local
+ part only. A sanitizing / cleaning is also performed on the name. If
+ name already exists an UserError is raised. """
+ sanitized_name = remove_accents(name).lower().split('@')[0]
+ sanitized_name = re.sub(r'[^\w+.]+', '-', sanitized_name)
+ sanitized_name = re.sub(r'^\.+|\.+$|\.+(?=\.)', '', sanitized_name)
+ sanitized_name = sanitized_name.encode('ascii', errors='replace').decode()
+
+ catchall_alias = self.env['ir.config_parameter'].sudo().get_param('mail.catchall.alias')
+ bounce_alias = self.env['ir.config_parameter'].sudo().get_param('mail.bounce.alias')
+ domain = [('alias_name', '=', sanitized_name)]
+ if self:
+ domain += [('id', 'not in', self.ids)]
+ if sanitized_name in [catchall_alias, bounce_alias] or self.search_count(domain):
+ raise UserError(_('The e-mail alias is already used. Please enter another one.'))
+ return sanitized_name
+
+ def open_document(self):
+ if not self.alias_model_id or not self.alias_force_thread_id:
+ return False
+ return {
+ 'view_mode': 'form',
+ 'res_model': self.alias_model_id.model,
+ 'res_id': self.alias_force_thread_id,
+ 'type': 'ir.actions.act_window',
+ }
+
+ def open_parent_document(self):
+ if not self.alias_parent_model_id or not self.alias_parent_thread_id:
+ return False
+ return {
+ 'view_mode': 'form',
+ 'res_model': self.alias_parent_model_id.model,
+ 'res_id': self.alias_parent_thread_id,
+ 'type': 'ir.actions.act_window',
+ }
+
+ def _get_alias_bounced_body_fallback(self, message_dict):
+ return _("""Hi,<br/>
+The following email sent to %s cannot be accepted because this is a private email address.
+Only allowed people can contact us at this address.""", self.display_name)
+
+ def _get_alias_bounced_body(self, message_dict):
+ """Get the body of the email return in case of bounced email.
+
+ :param message_dict: dictionary of mail values
+ """
+ lang_author = False
+ if message_dict.get('author_id'):
+ try:
+ lang_author = self.env['res.partner'].browse(message_dict['author_id']).lang
+ except:
+ pass
+
+ if lang_author:
+ self = self.with_context(lang=lang_author)
+
+ if not is_html_empty(self.alias_bounced_content):
+ body = self.alias_bounced_content
+ else:
+ body = self._get_alias_bounced_body_fallback(message_dict)
+ template = self.env.ref('mail.mail_bounce_alias_security', raise_if_not_found=True)
+ return template._render({
+ 'body': body,
+ 'message': message_dict
+ }, engine='ir.qweb', minimal_qcontext=True)
diff --git a/addons/mail/models/mail_alias_mixin.py b/addons/mail/models/mail_alias_mixin.py
new file mode 100644
index 00000000..08c7513e
--- /dev/null
+++ b/addons/mail/models/mail_alias_mixin.py
@@ -0,0 +1,130 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+
+from odoo import _, api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+class AliasMixin(models.AbstractModel):
+ """ A mixin for models that inherits mail.alias. This mixin initializes the
+ alias_id column in database, and manages the expected one-to-one
+ relation between your model and mail aliases.
+ """
+ _name = 'mail.alias.mixin'
+ _inherits = {'mail.alias': 'alias_id'}
+ _description = 'Email Aliases Mixin'
+ ALIAS_WRITEABLE_FIELDS = ['alias_name', 'alias_contact', 'alias_defaults', 'alias_bounced_content']
+
+ alias_id = fields.Many2one('mail.alias', string='Alias', ondelete="restrict", required=True)
+
+ # --------------------------------------------------
+ # CRUD
+ # --------------------------------------------------
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """ Create a record with each ``vals`` or ``vals_list`` and create a corresponding alias. """
+ valid_vals_list = []
+ for vals in vals_list:
+ new_alias = not vals.get('alias_id')
+ if new_alias:
+ alias_vals, record_vals = self._alias_filter_fields(vals)
+ alias_vals.update(self._alias_get_creation_values())
+ alias = self.env['mail.alias'].sudo().create(alias_vals)
+ record_vals['alias_id'] = alias.id
+ valid_vals_list.append(record_vals)
+ else:
+ valid_vals_list.append(vals)
+
+ records = super(AliasMixin, self).create(valid_vals_list)
+
+ for record in records:
+ record.alias_id.sudo().write(record._alias_get_creation_values())
+
+ return records
+
+ def write(self, vals):
+ """ Split writable fields of mail.alias and other fields alias fields will
+ write with sudo and the other normally """
+ alias_vals, record_vals = self._alias_filter_fields(vals, filters=self.ALIAS_WRITEABLE_FIELDS)
+ if record_vals:
+ super(AliasMixin, self).write(record_vals)
+ if alias_vals and (record_vals or self.check_access_rights('write', raise_exception=False)):
+ self.mapped('alias_id').sudo().write(alias_vals)
+
+ return True
+
+ def unlink(self):
+ """ Delete the given records, and cascade-delete their corresponding alias. """
+ aliases = self.mapped('alias_id')
+ res = super(AliasMixin, self).unlink()
+ aliases.sudo().unlink()
+ return res
+
+ @api.returns(None, lambda value: value[0])
+ def copy_data(self, default=None):
+ data = super(AliasMixin, self).copy_data(default)[0]
+ for fields_not_writable in set(self.env['mail.alias']._fields.keys()) - set(self.ALIAS_WRITEABLE_FIELDS):
+ if fields_not_writable in data:
+ del data[fields_not_writable]
+ return [data]
+
+ def _init_column(self, name):
+ """ Create aliases for existing rows. """
+ super(AliasMixin, self)._init_column(name)
+ if name == 'alias_id':
+ # as 'mail.alias' records refer to 'ir.model' records, create
+ # aliases after the reflection of models
+ self.pool.post_init(self._init_column_alias_id)
+
+ def _init_column_alias_id(self):
+ # both self and the alias model must be present in 'ir.model'
+ child_ctx = {
+ 'active_test': False, # retrieve all records
+ 'prefetch_fields': False, # do not prefetch fields on records
+ }
+ child_model = self.sudo().with_context(child_ctx)
+
+ for record in child_model.search([('alias_id', '=', False)]):
+ # create the alias, and link it to the current record
+ alias = self.env['mail.alias'].sudo().create(record._alias_get_creation_values())
+ record.with_context(mail_notrack=True).alias_id = alias
+ _logger.info('Mail alias created for %s %s (id %s)',
+ record._name, record.display_name, record.id)
+
+ # --------------------------------------------------
+ # MIXIN TOOL OVERRIDE METHODS
+ # --------------------------------------------------
+
+ def _alias_get_creation_values(self):
+ """ Return values to create an alias, or to write on the alias after its
+ creation.
+ """
+ return {
+ 'alias_parent_thread_id': self.id if self.id else False,
+ 'alias_parent_model_id': self.env['ir.model']._get(self._name).id,
+ }
+
+ def _alias_filter_fields(self, values, filters=False):
+ """ Split the vals dict into two dictionnary of vals, one for alias
+ field and the other for other fields """
+ if not filters:
+ filters = self.env['mail.alias']._fields.keys()
+ alias_values, record_values = {}, {}
+ for fname in values.keys():
+ if fname in filters:
+ alias_values[fname] = values.get(fname)
+ else:
+ record_values[fname] = values.get(fname)
+ return alias_values, record_values
+
+ # --------------------------------------------------
+ # GATEWAY
+ # --------------------------------------------------
+
+ def _alias_check_contact_on_record(self, record, message, message_dict, alias):
+ """ Move to ``BaseModel._alias_get_error_message() """
+ return record._alias_get_error_message(message, message_dict, alias)
diff --git a/addons/mail/models/mail_blacklist.py b/addons/mail/models/mail_blacklist.py
new file mode 100644
index 00000000..d19a3290
--- /dev/null
+++ b/addons/mail/models/mail_blacklist.py
@@ -0,0 +1,107 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, tools, _
+from odoo.exceptions import UserError
+
+
+class MailBlackList(models.Model):
+ """ Model of blacklisted email addresses to stop sending emails."""
+ _name = 'mail.blacklist'
+ _inherit = ['mail.thread']
+ _description = 'Mail Blacklist'
+ _rec_name = 'email'
+
+ email = fields.Char(string='Email Address', required=True, index=True, help='This field is case insensitive.',
+ tracking=True)
+ active = fields.Boolean(default=True, tracking=True)
+
+ _sql_constraints = [
+ ('unique_email', 'unique (email)', 'Email address already exists!')
+ ]
+
+ @api.model_create_multi
+ def create(self, values):
+ # First of all, extract values to ensure emails are really unique (and don't modify values in place)
+ new_values = []
+ all_emails = []
+ for value in values:
+ email = tools.email_normalize(value.get('email'))
+ if not email:
+ raise UserError(_('Invalid email address %r', value['email']))
+ if email in all_emails:
+ continue
+ all_emails.append(email)
+ new_value = dict(value, email=email)
+ new_values.append(new_value)
+
+ """ To avoid crash during import due to unique email, return the existing records if any """
+ sql = '''SELECT email, id FROM mail_blacklist WHERE email = ANY(%s)'''
+ emails = [v['email'] for v in new_values]
+ self._cr.execute(sql, (emails,))
+ bl_entries = dict(self._cr.fetchall())
+ to_create = [v for v in new_values if v['email'] not in bl_entries]
+
+ # TODO DBE Fixme : reorder ids according to incoming ids.
+ results = super(MailBlackList, self).create(to_create)
+ return self.env['mail.blacklist'].browse(bl_entries.values()) | results
+
+ def write(self, values):
+ if 'email' in values:
+ values['email'] = tools.email_normalize(values['email'])
+ return super(MailBlackList, self).write(values)
+
+ def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
+ """ Override _search in order to grep search on email field and make it
+ lower-case and sanitized """
+ if args:
+ new_args = []
+ for arg in args:
+ if isinstance(arg, (list, tuple)) and arg[0] == 'email' and isinstance(arg[2], str):
+ normalized = tools.email_normalize(arg[2])
+ if normalized:
+ new_args.append([arg[0], arg[1], normalized])
+ else:
+ new_args.append(arg)
+ else:
+ new_args.append(arg)
+ else:
+ new_args = args
+ return super(MailBlackList, self)._search(new_args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid)
+
+ def _add(self, email):
+ normalized = tools.email_normalize(email)
+ record = self.env["mail.blacklist"].with_context(active_test=False).search([('email', '=', normalized)])
+ if len(record) > 0:
+ record.action_unarchive()
+ else:
+ record = self.create({'email': email})
+ return record
+
+ def action_remove_with_reason(self, email, reason=None):
+ record = self._remove(email)
+ if reason:
+ record.message_post(body=_("Unblacklisting Reason: %s", reason))
+
+ return record
+
+ def _remove(self, email):
+ normalized = tools.email_normalize(email)
+ record = self.env["mail.blacklist"].with_context(active_test=False).search([('email', '=', normalized)])
+ if len(record) > 0:
+ record.action_archive()
+ else:
+ record = record.create({'email': email, 'active': False})
+ return record
+
+ def mail_action_blacklist_remove(self):
+ return {
+ 'name': 'Are you sure you want to unblacklist this Email Address?',
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'mail.blacklist.remove',
+ 'target': 'new',
+ }
+
+ def action_add(self):
+ self._add(self.email)
diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py
new file mode 100644
index 00000000..e0a5ffa5
--- /dev/null
+++ b/addons/mail/models/mail_channel.py
@@ -0,0 +1,1149 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import base64
+import logging
+import re
+from uuid import uuid4
+
+from odoo import _, api, fields, models, modules, tools
+from odoo.exceptions import UserError, ValidationError
+from odoo.osv import expression
+from odoo.tools import ormcache, formataddr
+from odoo.exceptions import AccessError
+from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
+
+MODERATION_FIELDS = ['moderation', 'moderator_ids', 'moderation_ids', 'moderation_notify', 'moderation_notify_msg', 'moderation_guidelines', 'moderation_guidelines_msg']
+_logger = logging.getLogger(__name__)
+
+
+class ChannelPartner(models.Model):
+ _name = 'mail.channel.partner'
+ _description = 'Listeners of a Channel'
+ _table = 'mail_channel_partner'
+ _rec_name = 'partner_id'
+
+ custom_channel_name = fields.Char('Custom channel name')
+ partner_id = fields.Many2one('res.partner', string='Recipient', ondelete='cascade')
+ partner_email = fields.Char('Email', related='partner_id.email', depends=['partner_id'], readonly=False)
+ channel_id = fields.Many2one('mail.channel', string='Channel', ondelete='cascade')
+ fetched_message_id = fields.Many2one('mail.message', string='Last Fetched')
+ seen_message_id = fields.Many2one('mail.message', string='Last Seen')
+ fold_state = fields.Selection([('open', 'Open'), ('folded', 'Folded'), ('closed', 'Closed')], string='Conversation Fold State', default='open')
+ is_minimized = fields.Boolean("Conversation is minimized")
+ is_pinned = fields.Boolean("Is pinned on the interface", default=True)
+
+ @api.model
+ def create(self, vals):
+ """Similar access rule as the access rule of the mail channel.
+
+ It can not be implemented in XML, because when the record will be created, the
+ partner will be added in the channel and the security rule will always authorize
+ the creation.
+ """
+ if 'channel_id' in vals and not self.env.is_admin():
+ channel_id = self.env['mail.channel'].browse(vals['channel_id'])
+ if not channel_id._can_invite(vals.get('partner_id')):
+ raise AccessError(_('This user can not be added in this channel'))
+ return super(ChannelPartner, self).create(vals)
+
+ def write(self, vals):
+ if not self.env.is_admin():
+ if {'channel_id', 'partner_id', 'partner_email'} & set(vals):
+ raise AccessError(_('You can not write on this field'))
+ return super(ChannelPartner, self).write(vals)
+
+
+class Moderation(models.Model):
+ _name = 'mail.moderation'
+ _description = 'Channel black/white list'
+
+ email = fields.Char(string="Email", index=True, required=True)
+ status = fields.Selection([
+ ('allow', 'Always Allow'),
+ ('ban', 'Permanent Ban')],
+ string="Status", required=True)
+ channel_id = fields.Many2one('mail.channel', string="Channel", index=True, required=True)
+
+ _sql_constraints = [
+ ('channel_email_uniq', 'unique (email,channel_id)', 'The email address must be unique per channel !')
+ ]
+
+
+class Channel(models.Model):
+ """ A mail.channel is a discussion group that may behave like a listener
+ on documents. """
+ _description = 'Discussion Channel'
+ _name = 'mail.channel'
+ _mail_flat_thread = False
+ _mail_post_access = 'read'
+ _inherit = ['mail.thread', 'mail.alias.mixin']
+
+ MAX_BOUNCE_LIMIT = 10
+
+ @api.model
+ def default_get(self, fields):
+ res = super(Channel, self).default_get(fields)
+ if not res.get('alias_contact') and (not fields or 'alias_contact' in fields):
+ res['alias_contact'] = 'everyone' if res.get('public', 'private') == 'public' else 'followers'
+ return res
+
+ def _get_default_image(self):
+ image_path = modules.get_module_resource('mail', 'static/src/img', 'groupdefault.png')
+ return base64.b64encode(open(image_path, 'rb').read())
+
+ name = fields.Char('Name', required=True, translate=True)
+ active = fields.Boolean(default=True, help="Set active to false to hide the channel without removing it.")
+ channel_type = fields.Selection([
+ ('chat', 'Chat Discussion'),
+ ('channel', 'Channel')],
+ 'Channel Type', default='channel')
+ is_chat = fields.Boolean(string='Is a chat', compute='_compute_is_chat', default=False)
+ description = fields.Text('Description')
+ uuid = fields.Char('UUID', size=50, index=True, default=lambda self: str(uuid4()), copy=False)
+ email_send = fields.Boolean('Send messages by email', default=False)
+ # multi users channel
+ # depends=['...'] is for `test_mail/tests/common.py`, class Moderation, `setUpClass`
+ channel_last_seen_partner_ids = fields.One2many('mail.channel.partner', 'channel_id', string='Last Seen', depends=['channel_partner_ids'])
+ channel_partner_ids = fields.Many2many('res.partner', 'mail_channel_partner', 'channel_id', 'partner_id', string='Listeners', depends=['channel_last_seen_partner_ids'])
+ channel_message_ids = fields.Many2many('mail.message', 'mail_message_mail_channel_rel')
+ is_member = fields.Boolean('Is a member', compute='_compute_is_member')
+ # access
+ public = fields.Selection([
+ ('public', 'Everyone'),
+ ('private', 'Invited people only'),
+ ('groups', 'Selected group of users')],
+ 'Privacy', required=True, default='groups',
+ help='This group is visible by non members. Invisible groups can add members through the invite button.')
+ group_public_id = fields.Many2one('res.groups', string='Authorized Group',
+ default=lambda self: self.env.ref('base.group_user'))
+ group_ids = fields.Many2many(
+ 'res.groups', string='Auto Subscription',
+ help="Members of those groups will automatically added as followers. "
+ "Note that they will be able to manage their subscription manually "
+ "if necessary.")
+ image_128 = fields.Image("Image", max_width=128, max_height=128, default=_get_default_image)
+ is_subscribed = fields.Boolean(
+ 'Is Subscribed', compute='_compute_is_subscribed')
+ # moderation
+ moderation = fields.Boolean(string='Moderate this channel')
+ moderator_ids = fields.Many2many('res.users', 'mail_channel_moderator_rel', string='Moderators')
+ is_moderator = fields.Boolean(help="Current user is a moderator of the channel", string='Moderator', compute="_compute_is_moderator")
+ moderation_ids = fields.One2many(
+ 'mail.moderation', 'channel_id', string='Moderated Emails',
+ groups="base.group_user")
+ moderation_count = fields.Integer(
+ string='Moderated emails count', compute='_compute_moderation_count',
+ groups="base.group_user")
+ moderation_notify = fields.Boolean(string="Automatic notification", help="People receive an automatic notification about their message being waiting for moderation.")
+ moderation_notify_msg = fields.Text(string="Notification message")
+ moderation_guidelines = fields.Boolean(string="Send guidelines to new subscribers", help="Newcomers on this moderated channel will automatically receive the guidelines.")
+ moderation_guidelines_msg = fields.Text(string="Guidelines")
+
+ @api.depends('channel_partner_ids')
+ def _compute_is_subscribed(self):
+ for channel in self:
+ channel.is_subscribed = self.env.user.partner_id in channel.channel_partner_ids
+
+ @api.depends('moderator_ids')
+ def _compute_is_moderator(self):
+ for channel in self:
+ channel.is_moderator = self.env.user in channel.moderator_ids
+
+ @api.depends('moderation_ids')
+ def _compute_moderation_count(self):
+ read_group_res = self.env['mail.moderation'].read_group([('channel_id', 'in', self.ids)], ['channel_id'], 'channel_id')
+ data = dict((res['channel_id'][0], res['channel_id_count']) for res in read_group_res)
+ for channel in self:
+ channel.moderation_count = data.get(channel.id, 0)
+
+ @api.constrains('moderator_ids')
+ def _check_moderator_email(self):
+ if any(not moderator.email for channel in self for moderator in channel.moderator_ids):
+ raise ValidationError(_("Moderators must have an email address."))
+
+ @api.constrains('moderator_ids', 'channel_partner_ids', 'channel_last_seen_partner_ids')
+ def _check_moderator_is_member(self):
+ for channel in self:
+ if not (channel.mapped('moderator_ids.partner_id') <= channel.sudo().channel_partner_ids):
+ raise ValidationError(_("Moderators should be members of the channel they moderate."))
+
+ @api.constrains('moderation', 'email_send')
+ def _check_moderation_parameters(self):
+ if any(not channel.email_send and channel.moderation for channel in self):
+ raise ValidationError(_('Only mailing lists can be moderated.'))
+
+ @api.constrains('moderator_ids')
+ def _check_moderator_existence(self):
+ if any(not channel.moderator_ids for channel in self if channel.moderation):
+ raise ValidationError(_('Moderated channels must have moderators.'))
+
+ def _compute_is_member(self):
+ memberships = self.env['mail.channel.partner'].sudo().search([
+ ('channel_id', 'in', self.ids),
+ ('partner_id', '=', self.env.user.partner_id.id),
+ ])
+ membership_ids = memberships.mapped('channel_id')
+ for record in self:
+ record.is_member = record in membership_ids
+
+ def _compute_is_chat(self):
+ for record in self:
+ if record.channel_type == 'chat':
+ record.is_chat = True
+ else:
+ record.is_chat = False
+
+ @api.onchange('public')
+ def _onchange_public(self):
+ if self.public != 'public' and self.alias_contact == 'everyone':
+ self.alias_contact = 'followers'
+
+ @api.onchange('moderator_ids')
+ def _onchange_moderator_ids(self):
+ missing_partner_ids = set(self.mapped('moderator_ids.partner_id').ids) - set(self.mapped('channel_last_seen_partner_ids.partner_id').ids)
+ if missing_partner_ids:
+ self.channel_last_seen_partner_ids = [
+ (0, 0, {'partner_id': partner_id})
+ for partner_id in missing_partner_ids
+ ]
+
+ @api.onchange('email_send')
+ def _onchange_email_send(self):
+ if not self.email_send:
+ self.moderation = False
+
+ @api.onchange('moderation')
+ def _onchange_moderation(self):
+ if not self.moderation:
+ self.moderation_notify = False
+ self.moderation_guidelines = False
+ self.moderator_ids = False
+ else:
+ self.moderator_ids |= self.env.user
+
+ @api.model
+ def create(self, vals):
+ # ensure image at quick create
+ if not vals.get('image_128'):
+ defaults = self.default_get(['image_128'])
+ vals['image_128'] = defaults['image_128']
+
+ current_partner = self.env.user.partner_id.id
+ # always add current user to new channel, go through
+ # channel_last_seen_partner_ids otherwise in v14 the channel is not
+ # visible for the user (because is_pinned is false and taken in account)
+ if 'channel_partner_ids' in vals:
+ vals['channel_partner_ids'] = [
+ entry
+ for entry in vals['channel_partner_ids']
+ if entry[0] != 4 or entry[1] != current_partner
+ ]
+ membership = vals.setdefault('channel_last_seen_partner_ids', [])
+ if all(entry[0] != 0 or entry[2].get('partner_id') != current_partner for entry in membership):
+ membership.append((0, False, {'partner_id': current_partner}))
+
+ visibility_default = self._fields['public'].default(self)
+ visibility = vals.pop('public', visibility_default)
+ vals['public'] = 'public'
+ # Create channel and alias
+ channel = super(Channel, self.with_context(
+ mail_create_nolog=True, mail_create_nosubscribe=True)
+ ).create(vals)
+ if visibility != 'public':
+ channel.sudo().public = visibility
+
+ if vals.get('group_ids'):
+ channel._subscribe_users()
+
+ # make channel listen itself: posting on a channel notifies the channel
+ if not self._context.get('mail_channel_noautofollow'):
+ channel.message_subscribe(channel_ids=[channel.id])
+
+ return channel
+
+ def unlink(self):
+ # Delete mail.channel
+ try:
+ all_emp_group = self.env.ref('mail.channel_all_employees')
+ except ValueError:
+ all_emp_group = None
+ if all_emp_group and all_emp_group in self and not self._context.get(MODULE_UNINSTALL_FLAG):
+ raise UserError(_('You cannot delete those groups, as the Whole Company group is required by other modules.'))
+ return super(Channel, self).unlink()
+
+ def write(self, vals):
+ # First checks if user tries to modify moderation fields and has not the right to do it.
+ if any(key for key in MODERATION_FIELDS if vals.get(key)) and any(self.env.user not in channel.moderator_ids for channel in self if channel.moderation):
+ if not self.env.user.has_group('base.group_system'):
+ raise UserError(_("You do not have the rights to modify fields related to moderation on one of the channels you are modifying."))
+
+ result = super(Channel, self).write(vals)
+
+ if vals.get('group_ids'):
+ self._subscribe_users()
+
+ # avoid keeping messages to moderate and accept them
+ if vals.get('moderation') is False:
+ self.env['mail.message'].search([
+ ('moderation_status', '=', 'pending_moderation'),
+ ('model', '=', 'mail.channel'),
+ ('res_id', 'in', self.ids)
+ ])._moderate_accept()
+
+ return result
+
+ def _alias_get_creation_values(self):
+ values = super(Channel, self)._alias_get_creation_values()
+ values['alias_model_id'] = self.env['ir.model']._get('mail.channel').id
+ if self.id:
+ values['alias_force_thread_id'] = self.id
+ return values
+
+ def _subscribe_users(self):
+ to_create = []
+ for mail_channel in self:
+ partners_to_add = mail_channel.group_ids.users.partner_id - mail_channel.channel_partner_ids
+ to_create += [{
+ 'channel_id': mail_channel.id,
+ 'partner_id': partner.id,
+ } for partner in partners_to_add]
+
+ self.env['mail.channel.partner'].create(to_create)
+
+ def action_follow(self):
+ self.ensure_one()
+ channel_partner = self.mapped('channel_last_seen_partner_ids').filtered(lambda cp: cp.partner_id == self.env.user.partner_id)
+ if not channel_partner:
+ return self.write({'channel_last_seen_partner_ids': [(0, 0, {'partner_id': self.env.user.partner_id.id})]})
+ return False
+
+ def action_unfollow(self):
+ return self._action_unfollow(self.env.user.partner_id)
+
+ def _action_unfollow(self, partner):
+ self.message_unsubscribe(partner.ids)
+ if partner not in self.with_context(active_test=False).channel_partner_ids:
+ return True
+ channel_info = self.channel_info('unsubscribe')[0] # must be computed before leaving the channel (access rights)
+ result = self.write({'channel_partner_ids': [(3, partner.id)]})
+ # side effect of unsubscribe that wasn't taken into account because
+ # channel_info is called before actually unpinning the channel
+ channel_info['is_pinned'] = False
+ self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', partner.id), channel_info)
+ if not self.email_send:
+ notification = _('<div class="o_mail_notification">left <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>', self.id, self.name)
+ # post 'channel left' message as root since the partner just unsubscribed from the channel
+ self.sudo().message_post(body=notification, subtype_xmlid="mail.mt_comment", author_id=partner.id)
+ return result
+
+ def _notify_get_groups(self, msg_vals=None):
+ """ All recipients of a message on a channel are considered as partners.
+ This means they will receive a minimal email, without a link to access
+ in the backend. Mailing lists should indeed send minimal emails to avoid
+ the noise. """
+ groups = super(Channel, self)._notify_get_groups(msg_vals=msg_vals)
+ for (index, (group_name, group_func, group_data)) in enumerate(groups):
+ if group_name != 'customer':
+ groups[index] = (group_name, lambda partner: False, group_data)
+ return groups
+
+ def _notify_email_header_dict(self):
+ headers = super(Channel, self)._notify_email_header_dict()
+ headers['Precedence'] = 'list'
+ # avoid out-of-office replies from MS Exchange
+ # http://blogs.technet.com/b/exchange/archive/2006/10/06/3395024.aspx
+ headers['X-Auto-Response-Suppress'] = 'OOF'
+ if self.alias_domain and self.alias_name:
+ headers['List-Id'] = '<%s.%s>' % (self.alias_name, self.alias_domain)
+ headers['List-Post'] = '<mailto:%s@%s>' % (self.alias_name, self.alias_domain)
+ # Avoid users thinking it was a personal message
+ # X-Forge-To: will replace To: after SMTP envelope is determined by ir.mail.server
+ list_to = '"%s" <%s@%s>' % (self.name, self.alias_name, self.alias_domain)
+ headers['X-Forge-To'] = list_to
+ return headers
+
+ def _message_receive_bounce(self, email, partner):
+ """ Override bounce management to unsubscribe bouncing addresses """
+ for p in partner:
+ if p.message_bounce >= self.MAX_BOUNCE_LIMIT:
+ self._action_unfollow(p)
+ return super(Channel, self)._message_receive_bounce(email, partner)
+
+ def _notify_email_recipient_values(self, recipient_ids):
+ # Excluded Blacklisted
+ whitelist = self.env['res.partner'].sudo().browse(recipient_ids).filtered(lambda p: not p.is_blacklisted)
+ # real mailing list: multiple recipients (hidden by X-Forge-To)
+ if self.alias_domain and self.alias_name:
+ return {
+ 'email_to': ','.join(formataddr((partner.name, partner.email_normalized)) for partner in whitelist if partner.email_normalized),
+ 'recipient_ids': [],
+ }
+ return super(Channel, self)._notify_email_recipient_values(whitelist.ids)
+
+ def _extract_moderation_values(self, message_type, **kwargs):
+ """ This method is used to compute moderation status before the creation
+ of a message. For this operation the message's author email address is required.
+ This address is returned with status for other computations. """
+ moderation_status = 'accepted'
+ email = ''
+ if self.moderation and message_type in ['email', 'comment']:
+ author_id = kwargs.get('author_id')
+ if author_id and isinstance(author_id, int):
+ email = self.env['res.partner'].browse([author_id]).email
+ elif author_id:
+ email = author_id.email
+ elif kwargs.get('email_from'):
+ email = tools.email_split(kwargs['email_from'])[0]
+ else:
+ email = self.env.user.email
+ if email in self.mapped('moderator_ids.email'):
+ return moderation_status, email
+ status = self.env['mail.moderation'].sudo().search([('email', '=', email), ('channel_id', 'in', self.ids)]).mapped('status')
+ if status and status[0] == 'allow':
+ moderation_status = 'accepted'
+ elif status and status[0] == 'ban':
+ moderation_status = 'rejected'
+ else:
+ moderation_status = 'pending_moderation'
+ return moderation_status, email
+
+ @api.returns('mail.message', lambda value: value.id)
+ def message_post(self, *, message_type='notification', **kwargs):
+ moderation_status, email = self._extract_moderation_values(message_type, **kwargs)
+ if moderation_status == 'rejected':
+ return self.env['mail.message']
+
+ self.filtered(lambda channel: channel.is_chat).mapped('channel_last_seen_partner_ids').sudo().write({'is_pinned': True})
+
+ # mail_post_autofollow=False is necessary to prevent adding followers
+ # when using mentions in channels. Followers should not be added to
+ # channels, and especially not automatically (because channel membership
+ # should be managed with channel.partner instead).
+ # The current client code might be setting the key to True on sending
+ # message but it is only useful when targeting customers in chatter.
+ # This value should simply be set to False in channels no matter what.
+ message = super(Channel, self.with_context(mail_create_nosubscribe=True, mail_post_autofollow=False)).message_post(message_type=message_type, moderation_status=moderation_status, **kwargs)
+
+ # Notifies the message author when his message is pending moderation if required on channel.
+ # The fields "email_from" and "reply_to" are filled in automatically by method create in model mail.message.
+ if self.moderation_notify and self.moderation_notify_msg and message_type in ['email','comment'] and moderation_status == 'pending_moderation':
+ self.env['mail.mail'].sudo().create({
+ 'author_id': self.env.user.partner_id.id,
+ 'email_from': self.env.user.company_id.catchall_formatted or self.env.user.company_id.email_formatted,
+ 'body_html': self.moderation_notify_msg,
+ 'subject': 'Re: %s' % (kwargs.get('subject', '')),
+ 'email_to': email,
+ 'auto_delete': True,
+ 'state': 'outgoing'
+ })
+ return message
+
+ def _message_post_after_hook(self, message, msg_vals):
+ """
+ Automatically set the message posted by the current user as seen for himself.
+ """
+ self._set_last_seen_message(message)
+ return super()._message_post_after_hook(message=message, msg_vals=msg_vals)
+
+ def _alias_get_error_message(self, message, message_dict, alias):
+ if alias.alias_contact == 'followers' and self.ids:
+ author = self.env['res.partner'].browse(message_dict.get('author_id', False))
+ if not author or author not in self.channel_partner_ids:
+ return _('restricted to channel members')
+ return False
+ return super(Channel, self)._alias_get_error_message(message, message_dict, alias)
+
+ def init(self):
+ self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('mail_channel_partner_seen_message_id_idx',))
+ if not self._cr.fetchone():
+ self._cr.execute('CREATE INDEX mail_channel_partner_seen_message_id_idx ON mail_channel_partner (channel_id,partner_id,seen_message_id)')
+
+ # --------------------------------------------------
+ # Moderation
+ # --------------------------------------------------
+
+ def send_guidelines(self):
+ """ Send guidelines to all channel members. """
+ if self.env.user in self.moderator_ids or self.env.user.has_group('base.group_system'):
+ success = self._send_guidelines(self.channel_partner_ids)
+ if not success:
+ raise UserError(_('View "mail.mail_channel_send_guidelines" was not found. No email has been sent. Please contact an administrator to fix this issue.'))
+ else:
+ raise UserError(_("Only an administrator or a moderator can send guidelines to channel members!"))
+
+ def _send_guidelines(self, partners):
+ """ Send guidelines of a given channel. Returns False if template used for guidelines
+ not found. Caller may have to handle this return value. """
+ self.ensure_one()
+ view = self.env.ref('mail.mail_channel_send_guidelines', raise_if_not_found=False)
+ if not view:
+ _logger.warning('View "mail.mail_channel_send_guidelines" was not found.')
+ return False
+ banned_emails = self.env['mail.moderation'].sudo().search([
+ ('status', '=', 'ban'),
+ ('channel_id', 'in', self.ids)
+ ]).mapped('email')
+ for partner in partners.filtered(lambda p: p.email and not (p.email in banned_emails)):
+ company = partner.company_id or self.env.company
+ create_values = {
+ 'email_from': company.catchall_formatted or company.email_formatted,
+ 'author_id': self.env.user.partner_id.id,
+ 'body_html': view._render({'channel': self, 'partner': partner}, engine='ir.qweb', minimal_qcontext=True),
+ 'subject': _("Guidelines of channel %s", self.name),
+ 'recipient_ids': [(4, partner.id)]
+ }
+ mail = self.env['mail.mail'].sudo().create(create_values)
+ return True
+
+ def _update_moderation_email(self, emails, status):
+ """ This method adds emails into either white or black of the channel list of emails
+ according to status. If an email in emails is already moderated, the method updates the email status.
+ :param emails: list of email addresses to put in white or black list of channel.
+ :param status: value is 'allow' or 'ban'. Emails are put in white list if 'allow', in black list if 'ban'.
+ """
+ self.ensure_one()
+ splitted_emails = [tools.email_split(email)[0] for email in emails if tools.email_split(email)]
+ moderated = self.env['mail.moderation'].sudo().search([
+ ('email', 'in', splitted_emails),
+ ('channel_id', 'in', self.ids)
+ ])
+ cmds = [(1, record.id, {'status': status}) for record in moderated]
+ not_moderated = [email for email in splitted_emails if email not in moderated.mapped('email')]
+ cmds += [(0, 0, {'email': email, 'status': status}) for email in not_moderated]
+ return self.write({'moderation_ids': cmds})
+
+ #------------------------------------------------------
+ # Instant Messaging API
+ #------------------------------------------------------
+ # A channel header should be broadcasted:
+ # - when adding user to channel (only to the new added partners)
+ # - when folding/minimizing a channel (only to the user making the action)
+ # A message should be broadcasted:
+ # - when a message is posted on a channel (to the channel, using _notify() method)
+
+ # Anonymous method
+ def _broadcast(self, partner_ids):
+ """ Broadcast the current channel header to the given partner ids
+ :param partner_ids : the partner to notify
+ """
+ notifications = self._channel_channel_notifications(partner_ids)
+ self.env['bus.bus'].sendmany(notifications)
+
+ def _channel_channel_notifications(self, partner_ids):
+ """ Generate the bus notifications of current channel for the given partner ids
+ :param partner_ids : the partner to send the current channel header
+ :returns list of bus notifications (tuple (bus_channe, message_content))
+ """
+ notifications = []
+ for partner in self.env['res.partner'].browse(partner_ids):
+ user_id = partner.user_ids and partner.user_ids[0] or False
+ if user_id:
+ user_channels = self.with_user(user_id).with_context(
+ allowed_company_ids=user_id.company_ids.ids
+ )
+ for channel_info in user_channels.channel_info():
+ notifications.append([(self._cr.dbname, 'res.partner', partner.id), channel_info])
+ return notifications
+
+ def _notify_thread(self, message, msg_vals=False, **kwargs):
+ # When posting a message on a mail channel, manage moderation and postpone notify users
+ if not msg_vals or msg_vals.get('moderation_status') != 'pending_moderation':
+ super(Channel, self)._notify_thread(message, msg_vals=msg_vals, **kwargs)
+ else:
+ message._notify_pending_by_chat()
+
+ def _channel_message_notifications(self, message, message_format=False):
+ """ Generate the bus notifications for the given message
+ :param message : the mail.message to sent
+ :returns list of bus notifications (tuple (bus_channe, message_content))
+ """
+ message_format = message_format or message.message_format()[0]
+ notifications = []
+ for channel in self:
+ notifications.append([(self._cr.dbname, 'mail.channel', channel.id), dict(message_format)])
+ # add uuid to allow anonymous to listen
+ if channel.public == 'public':
+ notifications.append([channel.uuid, dict(message_format)])
+ return notifications
+
+ @api.model
+ def partner_info(self, all_partners, direct_partners):
+ """
+ Return the information needed by channel to display channel members
+ :param all_partners: list of res.parner():
+ :param direct_partners: list of res.parner():
+ :returns: a list of {'id', 'name', 'email'} for each partner and adds {im_status} for direct_partners.
+ :rtype : list(dict)
+ """
+ partner_infos = {partner['id']: partner for partner in all_partners.sudo().read(['id', 'name', 'email'])}
+ # add im _status for direct_partners
+ direct_partners_im_status = {partner['id']: partner for partner in direct_partners.sudo().read(['im_status'])}
+
+ for i in direct_partners_im_status.keys():
+ partner_infos[i].update(direct_partners_im_status[i])
+
+ return partner_infos
+
+ def channel_info(self, extra_info=False):
+ """ Get the informations header for the current channels
+ :returns a list of channels values
+ :rtype : list(dict)
+ """
+ if not self:
+ return []
+ channel_infos = []
+ # all relations partner_channel on those channels
+ all_partner_channel = self.env['mail.channel.partner'].search([('channel_id', 'in', self.ids)])
+
+ # all partner infos on those channels
+ channel_dict = {channel.id: channel for channel in self}
+ all_partners = all_partner_channel.mapped('partner_id')
+ direct_channel_partners = all_partner_channel.filtered(lambda pc: channel_dict[pc.channel_id.id].channel_type == 'chat')
+ direct_partners = direct_channel_partners.mapped('partner_id')
+ partner_infos = self.partner_info(all_partners, direct_partners)
+ channel_last_message_ids = dict((r['id'], r['message_id']) for r in self._channel_last_message_ids())
+
+ for channel in self:
+ info = {
+ 'id': channel.id,
+ 'name': channel.name,
+ 'uuid': channel.uuid,
+ 'state': 'open',
+ 'is_minimized': False,
+ 'channel_type': channel.channel_type,
+ 'public': channel.public,
+ 'mass_mailing': channel.email_send,
+ 'moderation': channel.moderation,
+ 'is_moderator': self.env.uid in channel.moderator_ids.ids,
+ 'group_based_subscription': bool(channel.group_ids),
+ 'create_uid': channel.create_uid.id,
+ }
+ if extra_info:
+ info['info'] = extra_info
+
+ # add last message preview (only used in mobile)
+ info['last_message_id'] = channel_last_message_ids.get(channel.id, False)
+ # listeners of the channel
+ channel_partners = all_partner_channel.filtered(lambda pc: channel.id == pc.channel_id.id)
+
+ # find the channel partner state, if logged user
+ if self.env.user and self.env.user.partner_id:
+ # add needaction and unread counter, since the user is logged
+ info['message_needaction_counter'] = channel.message_needaction_counter
+ info['message_unread_counter'] = channel.message_unread_counter
+
+ # add user session state, if available and if user is logged
+ partner_channel = channel_partners.filtered(lambda pc: pc.partner_id.id == self.env.user.partner_id.id)
+ if partner_channel:
+ partner_channel = partner_channel[0]
+ info['state'] = partner_channel.fold_state or 'open'
+ info['is_minimized'] = partner_channel.is_minimized
+ info['seen_message_id'] = partner_channel.seen_message_id.id
+ info['custom_channel_name'] = partner_channel.custom_channel_name
+ info['is_pinned'] = partner_channel.is_pinned
+
+ # add members infos
+ if channel.channel_type != 'channel':
+ # avoid sending potentially a lot of members for big channels
+ # exclude chat and other small channels from this optimization because they are
+ # assumed to be smaller and it's important to know the member list for them
+ partner_ids = channel_partners.mapped('partner_id').ids
+ info['members'] = [partner_infos[partner] for partner in partner_ids]
+ if channel.channel_type != 'channel':
+ info['seen_partners_info'] = [{
+ 'id': cp.id,
+ 'partner_id': cp.partner_id.id,
+ 'fetched_message_id': cp.fetched_message_id.id,
+ 'seen_message_id': cp.seen_message_id.id,
+ } for cp in channel_partners]
+
+ channel_infos.append(info)
+ return channel_infos
+
+ def channel_fetch_message(self, last_id=False, limit=20):
+ """ Return message values of the current channel.
+ :param last_id : last message id to start the research
+ :param limit : maximum number of messages to fetch
+ :returns list of messages values
+ :rtype : list(dict)
+ """
+ self.ensure_one()
+ domain = [("channel_ids", "in", self.ids)]
+ if last_id:
+ domain.append(("id", "<", last_id))
+ return self.env['mail.message'].message_fetch(domain=domain, limit=limit)
+
+ # User methods
+ @api.model
+ def channel_get(self, partners_to, pin=True):
+ """ Get the canonical private channel between some partners, create it if needed.
+ To reuse an old channel (conversation), this one must be private, and contains
+ only the given partners.
+ :param partners_to : list of res.partner ids to add to the conversation
+ :param pin : True if getting the channel should pin it for the current user
+ :returns: channel_info of the created or existing channel
+ :rtype: dict
+ """
+ if self.env.user.partner_id.id not in partners_to:
+ partners_to.append(self.env.user.partner_id.id)
+ # determine type according to the number of partner in the channel
+ self.flush()
+ self.env.cr.execute("""
+ SELECT P.channel_id
+ FROM mail_channel C, mail_channel_partner P
+ WHERE P.channel_id = C.id
+ AND C.public LIKE 'private'
+ AND P.partner_id IN %s
+ AND C.channel_type LIKE 'chat'
+ AND NOT EXISTS (
+ SELECT *
+ FROM mail_channel_partner P2
+ WHERE P2.channel_id = C.id
+ AND P2.partner_id NOT IN %s
+ )
+ GROUP BY P.channel_id
+ HAVING ARRAY_AGG(DISTINCT P.partner_id ORDER BY P.partner_id) = %s
+ LIMIT 1
+ """, (tuple(partners_to), tuple(partners_to), sorted(list(partners_to)),))
+ result = self.env.cr.dictfetchall()
+ if result:
+ # get the existing channel between the given partners
+ channel = self.browse(result[0].get('channel_id'))
+ # pin up the channel for the current partner
+ if pin:
+ self.env['mail.channel.partner'].search([('partner_id', '=', self.env.user.partner_id.id), ('channel_id', '=', channel.id)]).write({'is_pinned': True})
+ channel._broadcast(self.env.user.partner_id.ids)
+ else:
+ # create a new one
+ channel = self.create({
+ 'channel_partner_ids': [(4, partner_id) for partner_id in partners_to],
+ 'public': 'private',
+ 'channel_type': 'chat',
+ 'email_send': False,
+ 'name': ', '.join(self.env['res.partner'].sudo().browse(partners_to).mapped('name')),
+ })
+ channel._broadcast(partners_to)
+ return channel.channel_info()[0]
+
+ @api.model
+ def channel_get_and_minimize(self, partners_to):
+ channel = self.channel_get(partners_to)
+ if channel:
+ self.channel_minimize(channel['uuid'])
+ return channel
+
+ @api.model
+ def channel_fold(self, uuid, state=None):
+ """ Update the fold_state of the given session. In order to syncronize web browser
+ tabs, the change will be broadcast to himself (the current user channel).
+ Note: the user need to be logged
+ :param state : the new status of the session for the current user.
+ """
+ domain = [('partner_id', '=', self.env.user.partner_id.id), ('channel_id.uuid', '=', uuid)]
+ for session_state in self.env['mail.channel.partner'].search(domain):
+ if not state:
+ state = session_state.fold_state
+ if session_state.fold_state == 'open':
+ state = 'folded'
+ else:
+ state = 'open'
+ is_minimized = bool(state != 'closed')
+ vals = {}
+ if session_state.fold_state != state:
+ vals['fold_state'] = state
+ if session_state.is_minimized != is_minimized:
+ vals['is_minimized'] = is_minimized
+ if vals:
+ session_state.write(vals)
+ self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), session_state.channel_id.channel_info()[0])
+
+ @api.model
+ def channel_minimize(self, uuid, minimized=True):
+ values = {
+ 'fold_state': minimized and 'open' or 'closed',
+ 'is_minimized': minimized
+ }
+ domain = [('partner_id', '=', self.env.user.partner_id.id), ('channel_id.uuid', '=', uuid)]
+ channel_partners = self.env['mail.channel.partner'].search(domain, limit=1)
+ channel_partners.write(values)
+ self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_partners.channel_id.channel_info()[0])
+
+ @api.model
+ def channel_pin(self, uuid, pinned=False):
+ # add the person in the channel, and pin it (or unpin it)
+ channel = self.search([('uuid', '=', uuid)])
+ channel._execute_channel_pin(pinned)
+
+ def _execute_channel_pin(self, pinned=False):
+ """ Hook for website_livechat channel unpin and cleaning """
+ self.ensure_one()
+ channel_partners = self.env['mail.channel.partner'].search(
+ [('partner_id', '=', self.env.user.partner_id.id), ('channel_id', '=', self.id), ('is_pinned', '!=', pinned)])
+ self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), self.channel_info('unsubscribe' if not pinned else False)[0])
+ if channel_partners:
+ channel_partners.write({'is_pinned': pinned})
+
+ def channel_seen(self, last_message_id=None):
+ """
+ Mark channel as seen by updating seen message id of the current logged partner
+ :param last_message_id: the id of the message to be marked as seen, last message of the
+ thread by default. This param SHOULD be required, the default behaviour is DEPRECATED and
+ kept only for compatibility reasons.
+ """
+ self.ensure_one()
+ domain = [('channel_ids', 'in', self.ids)]
+ if last_message_id:
+ domain = expression.AND([domain, [('id', '<=', last_message_id)]])
+ last_message = self.env['mail.message'].search(domain, order="id DESC", limit=1)
+ if not last_message:
+ return
+
+ self._set_last_seen_message(last_message)
+
+ data = {
+ 'info': 'channel_seen',
+ 'last_message_id': last_message.id,
+ 'partner_id': self.env.user.partner_id.id,
+ }
+ if self.channel_type == 'chat':
+ self.env['bus.bus'].sendmany([[(self._cr.dbname, 'mail.channel', self.id), data]])
+ else:
+ data['channel_id'] = self.id
+ self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), data)
+ return last_message.id
+
+ def _set_last_seen_message(self, last_message):
+ """
+ Set last seen message of `self` channels for the current user.
+ :param last_message: the message to set as last seen message
+ """
+ channel_partner_domain = expression.AND([
+ [('channel_id', 'in', self.ids)],
+ [('partner_id', '=', self.env.user.partner_id.id)],
+ expression.OR([
+ [('seen_message_id', '=', False)],
+ [('seen_message_id', '<', last_message.id)]
+ ])
+ ])
+ channel_partner = self.env['mail.channel.partner'].search(channel_partner_domain)
+ channel_partner.write({
+ 'fetched_message_id': last_message.id,
+ 'seen_message_id': last_message.id,
+ })
+
+ def channel_fetched(self):
+ """ Broadcast the channel_fetched notification to channel members
+ :param channel_ids : list of channel id that has been fetched by current user
+ """
+ for channel in self:
+ if not channel.channel_message_ids.ids:
+ return
+ if channel.channel_type != 'chat':
+ return
+ last_message_id = channel.channel_message_ids.ids[0] # zero is the index of the last message
+ channel_partner = self.env['mail.channel.partner'].search([('channel_id', '=', channel.id), ('partner_id', '=', self.env.user.partner_id.id)], limit=1)
+ if channel_partner.fetched_message_id.id == last_message_id:
+ # last message fetched by user is already up-to-date
+ return
+ channel_partner.write({
+ 'fetched_message_id': last_message_id,
+ })
+ data = {
+ 'id': channel_partner.id,
+ 'info': 'channel_fetched',
+ 'last_message_id': last_message_id,
+ 'partner_id': self.env.user.partner_id.id,
+ }
+ self.env['bus.bus'].sendmany([[(self._cr.dbname, 'mail.channel', channel.id), data]])
+
+ def channel_invite(self, partner_ids):
+ """ Add the given partner_ids to the current channels and broadcast the channel header to them.
+ :param partner_ids : list of partner id to add
+ """
+ partners = self.env['res.partner'].browse(partner_ids)
+ self._invite_check_access(partners)
+
+ # add the partner
+ for channel in self:
+ partners_to_add = partners - channel.channel_partner_ids
+ channel.write({'channel_last_seen_partner_ids': [(0, 0, {'partner_id': partner_id}) for partner_id in partners_to_add.ids]})
+ for partner in partners_to_add:
+ if partner.id != self.env.user.partner_id.id:
+ notification = _('<div class="o_mail_notification">%(author)s invited %(new_partner)s to <a href="#" class="o_channel_redirect" data-oe-id="%(channel_id)s">#%(channel_name)s</a></div>',
+ author=self.env.user.display_name,
+ new_partner=partner.display_name,
+ channel_id=channel.id,
+ channel_name=channel.name,
+ )
+ else:
+ notification = _('<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>', channel.id, channel.name)
+ self.message_post(body=notification, message_type="notification", subtype_xmlid="mail.mt_comment", author_id=partner.id, notify_by_email=False)
+
+ # broadcast the channel header to the added partner
+ self._broadcast(partner_ids)
+
+ def _invite_check_access(self, partners):
+ """ Check invited partners could match channel access """
+ failed = []
+ if any(channel.public == 'groups' for channel in self):
+ for channel in self.filtered(lambda c: c.public == 'groups'):
+ invalid_partners = [partner for partner in partners if channel.group_public_id not in partner.mapped('user_ids.groups_id')]
+ failed += [(channel, partner) for partner in invalid_partners]
+
+ if failed:
+ raise UserError(
+ _('Following invites are invalid as user groups do not match: %s') %
+ ', '.join('%s (channel %s)' % (partner.name, channel.name) for channel, partner in failed)
+ )
+
+ def _can_invite(self, partner_id):
+ """Return True if the current user can invite the partner to the channel."""
+ self.ensure_one()
+ sudo_self = self.sudo()
+ if sudo_self.public == 'public':
+ return True
+ if sudo_self.public == 'private':
+ return self.is_member
+
+ # get the user related to the invited partner
+ partner = self.env['res.partner'].browse(partner_id).exists()
+ invited_user_id = partner.user_ids[:1]
+ if invited_user_id:
+ return (self.env.user | invited_user_id) <= sudo_self.group_public_id.users
+ return False
+
+ @api.model
+ def channel_set_custom_name(self, channel_id, name=False):
+ domain = [('partner_id', '=', self.env.user.partner_id.id), ('channel_id.id', '=', channel_id)]
+ channel_partners = self.env['mail.channel.partner'].search(domain, limit=1)
+ channel_partners.write({
+ 'custom_channel_name': name,
+ })
+
+ def notify_typing(self, is_typing):
+ """ Broadcast the typing notification to channel members
+ :param is_typing: (boolean) tells whether the current user is typing or not
+ """
+ notifications = []
+ for channel in self:
+ data = {
+ 'info': 'typing_status',
+ 'is_typing': is_typing,
+ 'partner_id': self.env.user.partner_id.id,
+ 'partner_name': self.env.user.partner_id.name,
+ }
+ notifications.append([(self._cr.dbname, 'mail.channel', channel.id), data]) # notify backend users
+ notifications.append([channel.uuid, data]) # notify frontend users
+ self.env['bus.bus'].sendmany(notifications)
+
+ #------------------------------------------------------
+ # Instant Messaging View Specific (Slack Client Action)
+ #------------------------------------------------------
+ @api.model
+ def channel_fetch_slot(self):
+ """ Return the channels of the user grouped by 'slot' (channel, direct_message or private_group), and
+ the mapping between partner_id/channel_id for direct_message channels.
+ :returns dict : the grouped channels and the mapping
+ """
+ values = {}
+ my_partner_id = self.env.user.partner_id.id
+ pinned_channels = self.env['mail.channel.partner'].search([('partner_id', '=', my_partner_id), ('is_pinned', '=', True)]).mapped('channel_id')
+
+ # get the group/public channels
+ values['channel_channel'] = self.search([('channel_type', '=', 'channel'), ('public', 'in', ['public', 'groups']), ('channel_partner_ids', 'in', [my_partner_id])]).channel_info()
+
+ # get the pinned 'direct message' channel
+ direct_message_channels = self.search([('channel_type', '=', 'chat'), ('id', 'in', pinned_channels.ids)])
+ values['channel_direct_message'] = direct_message_channels.channel_info()
+
+ # get the private group
+ values['channel_private_group'] = self.search([('channel_type', '=', 'channel'), ('public', '=', 'private'), ('channel_partner_ids', 'in', [my_partner_id])]).channel_info()
+ return values
+
+ @api.model
+ def channel_search_to_join(self, name=None, domain=None):
+ """ Return the channel info of the channel the current partner can join
+ :param name : the name of the researched channels
+ :param domain : the base domain of the research
+ :returns dict : channel dict
+ """
+ if not domain:
+ domain = []
+ domain = expression.AND([
+ [('channel_type', '=', 'channel')],
+ [('channel_partner_ids', 'not in', [self.env.user.partner_id.id])],
+ [('public', '!=', 'private')],
+ domain
+ ])
+ if name:
+ domain = expression.AND([domain, [('name', 'ilike', '%'+name+'%')]])
+ return self.search(domain).read(['name', 'public', 'uuid', 'channel_type'])
+
+ def channel_join_and_get_info(self):
+ self.ensure_one()
+ added = self.action_follow()
+ if added and self.channel_type == 'channel' and not self.email_send:
+ notification = _('<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>', self.id, self.name)
+ self.message_post(body=notification, message_type="notification", subtype_xmlid="mail.mt_comment")
+
+ if added and self.moderation_guidelines:
+ self._send_guidelines(self.env.user.partner_id)
+
+ channel_info = self.channel_info('join')[0]
+ self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_info)
+ return channel_info
+
+ @api.model
+ def channel_create(self, name, privacy='public'):
+ """ Create a channel and add the current partner, broadcast it (to make the user directly
+ listen to it when polling)
+ :param name : the name of the channel to create
+ :param privacy : privacy of the channel. Should be 'public' or 'private'.
+ :return dict : channel header
+ """
+ # create the channel
+ new_channel = self.create({
+ 'name': name,
+ 'public': privacy,
+ 'email_send': False,
+ })
+ notification = _('<div class="o_mail_notification">created <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>', new_channel.id, new_channel.name)
+ new_channel.message_post(body=notification, message_type="notification", subtype_xmlid="mail.mt_comment")
+ channel_info = new_channel.channel_info('creation')[0]
+ self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_info)
+ return channel_info
+
+ @api.model
+ def get_mention_suggestions(self, search, limit=8):
+ """ Return 'limit'-first channels' id, name and public fields such that the name matches a
+ 'search' string. Exclude channels of type chat (DM), and private channels the current
+ user isn't registered to. """
+ domain = expression.AND([
+ [('name', 'ilike', search)],
+ [('channel_type', '=', 'channel')],
+ expression.OR([
+ [('public', '!=', 'private')],
+ [('channel_partner_ids', 'in', [self.env.user.partner_id.id])]
+ ])
+ ])
+ return self.search_read(domain, ['id', 'name', 'public', 'channel_type'], limit=limit)
+
+ @api.model
+ def channel_fetch_listeners(self, uuid):
+ """ Return the id, name and email of partners listening to the given channel """
+ self._cr.execute("""
+ SELECT P.id, P.name, P.email
+ FROM mail_channel_partner CP
+ INNER JOIN res_partner P ON CP.partner_id = P.id
+ INNER JOIN mail_channel C ON CP.channel_id = C.id
+ WHERE C.uuid = %s""", (uuid,))
+ return self._cr.dictfetchall()
+
+ def channel_fetch_preview(self):
+ """ Return the last message of the given channels """
+ if not self:
+ return []
+ channels_last_message_ids = self._channel_last_message_ids()
+ channels_preview = dict((r['message_id'], r) for r in channels_last_message_ids)
+ last_messages = self.env['mail.message'].browse(channels_preview).message_format()
+ for message in last_messages:
+ channel = channels_preview[message['id']]
+ del(channel['message_id'])
+ channel['last_message'] = message
+ return list(channels_preview.values())
+
+ def _channel_last_message_ids(self):
+ """ Return the last message of the given channels."""
+ if not self:
+ return []
+ self.flush()
+ self.env.cr.execute("""
+ SELECT mail_channel_id AS id, MAX(mail_message_id) AS message_id
+ FROM mail_message_mail_channel_rel
+ WHERE mail_channel_id IN %s
+ GROUP BY mail_channel_id
+ """, (tuple(self.ids),))
+ return self.env.cr.dictfetchall()
+
+ #------------------------------------------------------
+ # Commands
+ #------------------------------------------------------
+ @api.model
+ @ormcache()
+ def get_mention_commands(self):
+ """ Returns the allowed commands in channels """
+ commands = []
+ for n in dir(self):
+ match = re.search('^_define_command_(.+?)$', n)
+ if match:
+ command = getattr(self, n)()
+ command['name'] = match.group(1)
+ commands.append(command)
+ return commands
+
+ def execute_command(self, command='', **kwargs):
+ """ Executes a given command """
+ self.ensure_one()
+ command_callback = getattr(self, '_execute_command_' + command, False)
+ if command_callback:
+ command_callback(**kwargs)
+
+ def _send_transient_message(self, partner_to, content):
+ """ Notifies partner_to that a message (not stored in DB) has been
+ written in this channel """
+ self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', partner_to.id), {
+ 'body': "<span class='o_mail_notification'>" + content + "</span>",
+ 'channel_ids': [self.id],
+ 'info': 'transient_message',
+ })
+
+ def _define_command_help(self):
+ return {'help': _("Show a helper message")}
+
+ def _execute_command_help(self, **kwargs):
+ partner = self.env.user.partner_id
+ if self.channel_type == 'channel':
+ msg = _("You are in channel <b>#%s</b>.", self.name)
+ if self.public == 'private':
+ msg += _(" This channel is private. People must be invited to join it.")
+ else:
+ all_channel_partners = self.env['mail.channel.partner'].with_context(active_test=False)
+ channel_partners = all_channel_partners.search([('partner_id', '!=', partner.id), ('channel_id', '=', self.id)])
+ msg = _("You are in a private conversation with <b>@%s</b>.", channel_partners[0].partner_id.name if channel_partners else _('Anonymous'))
+ msg += _("""<br><br>
+ Type <b>@username</b> to mention someone, and grab his attention.<br>
+ Type <b>#channel</b> to mention a channel.<br>
+ Type <b>/command</b> to execute a command.<br>
+ Type <b>:shortcut</b> to insert a canned response in your message.<br>""")
+
+ self._send_transient_message(partner, msg)
+
+ def _define_command_leave(self):
+ return {'help': _("Leave this channel")}
+
+ def _execute_command_leave(self, **kwargs):
+ if self.channel_type == 'channel':
+ self.action_unfollow()
+ else:
+ self.channel_pin(self.uuid, False)
+
+ def _define_command_who(self):
+ return {
+ 'channel_types': ['channel', 'chat'],
+ 'help': _("List users in the current channel")
+ }
+
+ def _execute_command_who(self, **kwargs):
+ partner = self.env.user.partner_id
+ members = [
+ '<a href="#" data-oe-id='+str(p.id)+' data-oe-model="res.partner">@'+p.name+'</a>'
+ for p in self.channel_partner_ids[:30] if p != partner
+ ]
+ if len(members) == 0:
+ msg = _("You are alone in this channel.")
+ else:
+ dots = "..." if len(members) != len(self.channel_partner_ids) - 1 else ""
+ msg = _("Users in this channel: %(members)s %(dots)s and you.", members=", ".join(members), dots=dots)
+
+ self._send_transient_message(partner, msg)
diff --git a/addons/mail/models/mail_followers.py b/addons/mail/models/mail_followers.py
new file mode 100644
index 00000000..e584b87d
--- /dev/null
+++ b/addons/mail/models/mail_followers.py
@@ -0,0 +1,397 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from collections import defaultdict
+import itertools
+
+from odoo import api, fields, models
+
+
+class Followers(models.Model):
+ """ mail_followers holds the data related to the follow mechanism inside
+ Odoo. Partners can choose to follow documents (records) of any kind
+ that inherits from mail.thread. Following documents allow to receive
+ notifications for new messages. A subscription is characterized by:
+
+ :param: res_model: model of the followed objects
+ :param: res_id: ID of resource (may be 0 for every objects)
+ """
+ _name = 'mail.followers'
+ _rec_name = 'partner_id'
+ _log_access = False
+ _description = 'Document Followers'
+
+ # Note. There is no integrity check on model names for performance reasons.
+ # However, followers of unlinked models are deleted by models themselves
+ # (see 'ir.model' inheritance).
+ res_model = fields.Char(
+ 'Related Document Model Name', required=True, index=True)
+ res_id = fields.Many2oneReference(
+ 'Related Document ID', index=True, help='Id of the followed resource', model_field='res_model')
+ partner_id = fields.Many2one(
+ 'res.partner', string='Related Partner', ondelete='cascade', index=True, domain=[('type', '!=', 'private')])
+ channel_id = fields.Many2one(
+ 'mail.channel', string='Listener', ondelete='cascade', index=True)
+ subtype_ids = fields.Many2many(
+ 'mail.message.subtype', string='Subtype',
+ help="Message subtypes followed, meaning subtypes that will be pushed onto the user's Wall.")
+ name = fields.Char('Name', compute='_compute_related_fields',
+ help="Name of the related partner (if exist) or the related channel")
+ email = fields.Char('Email', compute='_compute_related_fields',
+ help="Email of the related partner (if exist) or False")
+ is_active = fields.Boolean('Is Active', compute='_compute_related_fields',
+ help="If the related partner is active (if exist) or if related channel exist")
+
+ def _invalidate_documents(self, vals_list=None):
+ """ Invalidate the cache of the documents followed by ``self``.
+
+ Modifying followers change access rights to individual documents. As the
+ cache may contain accessible/inaccessible data, one has to refresh it.
+ """
+ to_invalidate = defaultdict(list)
+ for record in (vals_list or [{'res_model': rec.res_model, 'res_id': rec.res_id} for rec in self]):
+ if record.get('res_id'):
+ to_invalidate[record.get('res_model')].append(record.get('res_id'))
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ res = super(Followers, self).create(vals_list)
+ res._invalidate_documents(vals_list)
+ return res
+
+ def write(self, vals):
+ if 'res_model' in vals or 'res_id' in vals:
+ self._invalidate_documents()
+ res = super(Followers, self).write(vals)
+ if any(x in vals for x in ['res_model', 'res_id', 'partner_id']):
+ self._invalidate_documents()
+ return res
+
+ def unlink(self):
+ self._invalidate_documents()
+ return super(Followers, self).unlink()
+
+ _sql_constraints = [
+ ('mail_followers_res_partner_res_model_id_uniq', 'unique(res_model,res_id,partner_id)', 'Error, a partner cannot follow twice the same object.'),
+ ('mail_followers_res_channel_res_model_id_uniq', 'unique(res_model,res_id,channel_id)', 'Error, a channel cannot follow twice the same object.'),
+ ('partner_xor_channel', 'CHECK((partner_id IS NULL) != (channel_id IS NULL))', 'Error: A follower must be either a partner or a channel (but not both).')
+ ]
+
+ # --------------------------------------------------
+ # Private tools methods to fetch followers data
+ # --------------------------------------------------
+
+ @api.depends('partner_id', 'channel_id')
+ def _compute_related_fields(self):
+ for follower in self:
+ if follower.partner_id:
+ follower.name = follower.partner_id.name
+ follower.email = follower.partner_id.email
+ follower.is_active = follower.partner_id.active
+ else:
+ follower.name = follower.channel_id.name
+ follower.is_active = bool(follower.channel_id)
+ follower.email = False
+
+ def _get_recipient_data(self, records, message_type, subtype_id, pids=None, cids=None):
+ """ Private method allowing to fetch recipients data based on a subtype.
+ Purpose of this method is to fetch all data necessary to notify recipients
+ in a single query. It fetches data from
+
+ * followers (partners and channels) of records that follow the given
+ subtype if records and subtype are set;
+ * partners if pids is given;
+ * channels if cids is given;
+
+ :param records: fetch data from followers of records that follow subtype_id;
+ :param message_type: mail.message.message_type in order to allow custom behavior depending on it (SMS for example);
+ :param subtype_id: mail.message.subtype to check against followers;
+ :param pids: additional set of partner IDs from which to fetch recipient data;
+ :param cids: additional set of channel IDs from which to fetch recipient data;
+
+ :return: list of recipient data which is a tuple containing
+ partner ID (void if channel ID),
+ channel ID (void if partner ID),
+ active value (always True for channels),
+ share status of partner (void as irrelevant if channel ID),
+ notification status of partner or channel (email or inbox),
+ user groups of partner (void as irrelevant if channel ID),
+ """
+ self.env['mail.followers'].flush(['partner_id', 'channel_id', 'subtype_ids'])
+ self.env['mail.message.subtype'].flush(['internal'])
+ self.env['res.users'].flush(['notification_type', 'active', 'partner_id', 'groups_id'])
+ self.env['res.partner'].flush(['active', 'partner_share'])
+ self.env['res.groups'].flush(['users'])
+ self.env['mail.channel'].flush(['email_send', 'channel_type'])
+ if records and subtype_id:
+ query = """
+SELECT DISTINCT ON(pid, cid) * FROM (
+ WITH sub_followers AS (
+ SELECT fol.id, fol.partner_id, fol.channel_id, subtype.internal
+ FROM mail_followers fol
+ RIGHT JOIN mail_followers_mail_message_subtype_rel subrel
+ ON subrel.mail_followers_id = fol.id
+ RIGHT JOIN mail_message_subtype subtype
+ ON subtype.id = subrel.mail_message_subtype_id
+ WHERE subrel.mail_message_subtype_id = %%s AND fol.res_model = %%s AND fol.res_id IN %%s
+ )
+ SELECT partner.id as pid, NULL::int AS cid,
+ partner.active as active, partner.partner_share as pshare, NULL as ctype,
+ users.notification_type AS notif, array_agg(groups.id) AS groups
+ FROM res_partner partner
+ LEFT JOIN res_users users ON users.partner_id = partner.id AND users.active
+ LEFT JOIN res_groups_users_rel groups_rel ON groups_rel.uid = users.id
+ LEFT JOIN res_groups groups ON groups.id = groups_rel.gid
+ WHERE EXISTS (
+ SELECT partner_id FROM sub_followers
+ WHERE sub_followers.channel_id IS NULL
+ AND sub_followers.partner_id = partner.id
+ AND (coalesce(sub_followers.internal, false) <> TRUE OR coalesce(partner.partner_share, false) <> TRUE)
+ ) %s
+ GROUP BY partner.id, users.notification_type
+ UNION
+ SELECT NULL::int AS pid, channel.id AS cid,
+ TRUE as active, NULL AS pshare, channel.channel_type AS ctype,
+ CASE WHEN channel.email_send = TRUE THEN 'email' ELSE 'inbox' END AS notif, NULL AS groups
+ FROM mail_channel channel
+ WHERE EXISTS (
+ SELECT channel_id FROM sub_followers WHERE partner_id IS NULL AND sub_followers.channel_id = channel.id
+ ) %s
+) AS x
+ORDER BY pid, cid, notif
+""" % ('OR partner.id IN %s' if pids else '', 'OR channel.id IN %s' if cids else '')
+ params = [subtype_id, records._name, tuple(records.ids)]
+ if pids:
+ params.append(tuple(pids))
+ if cids:
+ params.append(tuple(cids))
+ self.env.cr.execute(query, tuple(params))
+ res = self.env.cr.fetchall()
+ elif pids or cids:
+ params, query_pid, query_cid = [], '', ''
+ if pids:
+ query_pid = """
+SELECT partner.id as pid, NULL::int AS cid,
+ partner.active as active, partner.partner_share as pshare, NULL as ctype,
+ users.notification_type AS notif, NULL AS groups
+FROM res_partner partner
+LEFT JOIN res_users users ON users.partner_id = partner.id AND users.active
+WHERE partner.id IN %s"""
+ params.append(tuple(pids))
+ if cids:
+ query_cid = """
+SELECT NULL::int AS pid, channel.id AS cid,
+ TRUE as active, NULL AS pshare, channel.channel_type AS ctype,
+ CASE when channel.email_send = TRUE then 'email' else 'inbox' end AS notif, NULL AS groups
+FROM mail_channel channel WHERE channel.id IN %s """
+ params.append(tuple(cids))
+ query = ' UNION'.join(x for x in [query_pid, query_cid] if x)
+ query = 'SELECT DISTINCT ON(pid, cid) * FROM (%s) AS x ORDER BY pid, cid, notif' % query
+ self.env.cr.execute(query, tuple(params))
+ res = self.env.cr.fetchall()
+ else:
+ res = []
+ return res
+
+ def _get_subscription_data(self, doc_data, pids, cids, include_pshare=False, include_active=False):
+ """ Private method allowing to fetch follower data from several documents of a given model.
+ Followers can be filtered given partner IDs and channel IDs.
+
+ :param doc_data: list of pair (res_model, res_ids) that are the documents from which we
+ want to have subscription data;
+ :param pids: optional partner to filter; if None take all, otherwise limitate to pids
+ :param cids: optional channel to filter; if None take all, otherwise limitate to cids
+ :param include_pshare: optional join in partner to fetch their share status
+ :param include_active: optional join in partner to fetch their active flag
+
+ :return: list of followers data which is a list of tuples containing
+ follower ID,
+ document ID,
+ partner ID (void if channel_id),
+ channel ID (void if partner_id),
+ followed subtype IDs,
+ share status of partner (void id channel_id, returned only if include_pshare is True)
+ active flag status of partner (void id channel_id, returned only if include_active is True)
+ """
+ # base query: fetch followers of given documents
+ where_clause = ' OR '.join(['fol.res_model = %s AND fol.res_id IN %s'] * len(doc_data))
+ where_params = list(itertools.chain.from_iterable((rm, tuple(rids)) for rm, rids in doc_data))
+
+ # additional: filter on optional pids / cids
+ sub_where = []
+ if pids:
+ sub_where += ["fol.partner_id IN %s"]
+ where_params.append(tuple(pids))
+ elif pids is not None:
+ sub_where += ["fol.partner_id IS NULL"]
+ if cids:
+ sub_where += ["fol.channel_id IN %s"]
+ where_params.append(tuple(cids))
+ elif cids is not None:
+ sub_where += ["fol.channel_id IS NULL"]
+ if sub_where:
+ where_clause += "AND (%s)" % " OR ".join(sub_where)
+
+ query = """
+SELECT fol.id, fol.res_id, fol.partner_id, fol.channel_id, array_agg(subtype.id)%s%s
+FROM mail_followers fol
+%s
+LEFT JOIN mail_followers_mail_message_subtype_rel fol_rel ON fol_rel.mail_followers_id = fol.id
+LEFT JOIN mail_message_subtype subtype ON subtype.id = fol_rel.mail_message_subtype_id
+WHERE %s
+GROUP BY fol.id%s%s""" % (
+ ', partner.partner_share' if include_pshare else '',
+ ', partner.active' if include_active else '',
+ 'LEFT JOIN res_partner partner ON partner.id = fol.partner_id' if (include_pshare or include_active) else '',
+ where_clause,
+ ', partner.partner_share' if include_pshare else '',
+ ', partner.active' if include_active else ''
+ )
+ self.env.cr.execute(query, tuple(where_params))
+ return self.env.cr.fetchall()
+
+ # --------------------------------------------------
+ # Private tools methods to generate new subscription
+ # --------------------------------------------------
+
+ def _insert_followers(self, res_model, res_ids, partner_ids, partner_subtypes, channel_ids, channel_subtypes,
+ customer_ids=None, check_existing=True, existing_policy='skip'):
+ """ Main internal method allowing to create or update followers for documents, given a
+ res_model and the document res_ids. This method does not handle access rights. This is the
+ role of the caller to ensure there is no security breach.
+
+ :param partner_subtypes: optional subtypes for new partner followers. If not given, default
+ ones are computed;
+ :param channel_subtypes: optional subtypes for new channel followers. If not given, default
+ ones are computed;
+ :param customer_ids: see ``_add_default_followers``
+ :param check_existing: see ``_add_followers``;
+ :param existing_policy: see ``_add_followers``;
+ """
+ sudo_self = self.sudo().with_context(default_partner_id=False, default_channel_id=False)
+ if not partner_subtypes and not channel_subtypes: # no subtypes -> default computation, no force, skip existing
+ new, upd = self._add_default_followers(
+ res_model, res_ids,
+ partner_ids, channel_ids,
+ customer_ids=customer_ids,
+ check_existing=check_existing,
+ existing_policy=existing_policy)
+ else:
+ new, upd = self._add_followers(
+ res_model, res_ids,
+ partner_ids, partner_subtypes,
+ channel_ids, channel_subtypes,
+ check_existing=check_existing,
+ existing_policy=existing_policy)
+ if new:
+ sudo_self.create([
+ dict(values, res_id=res_id)
+ for res_id, values_list in new.items()
+ for values in values_list
+ ])
+ for fol_id, values in upd.items():
+ sudo_self.browse(fol_id).write(values)
+
+ def _add_default_followers(self, res_model, res_ids, partner_ids, channel_ids=None, customer_ids=None,
+ check_existing=True, existing_policy='skip'):
+ """ Shortcut to ``_add_followers`` that computes default subtypes. Existing
+ followers are skipped as their subscription is considered as more important
+ compared to new default subscription.
+
+ :param customer_ids: optional list of partner ids that are customers. It is used if computing
+ default subtype is necessary and allow to avoid the check of partners being customers (no
+ user or share user). It is just a matter of saving queries if the info is already known;
+ :param check_existing: see ``_add_followers``;
+ :param existing_policy: see ``_add_followers``;
+
+ :return: see ``_add_followers``
+ """
+ if not partner_ids and not channel_ids:
+ return dict(), dict()
+
+ default, _, external = self.env['mail.message.subtype'].default_subtypes(res_model)
+ if partner_ids and customer_ids is None:
+ customer_ids = self.env['res.partner'].sudo().search([('id', 'in', partner_ids), ('partner_share', '=', True)]).ids
+
+ c_stypes = dict.fromkeys(channel_ids or [], default.ids)
+ p_stypes = dict((pid, external.ids if pid in customer_ids else default.ids) for pid in partner_ids)
+
+ return self._add_followers(res_model, res_ids, partner_ids, p_stypes, channel_ids, c_stypes, check_existing=check_existing, existing_policy=existing_policy)
+
+ def _add_followers(self, res_model, res_ids, partner_ids, partner_subtypes, channel_ids, channel_subtypes,
+ check_existing=False, existing_policy='skip'):
+ """ Internal method that generates values to insert or update followers. Callers have to
+ handle the result, for example by making a valid ORM command, inserting or updating directly
+ follower records, ... This method returns two main data
+
+ * first one is a dict which keys are res_ids. Value is a list of dict of values valid for
+ creating new followers for the related res_id;
+ * second one is a dict which keys are follower ids. Value is a dict of values valid for
+ updating the related follower record;
+
+ :param check_existing: if True, check for existing followers for given documents and handle
+ them according to existing_policy parameter. Setting to False allows to save some computation
+ if caller is sure there are no conflict for followers;
+ :param existing policy: if check_existing, tells what to do with already-existing followers:
+
+ * skip: simply skip existing followers, do not touch them;
+ * force: update existing with given subtypes only;
+ * replace: replace existing with new subtypes (like force without old / new follower);
+ * update: gives an update dict allowing to add missing subtypes (no subtype removal);
+ """
+ _res_ids = res_ids or [0]
+ data_fols, doc_pids, doc_cids = dict(), dict((i, set()) for i in _res_ids), dict((i, set()) for i in _res_ids)
+
+ if check_existing and res_ids:
+ for fid, rid, pid, cid, sids in self._get_subscription_data([(res_model, res_ids)], partner_ids or None, channel_ids or None):
+ if existing_policy != 'force':
+ if pid:
+ doc_pids[rid].add(pid)
+ elif cid:
+ doc_cids[rid].add(cid)
+ data_fols[fid] = (rid, pid, cid, sids)
+
+ if existing_policy == 'force':
+ self.sudo().browse(data_fols.keys()).unlink()
+
+ new, update = dict(), dict()
+ for res_id in _res_ids:
+ for partner_id in set(partner_ids or []):
+ if partner_id not in doc_pids[res_id]:
+ new.setdefault(res_id, list()).append({
+ 'res_model': res_model,
+ 'partner_id': partner_id,
+ 'subtype_ids': [(6, 0, partner_subtypes[partner_id])],
+ })
+ elif existing_policy in ('replace', 'update'):
+ fol_id, sids = next(((key, val[3]) for key, val in data_fols.items() if val[0] == res_id and val[1] == partner_id), (False, []))
+ new_sids = set(partner_subtypes[partner_id]) - set(sids)
+ old_sids = set(sids) - set(partner_subtypes[partner_id])
+ update_cmd = []
+ if fol_id and new_sids:
+ update_cmd += [(4, sid) for sid in new_sids]
+ if fol_id and old_sids and existing_policy == 'replace':
+ update_cmd += [(3, sid) for sid in old_sids]
+ if update_cmd:
+ update[fol_id] = {'subtype_ids': update_cmd}
+
+ for channel_id in set(channel_ids or []):
+ if channel_id not in doc_cids[res_id]:
+ new.setdefault(res_id, list()).append({
+ 'res_model': res_model,
+ 'channel_id': channel_id,
+ 'subtype_ids': [(6, 0, channel_subtypes[channel_id])],
+ })
+ elif existing_policy in ('replace', 'update'):
+ fol_id, sids = next(((key, val[3]) for key, val in data_fols.items() if val[0] == res_id and val[2] == channel_id), (False, []))
+ new_sids = set(channel_subtypes[channel_id]) - set(sids)
+ old_sids = set(sids) - set(channel_subtypes[channel_id])
+ update_cmd = []
+ if fol_id and new_sids:
+ update_cmd += [(4, sid) for sid in new_sids]
+ if fol_id and old_sids and existing_policy == 'replace':
+ update_cmd += [(3, sid) for sid in old_sids]
+ if update_cmd:
+ update[fol_id] = {'subtype_ids': update_cmd}
+
+ return new, update
diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py
new file mode 100644
index 00000000..672476cf
--- /dev/null
+++ b/addons/mail/models/mail_mail.py
@@ -0,0 +1,446 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import ast
+import base64
+import datetime
+import logging
+import psycopg2
+import smtplib
+import threading
+import re
+
+from collections import defaultdict
+
+from odoo import _, api, fields, models
+from odoo import tools
+from odoo.addons.base.models.ir_mail_server import MailDeliveryException
+
+_logger = logging.getLogger(__name__)
+
+
+class MailMail(models.Model):
+ """ Model holding RFC2822 email messages to send. This model also provides
+ facilities to queue and send new email messages. """
+ _name = 'mail.mail'
+ _description = 'Outgoing Mails'
+ _inherits = {'mail.message': 'mail_message_id'}
+ _order = 'id desc'
+ _rec_name = 'subject'
+
+ # content
+ mail_message_id = fields.Many2one('mail.message', 'Message', required=True, ondelete='cascade', index=True,
+ auto_join=True)
+ body_html = fields.Text('Rich-text Contents', help="Rich-text/HTML message")
+ references = fields.Text('References', help='Message references, such as identifiers of previous messages',
+ readonly=1)
+ headers = fields.Text('Headers', copy=False)
+ # Auto-detected based on create() - if 'mail_message_id' was passed then this mail is a notification
+ # and during unlink() we will not cascade delete the parent and its attachments
+ notification = fields.Boolean('Is Notification',
+ help='Mail has been created to notify people of an existing mail.message')
+ # recipients: include inactive partners (they may have been archived after
+ # the message was sent, but they should remain visible in the relation)
+ email_to = fields.Text('To', help='Message recipients (emails)')
+ email_cc = fields.Char('Cc', help='Carbon copy message recipients')
+ recipient_ids = fields.Many2many('res.partner', string='To (Partners)',
+ context={'active_test': False})
+ # process
+ state = fields.Selection([
+ ('outgoing', 'Outgoing'),
+ ('sent', 'Sent'),
+ ('received', 'Received'),
+ ('exception', 'Delivery Failed'),
+ ('cancel', 'Cancelled'),
+ ], 'Status', readonly=True, copy=False, default='outgoing')
+ auto_delete = fields.Boolean(
+ 'Auto Delete',
+ 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.")
+ failure_reason = fields.Text(
+ 'Failure Reason', readonly=1,
+ help="Failure reason. This is usually the exception thrown by the email server, stored to ease the debugging of mailing issues.")
+ scheduled_date = fields.Char('Scheduled Send Date',
+ help="If set, the queue manager will send the email after the date. If not set, the email will be send as soon as possible.")
+
+ @api.model_create_multi
+ def create(self, values_list):
+ # notification field: if not set, set if mail comes from an existing mail.message
+ for values in values_list:
+ if 'notification' not in values and values.get('mail_message_id'):
+ values['notification'] = True
+
+ new_mails = super(MailMail, self).create(values_list)
+
+ new_mails_w_attach = self
+ for mail, values in zip(new_mails, values_list):
+ if values.get('attachment_ids'):
+ new_mails_w_attach += mail
+ if new_mails_w_attach:
+ new_mails_w_attach.mapped('attachment_ids').check(mode='read')
+
+ return new_mails
+
+ def write(self, vals):
+ res = super(MailMail, self).write(vals)
+ if vals.get('attachment_ids'):
+ for mail in self:
+ mail.attachment_ids.check(mode='read')
+ return res
+
+ def unlink(self):
+ # cascade-delete the parent message for all mails that are not created for a notification
+ mail_msg_cascade_ids = [mail.mail_message_id.id for mail in self if not mail.notification]
+ res = super(MailMail, self).unlink()
+ if mail_msg_cascade_ids:
+ self.env['mail.message'].browse(mail_msg_cascade_ids).unlink()
+ return res
+
+ @api.model
+ def default_get(self, fields):
+ # protection for `default_type` values leaking from menu action context (e.g. for invoices)
+ # To remove when automatic context propagation is removed in web client
+ if self._context.get('default_type') not in type(self).message_type.base_field.selection:
+ self = self.with_context(dict(self._context, default_type=None))
+ return super(MailMail, self).default_get(fields)
+
+ def mark_outgoing(self):
+ return self.write({'state': 'outgoing'})
+
+ def cancel(self):
+ return self.write({'state': 'cancel'})
+
+ @api.model
+ def process_email_queue(self, ids=None):
+ """Send immediately queued messages, committing after each
+ message is sent - this is not transactional and should
+ not be called during another transaction!
+
+ :param list ids: optional list of emails ids to send. If passed
+ no search is performed, and these ids are used
+ instead.
+ :param dict context: if a 'filters' key is present in context,
+ this value will be used as an additional
+ filter to further restrict the outgoing
+ messages to send (by default all 'outgoing'
+ messages are sent).
+ """
+ filters = ['&',
+ ('state', '=', 'outgoing'),
+ '|',
+ ('scheduled_date', '<', datetime.datetime.now()),
+ ('scheduled_date', '=', False)]
+ if 'filters' in self._context:
+ filters.extend(self._context['filters'])
+ # TODO: make limit configurable
+ filtered_ids = self.search(filters, limit=10000).ids
+ if not ids:
+ ids = filtered_ids
+ else:
+ ids = list(set(filtered_ids) & set(ids))
+ ids.sort()
+
+ res = None
+ try:
+ # auto-commit except in testing mode
+ auto_commit = not getattr(threading.currentThread(), 'testing', False)
+ res = self.browse(ids).send(auto_commit=auto_commit)
+ except Exception:
+ _logger.exception("Failed processing mail queue")
+ return res
+
+ def _postprocess_sent_message(self, success_pids, failure_reason=False, failure_type=None):
+ """Perform any post-processing necessary after sending ``mail``
+ successfully, including deleting it completely along with its
+ attachment if the ``auto_delete`` flag of the mail was set.
+ Overridden by subclasses for extra post-processing behaviors.
+
+ :return: True
+ """
+ notif_mails_ids = [mail.id for mail in self if mail.notification]
+ if notif_mails_ids:
+ notifications = self.env['mail.notification'].search([
+ ('notification_type', '=', 'email'),
+ ('mail_id', 'in', notif_mails_ids),
+ ('notification_status', 'not in', ('sent', 'canceled'))
+ ])
+ if notifications:
+ # find all notification linked to a failure
+ failed = self.env['mail.notification']
+ if failure_type:
+ failed = notifications.filtered(lambda notif: notif.res_partner_id not in success_pids)
+ (notifications - failed).sudo().write({
+ 'notification_status': 'sent',
+ 'failure_type': '',
+ 'failure_reason': '',
+ })
+ if failed:
+ failed.sudo().write({
+ 'notification_status': 'exception',
+ 'failure_type': failure_type,
+ 'failure_reason': failure_reason,
+ })
+ messages = notifications.mapped('mail_message_id').filtered(lambda m: m.is_thread_message())
+ # TDE TODO: could be great to notify message-based, not notifications-based, to lessen number of notifs
+ messages._notify_message_notification_update() # notify user that we have a failure
+ if not failure_type or failure_type == 'RECIPIENT': # if we have another error, we want to keep the mail.
+ mail_to_delete_ids = [mail.id for mail in self if mail.auto_delete]
+ self.browse(mail_to_delete_ids).sudo().unlink()
+ return True
+
+ # ------------------------------------------------------
+ # mail_mail formatting, tools and send mechanism
+ # ------------------------------------------------------
+
+ def _send_prepare_body(self):
+ """Return a specific ir_email body. The main purpose of this method
+ is to be inherited to add custom content depending on some module."""
+ self.ensure_one()
+ return self.body_html or ''
+
+ def _send_prepare_values(self, partner=None):
+ """Return a dictionary for specific email values, depending on a
+ partner, or generic to the whole recipients given by mail.email_to.
+
+ :param Model partner: specific recipient partner
+ """
+ self.ensure_one()
+ body = self._send_prepare_body()
+ body_alternative = tools.html2plaintext(body)
+ if partner:
+ email_to = [tools.formataddr((partner.name or 'False', partner.email or 'False'))]
+ else:
+ email_to = tools.email_split_and_format(self.email_to)
+ res = {
+ 'body': body,
+ 'body_alternative': body_alternative,
+ 'email_to': email_to,
+ }
+ return res
+
+ def _split_by_server(self):
+ """Returns an iterator of pairs `(mail_server_id, record_ids)` for current recordset.
+
+ The same `mail_server_id` may repeat in order to limit batch size according to
+ the `mail.session.batch.size` system parameter.
+ """
+ groups = defaultdict(list)
+ # Turn prefetch OFF to avoid MemoryError on very large mail queues, we only care
+ # about the mail server ids in this case.
+ for mail in self.with_context(prefetch_fields=False):
+ groups[mail.mail_server_id.id].append(mail.id)
+ sys_params = self.env['ir.config_parameter'].sudo()
+ batch_size = int(sys_params.get_param('mail.session.batch.size', 1000))
+ for server_id, record_ids in groups.items():
+ for mail_batch in tools.split_every(batch_size, record_ids):
+ yield server_id, mail_batch
+
+ def send(self, auto_commit=False, raise_exception=False):
+ """ Sends the selected emails immediately, ignoring their current
+ state (mails that have already been sent should not be passed
+ unless they should actually be re-sent).
+ Emails successfully delivered are marked as 'sent', and those
+ that fail to be deliver are marked as 'exception', and the
+ corresponding error mail is output in the server logs.
+
+ :param bool auto_commit: whether to force a commit of the mail status
+ after sending each mail (meant only for scheduler processing);
+ should never be True during normal transactions (default: False)
+ :param bool raise_exception: whether to raise an exception if the
+ email sending process has failed
+ :return: True
+ """
+ for server_id, batch_ids in self._split_by_server():
+ smtp_session = None
+ try:
+ smtp_session = self.env['ir.mail_server'].connect(mail_server_id=server_id)
+ except Exception as exc:
+ if raise_exception:
+ # To be consistent and backward compatible with mail_mail.send() raised
+ # exceptions, it is encapsulated into an Odoo MailDeliveryException
+ raise MailDeliveryException(_('Unable to connect to SMTP Server'), exc)
+ else:
+ batch = self.browse(batch_ids)
+ batch.write({'state': 'exception', 'failure_reason': exc})
+ batch._postprocess_sent_message(success_pids=[], failure_type="SMTP")
+ else:
+ self.browse(batch_ids)._send(
+ auto_commit=auto_commit,
+ raise_exception=raise_exception,
+ smtp_session=smtp_session)
+ _logger.info(
+ 'Sent batch %s emails via mail server ID #%s',
+ len(batch_ids), server_id)
+ finally:
+ if smtp_session:
+ smtp_session.quit()
+
+ def _send(self, auto_commit=False, raise_exception=False, smtp_session=None):
+ IrMailServer = self.env['ir.mail_server']
+ IrAttachment = self.env['ir.attachment']
+ for mail_id in self.ids:
+ success_pids = []
+ failure_type = None
+ processing_pid = None
+ mail = None
+ try:
+ mail = self.browse(mail_id)
+ if mail.state != 'outgoing':
+ if mail.state != 'exception' and mail.auto_delete:
+ mail.sudo().unlink()
+ continue
+
+ # remove attachments if user send the link with the access_token
+ body = mail.body_html or ''
+ attachments = mail.attachment_ids
+ for link in re.findall(r'/web/(?:content|image)/([0-9]+)', body):
+ attachments = attachments - IrAttachment.browse(int(link))
+
+ # load attachment binary data with a separate read(), as prefetching all
+ # `datas` (binary field) could bloat the browse cache, triggerring
+ # soft/hard mem limits with temporary data.
+ attachments = [(a['name'], base64.b64decode(a['datas']), a['mimetype'])
+ for a in attachments.sudo().read(['name', 'datas', 'mimetype']) if
+ a['datas'] is not False]
+
+ # specific behavior to customize the send email for notified partners
+ email_list = []
+ if mail.email_to:
+ email_list.append(mail._send_prepare_values())
+ for partner in mail.recipient_ids:
+ values = mail._send_prepare_values(partner=partner)
+ values['partner_id'] = partner
+ email_list.append(values)
+
+ # headers
+ headers = {}
+ ICP = self.env['ir.config_parameter'].sudo()
+ bounce_alias = ICP.get_param("mail.bounce.alias")
+ bounce_alias_static = tools.str2bool(ICP.get_param("mail.bounce.alias.static", "False"))
+ catchall_domain = ICP.get_param("mail.catchall.domain")
+ if bounce_alias and catchall_domain:
+ if bounce_alias_static:
+ headers['Return-Path'] = '%s@%s' % (bounce_alias, catchall_domain)
+ elif mail.mail_message_id.is_thread_message():
+ headers['Return-Path'] = '%s+%d-%s-%d@%s' % (
+ bounce_alias, mail.id, mail.model, mail.res_id, catchall_domain)
+ else:
+ headers['Return-Path'] = '%s+%d@%s' % (bounce_alias, mail.id, catchall_domain)
+ if mail.headers:
+ try:
+ headers.update(ast.literal_eval(mail.headers))
+ except Exception:
+ pass
+
+ # Writing on the mail object may fail (e.g. lock on user) which
+ # would trigger a rollback *after* actually sending the email.
+ # To avoid sending twice the same email, provoke the failure earlier
+ mail.write({
+ 'state': 'exception',
+ 'failure_reason': _(
+ 'Error without exception. Probably due do sending an email without computed recipients.'),
+ })
+ # Update notification in a transient exception state to avoid concurrent
+ # update in case an email bounces while sending all emails related to current
+ # mail record.
+ notifs = self.env['mail.notification'].search([
+ ('notification_type', '=', 'email'),
+ ('mail_id', 'in', mail.ids),
+ ('notification_status', 'not in', ('sent', 'canceled'))
+ ])
+ if notifs:
+ notif_msg = _(
+ 'Error without exception. Probably due do concurrent access update of notification records. Please see with an administrator.')
+ notifs.sudo().write({
+ 'notification_status': 'exception',
+ 'failure_type': 'UNKNOWN',
+ 'failure_reason': notif_msg,
+ })
+ # `test_mail_bounce_during_send`, force immediate update to obtain the lock.
+ # see rev. 56596e5240ef920df14d99087451ce6f06ac6d36
+ notifs.flush(fnames=['notification_status', 'failure_type', 'failure_reason'], records=notifs)
+
+ # build an RFC2822 email.message.Message object and send it without queuing
+ res = None
+ for email in email_list:
+ # custom static email @stephan
+ custom_reply_to = mail.reply_to
+ if not mail.model:
+ custom_reply_to = mail.model
+ elif "sale.order" in mail.model:
+ custom_reply_to = 'sales2@indoteknik.com'
+ elif "purchase.order" in mail.model:
+ custom_reply_to = 'purchase@indoteknik.co.id'
+ else:
+ custom_reply_to = 'sales@indoteknik.com'
+ msg = IrMailServer.build_email(
+ email_from=mail.email_from,
+ email_to=email.get('email_to'),
+ subject=mail.subject,
+ body=email.get('body'),
+ body_alternative=email.get('body_alternative'),
+ email_cc=tools.email_split(mail.email_cc),
+ reply_to=custom_reply_to,
+ attachments=attachments,
+ message_id=mail.message_id,
+ references=mail.references,
+ object_id=mail.res_id and ('%s-%s' % (mail.res_id, mail.model)),
+ subtype='html',
+ subtype_alternative='plain',
+ headers=headers)
+ processing_pid = email.pop("partner_id", None)
+ try:
+ res = IrMailServer.send_email(
+ msg, mail_server_id=mail.mail_server_id.id, smtp_session=smtp_session)
+ if processing_pid:
+ success_pids.append(processing_pid)
+ processing_pid = None
+ except AssertionError as error:
+ if str(error) == IrMailServer.NO_VALID_RECIPIENT:
+ failure_type = "RECIPIENT"
+ # No valid recipient found for this particular
+ # mail item -> ignore error to avoid blocking
+ # delivery to next recipients, if any. If this is
+ # the only recipient, the mail will show as failed.
+ _logger.info("Ignoring invalid recipients for mail.mail %s: %s",
+ mail.message_id, email.get('email_to'))
+ else:
+ raise
+ if res: # mail has been sent at least once, no major exception occured
+ mail.write({'state': 'sent', 'message_id': res, 'failure_reason': False})
+ _logger.info('Mail with ID %r and Message-Id %r successfully sent', mail.id, mail.message_id)
+ # /!\ can't use mail.state here, as mail.refresh() will cause an error
+ # see revid:odo@openerp.com-20120622152536-42b2s28lvdv3odyr in 6.1
+ mail._postprocess_sent_message(success_pids=success_pids, failure_type=failure_type)
+ except MemoryError:
+ # prevent catching transient MemoryErrors, bubble up to notify user or abort cron job
+ # instead of marking the mail as failed
+ _logger.exception(
+ 'MemoryError while processing mail with ID %r and Msg-Id %r. Consider raising the --limit-memory-hard startup option',
+ mail.id, mail.message_id)
+ # mail status will stay on ongoing since transaction will be rollback
+ raise
+ except (psycopg2.Error, smtplib.SMTPServerDisconnected):
+ # If an error with the database or SMTP session occurs, chances are that the cursor
+ # or SMTP session are unusable, causing further errors when trying to save the state.
+ _logger.exception(
+ 'Exception while processing mail with ID %r and Msg-Id %r.',
+ mail.id, mail.message_id)
+ raise
+ except Exception as e:
+ failure_reason = tools.ustr(e)
+ _logger.exception('failed sending mail (id: %s) due to %s', mail.id, failure_reason)
+ mail.write({'state': 'exception', 'failure_reason': failure_reason})
+ mail._postprocess_sent_message(success_pids=success_pids, failure_reason=failure_reason,
+ failure_type='UNKNOWN')
+ if raise_exception:
+ if isinstance(e, (AssertionError, UnicodeEncodeError)):
+ if isinstance(e, UnicodeEncodeError):
+ value = "Invalid text: %s" % e.object
+ else:
+ value = '. '.join(e.args)
+ raise MailDeliveryException(value)
+ raise
+
+ if auto_commit is True:
+ self._cr.commit()
+ return True
diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py
new file mode 100644
index 00000000..881cde7c
--- /dev/null
+++ b/addons/mail/models/mail_message.py
@@ -0,0 +1,1251 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+import re
+
+from binascii import Error as binascii_error
+from collections import defaultdict
+from operator import itemgetter
+
+from odoo import _, api, fields, models, modules, tools
+from odoo.exceptions import AccessError, UserError
+from odoo.http import request
+from odoo.osv import expression
+from odoo.tools import groupby
+from odoo.tools.misc import clean_context
+
+_logger = logging.getLogger(__name__)
+_image_dataurl = re.compile(r'(data:image/[a-z]+?);base64,([a-z0-9+/\n]{3,}=*)\n*([\'"])(?: data-filename="([^"]*)")?', re.I)
+
+
+class Message(models.Model):
+ """ Messages model: system notification (replacing res.log notifications),
+ comments (OpenChatter discussion) and incoming emails. """
+ _name = 'mail.message'
+ _description = 'Message'
+ _order = 'id desc'
+ _rec_name = 'record_name'
+
+ @api.model
+ def default_get(self, fields):
+ res = super(Message, self).default_get(fields)
+ missing_author = 'author_id' in fields and 'author_id' not in res
+ missing_email_from = 'email_from' in fields and 'email_from' not in res
+ if missing_author or missing_email_from:
+ author_id, email_from = self.env['mail.thread']._message_compute_author(res.get('author_id'), res.get('email_from'), raise_exception=False)
+ if missing_email_from:
+ res['email_from'] = email_from
+ if missing_author:
+ res['author_id'] = author_id
+ return res
+
+ # content
+ subject = fields.Char('Subject')
+ date = fields.Datetime('Date', default=fields.Datetime.now)
+ body = fields.Html('Contents', default='', sanitize_style=True)
+ description = fields.Char(
+ 'Short description', compute="_compute_description",
+ help='Message description: either the subject, or the beginning of the body')
+ attachment_ids = fields.Many2many(
+ 'ir.attachment', 'message_attachment_rel',
+ 'message_id', 'attachment_id',
+ string='Attachments',
+ help='Attachments are linked to a document through model / res_id and to the message '
+ 'through this field.')
+ parent_id = fields.Many2one(
+ 'mail.message', 'Parent Message', index=True, ondelete='set null',
+ help="Initial thread message.")
+ child_ids = fields.One2many('mail.message', 'parent_id', 'Child Messages')
+ # related document
+ model = fields.Char('Related Document Model', index=True)
+ res_id = fields.Many2oneReference('Related Document ID', index=True, model_field='model')
+ record_name = fields.Char('Message Record Name', help="Name get of the related document.")
+ # characteristics
+ message_type = fields.Selection([
+ ('email', 'Email'),
+ ('comment', 'Comment'),
+ ('notification', 'System notification'),
+ ('user_notification', 'User Specific Notification')],
+ 'Type', required=True, default='email',
+ 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)
+ mail_activity_type_id = fields.Many2one(
+ 'mail.activity.type', 'Mail Activity Type',
+ index=True, ondelete='set null')
+ is_internal = fields.Boolean('Employee Only', help='Hide to public / portal users, independently from subtype configuration.')
+ # 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, ondelete='set null',
+ help="Author of the message. If not set, email_from may hold an email address that did not match any partner.")
+ author_avatar = fields.Binary("Author's avatar", related='author_id.image_128', depends=['author_id'], readonly=False)
+ # recipients: include inactive partners (they may have been archived after
+ # the message was sent, but they should remain visible in the relation)
+ partner_ids = fields.Many2many('res.partner', string='Recipients', context={'active_test': False})
+ # list of partner having a notification. Caution: list may change over time because of notif gc cron.
+ # mainly usefull for testing
+ notified_partner_ids = fields.Many2many(
+ 'res.partner', 'mail_message_res_partner_needaction_rel', string='Partners with Need Action',
+ context={'active_test': False}, depends=['notification_ids'])
+ needaction = fields.Boolean(
+ 'Need Action', compute='_get_needaction', search='_search_needaction',
+ help='Need Action')
+ has_error = fields.Boolean(
+ 'Has error', compute='_compute_has_error', search='_search_has_error',
+ help='Has error')
+ channel_ids = fields.Many2many(
+ 'mail.channel', 'mail_message_mail_channel_rel', string='Channels')
+ # notifications
+ notification_ids = fields.One2many(
+ 'mail.notification', 'mail_message_id', 'Notifications',
+ auto_join=True, copy=False, depends=['notified_partner_ids'])
+ # user interface
+ starred_partner_ids = fields.Many2many(
+ 'res.partner', 'mail_message_res_partner_starred_rel', string='Favorited By')
+ starred = fields.Boolean(
+ 'Starred', compute='_get_starred', search='_search_starred', compute_sudo=False,
+ help='Current user has a starred notification linked to this message')
+ # tracking
+ tracking_value_ids = fields.One2many(
+ 'mail.tracking.value', 'mail_message_id',
+ string='Tracking values',
+ groups="base.group_system",
+ help='Tracked values are stored in a separate model. This field allow to reconstruct '
+ 'the tracking and to generate statistics on the model.')
+ # mail gateway
+ 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.')
+ message_id = fields.Char('Message-Id', help='Message unique identifier', index=True, readonly=1, copy=False)
+ reply_to = fields.Char('Reply-To', help='Reply email address. Setting the reply_to bypasses the automatic thread creation.')
+ mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing mail server')
+ # moderation
+ moderation_status = fields.Selection([
+ ('pending_moderation', 'Pending Moderation'),
+ ('accepted', 'Accepted'),
+ ('rejected', 'Rejected')], string="Moderation Status", index=True)
+ moderator_id = fields.Many2one('res.users', string="Moderated By", index=True)
+ need_moderation = fields.Boolean('Need moderation', compute='_compute_need_moderation', search='_search_need_moderation')
+ # keep notification layout informations to be able to generate mail again
+ email_layout_xmlid = fields.Char('Layout', copy=False) # xml id of layout
+ add_sign = fields.Boolean(default=True)
+ # `test_adv_activity`, `test_adv_activity_full`, `test_message_assignation_inbox`,...
+ # By setting an inverse for mail.mail_message_id, the number of SQL queries done by `modified` is reduced.
+ # 'mail.mail' inherits from `mail.message`: `_inherits = {'mail.message': 'mail_message_id'}`
+ # Therefore, when changing a field on `mail.message`, this triggers the modification of the same field on `mail.mail`
+ # By setting up the inverse one2many, we avoid to have to do a search to find the mails linked to the `mail.message`
+ # as the cache value for this inverse one2many is up-to-date.
+ # Besides for new messages, and messages never sending emails, there was no mail, and it was searching for nothing.
+ mail_ids = fields.One2many('mail.mail', 'mail_message_id', string='Mails', groups="base.group_system")
+ canned_response_ids = fields.One2many('mail.shortcode', 'message_ids', string="Canned Responses", store=False)
+
+ def _compute_description(self):
+ for message in self:
+ if message.subject:
+ message.description = message.subject
+ else:
+ plaintext_ct = '' if not message.body else tools.html2plaintext(message.body)
+ message.description = plaintext_ct[:30] + '%s' % (' [...]' if len(plaintext_ct) >= 30 else '')
+
+ def _get_needaction(self):
+ """ Need action on a mail.message = notified on my channel """
+ my_messages = self.env['mail.notification'].sudo().search([
+ ('mail_message_id', 'in', self.ids),
+ ('res_partner_id', '=', self.env.user.partner_id.id),
+ ('is_read', '=', False)]).mapped('mail_message_id')
+ for message in self:
+ message.needaction = message in my_messages
+
+ @api.model
+ def _search_needaction(self, operator, operand):
+ is_read = False if operator == '=' and operand else True
+ notification_ids = self.env['mail.notification']._search([('res_partner_id', '=', self.env.user.partner_id.id), ('is_read', '=', is_read)])
+ return [('notification_ids', 'in', notification_ids)]
+
+ def _compute_has_error(self):
+ error_from_notification = self.env['mail.notification'].sudo().search([
+ ('mail_message_id', 'in', self.ids),
+ ('notification_status', 'in', ('bounce', 'exception'))]).mapped('mail_message_id')
+ for message in self:
+ message.has_error = message in error_from_notification
+
+ def _search_has_error(self, operator, operand):
+ if operator == '=' and operand:
+ return [('notification_ids.notification_status', 'in', ('bounce', 'exception'))]
+ return ['!', ('notification_ids.notification_status', 'in', ('bounce', 'exception'))] # this wont work and will be equivalent to "not in" beacause of orm restrictions. Dont use "has_error = False"
+
+ @api.depends('starred_partner_ids')
+ @api.depends_context('uid')
+ def _get_starred(self):
+ """ Compute if the message is starred by the current user. """
+ # TDE FIXME: use SQL
+ starred = self.sudo().filtered(lambda msg: self.env.user.partner_id in msg.starred_partner_ids)
+ for message in self:
+ message.starred = message in starred
+
+ @api.model
+ def _search_starred(self, operator, operand):
+ if operator == '=' and operand:
+ return [('starred_partner_ids', 'in', [self.env.user.partner_id.id])]
+ return [('starred_partner_ids', 'not in', [self.env.user.partner_id.id])]
+
+ def _compute_need_moderation(self):
+ for message in self:
+ message.need_moderation = False
+
+ @api.model
+ def _search_need_moderation(self, operator, operand):
+ if operator == '=' and operand is True:
+ return ['&', '&',
+ ('moderation_status', '=', 'pending_moderation'),
+ ('model', '=', 'mail.channel'),
+ ('res_id', 'in', self.env.user.moderation_channel_ids.ids)]
+
+ # no support for other operators
+ raise UserError(_('Unsupported search filter on moderation status'))
+
+ # ------------------------------------------------------
+ # CRUD / ORM
+ # ------------------------------------------------------
+
+ def init(self):
+ self._cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
+ if not self._cr.fetchone():
+ self._cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
+
+ @api.model
+ def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
+ """ Override that adds specific access rights of mail.message, to remove
+ ids uid could not see according to our custom rules. Please refer to
+ check_access_rule for more details about those rules.
+
+ Non employees users see only message with subtype (aka do not see
+ internal logs).
+
+ After having received ids of a classic search, keep only:
+ - if author_id == pid, uid is the author, OR
+ - uid belongs to a notified channel, OR
+ - uid is in the specified recipients, OR
+ - uid has a notification on the message
+ - otherwise: remove the id
+ """
+ # Rules do not apply to administrator
+ if self.env.is_superuser():
+ return super(Message, self)._search(
+ args, offset=offset, limit=limit, order=order,
+ count=count, access_rights_uid=access_rights_uid)
+ # Non-employee see only messages with a subtype and not internal
+ if not self.env['res.users'].has_group('base.group_user'):
+ args = expression.AND([self._get_search_domain_share(), args])
+ # Perform a super with count as False, to have the ids, not a counter
+ ids = super(Message, self)._search(
+ args, offset=offset, limit=limit, order=order,
+ count=False, access_rights_uid=access_rights_uid)
+ if not ids and count:
+ return 0
+ elif not ids:
+ return ids
+
+ pid = self.env.user.partner_id.id
+ author_ids, partner_ids, channel_ids, allowed_ids = set([]), set([]), set([]), set([])
+ model_ids = {}
+
+ # check read access rights before checking the actual rules on the given ids
+ super(Message, self.with_user(access_rights_uid or self._uid)).check_access_rights('read')
+
+ self.flush(['model', 'res_id', 'author_id', 'message_type', 'partner_ids', 'channel_ids'])
+ self.env['mail.notification'].flush(['mail_message_id', 'res_partner_id'])
+ self.env['mail.channel'].flush(['channel_message_ids'])
+ self.env['mail.channel.partner'].flush(['channel_id', 'partner_id'])
+ for sub_ids in self._cr.split_for_in_conditions(ids):
+ self._cr.execute("""
+ SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.message_type,
+ COALESCE(partner_rel.res_partner_id, needaction_rel.res_partner_id),
+ channel_partner.channel_id as channel_id
+ FROM "%s" m
+ LEFT JOIN "mail_message_res_partner_rel" partner_rel
+ ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %%(pid)s
+ LEFT JOIN "mail_message_res_partner_needaction_rel" needaction_rel
+ ON needaction_rel.mail_message_id = m.id AND needaction_rel.res_partner_id = %%(pid)s
+ LEFT JOIN "mail_message_mail_channel_rel" channel_rel
+ ON channel_rel.mail_message_id = m.id
+ LEFT JOIN "mail_channel" channel
+ ON channel.id = channel_rel.mail_channel_id
+ LEFT JOIN "mail_channel_partner" channel_partner
+ ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = %%(pid)s
+
+ WHERE m.id = ANY (%%(ids)s)""" % self._table, dict(pid=pid, ids=list(sub_ids)))
+ for id, rmod, rid, author_id, message_type, partner_id, channel_id in self._cr.fetchall():
+ if author_id == pid:
+ author_ids.add(id)
+ elif partner_id == pid:
+ partner_ids.add(id)
+ elif channel_id:
+ channel_ids.add(id)
+ elif rmod and rid and message_type != 'user_notification':
+ model_ids.setdefault(rmod, {}).setdefault(rid, set()).add(id)
+
+ allowed_ids = self._find_allowed_doc_ids(model_ids)
+
+ final_ids = author_ids | partner_ids | channel_ids | allowed_ids
+
+ if count:
+ return len(final_ids)
+ else:
+ # re-construct a list based on ids, because set did not keep the original order
+ id_list = [id for id in ids if id in final_ids]
+ return id_list
+
+ @api.model
+ def _find_allowed_model_wise(self, doc_model, doc_dict):
+ doc_ids = list(doc_dict)
+ allowed_doc_ids = self.env[doc_model].with_context(active_test=False).search([('id', 'in', doc_ids)]).ids
+ return set([message_id for allowed_doc_id in allowed_doc_ids for message_id in doc_dict[allowed_doc_id]])
+
+ @api.model
+ def _find_allowed_doc_ids(self, model_ids):
+ IrModelAccess = self.env['ir.model.access']
+ allowed_ids = set()
+ for doc_model, doc_dict in model_ids.items():
+ if not IrModelAccess.check(doc_model, 'read', False):
+ continue
+ allowed_ids |= self._find_allowed_model_wise(doc_model, doc_dict)
+ return allowed_ids
+
+ def check_access_rule(self, operation):
+ """ Access rules of mail.message:
+ - read: if
+ - author_id == pid, uid is the author OR
+ - uid is in the recipients (partner_ids) OR
+ - uid has been notified (needaction) OR
+ - uid is member of a listern channel (channel_ids.partner_ids) OR
+ - uid have read access to the related document if model, res_id
+ - otherwise: raise
+ - create: if
+ - no model, no res_id (private message) OR
+ - pid in message_follower_ids if model, res_id OR
+ - uid can read the parent OR
+ - uid have write or create access on the related document if model, res_id, OR
+ - otherwise: raise
+ - write: if
+ - author_id == pid, uid is the author, OR
+ - uid is in the recipients (partner_ids) OR
+ - uid is moderator of the channel and moderation_status is pending_moderation OR
+ - uid has write or create access on the related document if model, res_id and moderation_status is not pending_moderation
+ - otherwise: raise
+ - unlink: if
+ - uid is moderator of the channel and moderation_status is pending_moderation OR
+ - uid has write or create access on the related document if model, res_id and moderation_status is not pending_moderation
+ - otherwise: raise
+
+ Specific case: non employee users see only messages with subtype (aka do
+ not see internal logs).
+ """
+ def _generate_model_record_ids(msg_val, msg_ids):
+ """ :param model_record_ids: {'model': {'res_id': (msg_id, msg_id)}, ... }
+ :param message_values: {'msg_id': {'model': .., 'res_id': .., 'author_id': ..}}
+ """
+ model_record_ids = {}
+ for id in msg_ids:
+ vals = msg_val.get(id, {})
+ if vals.get('model') and vals.get('res_id'):
+ model_record_ids.setdefault(vals['model'], set()).add(vals['res_id'])
+ return model_record_ids
+
+ if self.env.is_superuser():
+ return
+ # Non employees see only messages with a subtype (aka, not internal logs)
+ if not self.env['res.users'].has_group('base.group_user'):
+ self._cr.execute('''SELECT DISTINCT message.id, message.subtype_id, subtype.internal
+ FROM "%s" AS message
+ LEFT JOIN "mail_message_subtype" as subtype
+ ON message.subtype_id = subtype.id
+ WHERE message.message_type = %%s AND
+ (message.is_internal IS TRUE OR message.subtype_id IS NULL OR subtype.internal IS TRUE) AND
+ message.id = ANY (%%s)''' % (self._table), ('comment', self.ids,))
+ if self._cr.fetchall():
+ raise AccessError(
+ _('The requested operation cannot be completed due to security restrictions. Please contact your system administrator.\n\n(Document type: %s, Operation: %s)', self._description, operation)
+ + ' - ({} {}, {} {})'.format(_('Records:'), self.ids[:6], _('User:'), self._uid)
+ )
+
+ # Read mail_message.ids to have their values
+ message_values = dict((message_id, {}) for message_id in self.ids)
+
+ self.flush(['model', 'res_id', 'author_id', 'parent_id', 'moderation_status', 'message_type', 'partner_ids', 'channel_ids'])
+ self.env['mail.notification'].flush(['mail_message_id', 'res_partner_id'])
+ self.env['mail.channel'].flush(['channel_message_ids', 'moderator_ids'])
+ self.env['mail.channel.partner'].flush(['channel_id', 'partner_id'])
+ self.env['res.users'].flush(['moderation_channel_ids'])
+
+ if operation == 'read':
+ self._cr.execute("""
+ SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.parent_id,
+ COALESCE(partner_rel.res_partner_id, needaction_rel.res_partner_id),
+ channel_partner.channel_id as channel_id, m.moderation_status,
+ m.message_type as message_type
+ FROM "%s" m
+ LEFT JOIN "mail_message_res_partner_rel" partner_rel
+ ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %%(pid)s
+ LEFT JOIN "mail_message_res_partner_needaction_rel" needaction_rel
+ ON needaction_rel.mail_message_id = m.id AND needaction_rel.res_partner_id = %%(pid)s
+ LEFT JOIN "mail_message_mail_channel_rel" channel_rel
+ ON channel_rel.mail_message_id = m.id
+ LEFT JOIN "mail_channel" channel
+ ON channel.id = channel_rel.mail_channel_id
+ LEFT JOIN "mail_channel_partner" channel_partner
+ ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = %%(pid)s
+ WHERE m.id = ANY (%%(ids)s)""" % self._table, dict(pid=self.env.user.partner_id.id, ids=self.ids))
+ for mid, rmod, rid, author_id, parent_id, partner_id, channel_id, moderation_status, message_type in self._cr.fetchall():
+ message_values[mid] = {
+ 'model': rmod,
+ 'res_id': rid,
+ 'author_id': author_id,
+ 'parent_id': parent_id,
+ 'moderation_status': moderation_status,
+ 'moderator_id': False,
+ 'notified': any((message_values[mid].get('notified'), partner_id, channel_id)),
+ 'message_type': message_type,
+ }
+ elif operation == 'write':
+ self._cr.execute("""
+ SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.parent_id, m.moderation_status,
+ COALESCE(partner_rel.res_partner_id, needaction_rel.res_partner_id),
+ channel_partner.channel_id as channel_id, channel_moderator_rel.res_users_id as moderator_id,
+ m.message_type as message_type
+ FROM "%s" m
+ LEFT JOIN "mail_message_res_partner_rel" partner_rel
+ ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %%(pid)s
+ LEFT JOIN "mail_message_res_partner_needaction_rel" needaction_rel
+ ON needaction_rel.mail_message_id = m.id AND needaction_rel.res_partner_id = %%(pid)s
+ LEFT JOIN "mail_message_mail_channel_rel" channel_rel
+ ON channel_rel.mail_message_id = m.id
+ LEFT JOIN "mail_channel" channel
+ ON channel.id = channel_rel.mail_channel_id
+ LEFT JOIN "mail_channel_partner" channel_partner
+ ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = %%(pid)s
+ LEFT JOIN "mail_channel" moderated_channel
+ ON m.moderation_status = 'pending_moderation' AND m.res_id = moderated_channel.id
+ LEFT JOIN "mail_channel_moderator_rel" channel_moderator_rel
+ ON channel_moderator_rel.mail_channel_id = moderated_channel.id AND channel_moderator_rel.res_users_id = %%(uid)s
+ WHERE m.id = ANY (%%(ids)s)""" % self._table, dict(pid=self.env.user.partner_id.id, uid=self.env.user.id, ids=self.ids))
+ for mid, rmod, rid, author_id, parent_id, moderation_status, partner_id, channel_id, moderator_id, message_type in self._cr.fetchall():
+ message_values[mid] = {
+ 'model': rmod,
+ 'res_id': rid,
+ 'author_id': author_id,
+ 'parent_id': parent_id,
+ 'moderation_status': moderation_status,
+ 'moderator_id': moderator_id,
+ 'notified': any((message_values[mid].get('notified'), partner_id, channel_id)),
+ 'message_type': message_type,
+ }
+ elif operation == 'create':
+ self._cr.execute("""SELECT DISTINCT id, model, res_id, author_id, parent_id, moderation_status, message_type FROM "%s" WHERE id = ANY (%%s)""" % self._table, (self.ids,))
+ for mid, rmod, rid, author_id, parent_id, moderation_status, message_type in self._cr.fetchall():
+ message_values[mid] = {
+ 'model': rmod,
+ 'res_id': rid,
+ 'author_id': author_id,
+ 'parent_id': parent_id,
+ 'moderation_status': moderation_status,
+ 'moderator_id': False,
+ 'message_type': message_type,
+ }
+ else: # unlink
+ self._cr.execute("""SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.parent_id, m.moderation_status, channel_moderator_rel.res_users_id as moderator_id, m.message_type as message_type
+ FROM "%s" m
+ LEFT JOIN "mail_channel" moderated_channel
+ ON m.moderation_status = 'pending_moderation' AND m.res_id = moderated_channel.id
+ LEFT JOIN "mail_channel_moderator_rel" channel_moderator_rel
+ ON channel_moderator_rel.mail_channel_id = moderated_channel.id AND channel_moderator_rel.res_users_id = (%%s)
+ WHERE m.id = ANY (%%s)""" % self._table, (self.env.user.id, self.ids,))
+ for mid, rmod, rid, author_id, parent_id, moderation_status, moderator_id, message_type in self._cr.fetchall():
+ message_values[mid] = {
+ 'model': rmod,
+ 'res_id': rid,
+ 'author_id': author_id,
+ 'parent_id': parent_id,
+ 'moderation_status': moderation_status,
+ 'moderator_id': moderator_id,
+ 'message_type': message_type,
+ }
+
+ # Author condition (READ, WRITE, CREATE (private))
+ author_ids = []
+ if operation == 'read':
+ author_ids = [mid for mid, message in message_values.items()
+ if message.get('author_id') and message.get('author_id') == self.env.user.partner_id.id]
+ elif operation == 'write':
+ author_ids = [mid for mid, message in message_values.items()
+ if message.get('moderation_status') != 'pending_moderation' and message.get('author_id') == self.env.user.partner_id.id]
+ elif operation == 'create':
+ author_ids = [mid for mid, message in message_values.items()
+ if not self.is_thread_message(message)]
+
+ # Moderator condition: allow to WRITE, UNLINK if moderator of a pending message
+ moderator_ids = []
+ if operation in ['write', 'unlink']:
+ moderator_ids = [mid for mid, message in message_values.items() if message.get('moderator_id')]
+ messages_to_check = self.ids
+ messages_to_check = set(messages_to_check).difference(set(author_ids), set(moderator_ids))
+ if not messages_to_check:
+ return
+
+ # Recipients condition, for read and write (partner_ids)
+ # keep on top, usefull for systray notifications
+ notified_ids = []
+ model_record_ids = _generate_model_record_ids(message_values, messages_to_check)
+ if operation in ['read', 'write']:
+ notified_ids = [mid for mid, message in message_values.items() if message.get('notified')]
+
+ messages_to_check = set(messages_to_check).difference(set(notified_ids))
+ if not messages_to_check:
+ return
+
+ # CRUD: Access rights related to the document
+ document_related_ids = []
+ document_related_candidate_ids = [mid for mid, message in message_values.items()
+ if (message.get('model') and message.get('res_id') and
+ message.get('message_type') != 'user_notification' and
+ (message.get('moderation_status') != 'pending_moderation' or operation not in ['write', 'unlink']))]
+ model_record_ids = _generate_model_record_ids(message_values, document_related_candidate_ids)
+ for model, doc_ids in model_record_ids.items():
+ DocumentModel = self.env[model]
+ if hasattr(DocumentModel, '_get_mail_message_access'):
+ check_operation = DocumentModel._get_mail_message_access(doc_ids, operation) ## why not giving model here?
+ else:
+ check_operation = self.env['mail.thread']._get_mail_message_access(doc_ids, operation, model_name=model)
+ records = DocumentModel.browse(doc_ids)
+ records.check_access_rights(check_operation)
+ mids = records.browse(doc_ids)._filter_access_rules(check_operation)
+ document_related_ids += [
+ mid for mid, message in message_values.items()
+ if (message.get('model') == model and
+ message.get('res_id') in mids.ids and
+ message.get('message_type') != 'user_notification' and
+ (message.get('moderation_status') != 'pending_moderation' or
+ operation not in ['write', 'unlink']))]
+
+ messages_to_check = messages_to_check.difference(set(document_related_ids))
+
+ if not messages_to_check:
+ return
+
+ # Parent condition, for create (check for received notifications for the created message parent)
+ notified_ids = []
+ if operation == 'create':
+ # TDE: probably clean me
+ parent_ids = [message.get('parent_id') for message in message_values.values()
+ if message.get('parent_id')]
+ self._cr.execute("""SELECT DISTINCT m.id, partner_rel.res_partner_id, channel_partner.partner_id FROM "%s" m
+ LEFT JOIN "mail_message_res_partner_rel" partner_rel
+ ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = (%%s)
+ LEFT JOIN "mail_message_mail_channel_rel" channel_rel
+ ON channel_rel.mail_message_id = m.id
+ LEFT JOIN "mail_channel" channel
+ ON channel.id = channel_rel.mail_channel_id
+ LEFT JOIN "mail_channel_partner" channel_partner
+ ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = (%%s)
+ WHERE m.id = ANY (%%s)""" % self._table, (self.env.user.partner_id.id, self.env.user.partner_id.id, parent_ids,))
+ not_parent_ids = [mid[0] for mid in self._cr.fetchall() if any([mid[1], mid[2]])]
+ notified_ids += [mid for mid, message in message_values.items()
+ if message.get('parent_id') in not_parent_ids]
+
+ messages_to_check = messages_to_check.difference(set(notified_ids))
+ if not messages_to_check:
+ return
+
+ # Recipients condition for create (message_follower_ids)
+ if operation == 'create':
+ for doc_model, doc_ids in model_record_ids.items():
+ followers = self.env['mail.followers'].sudo().search([
+ ('res_model', '=', doc_model),
+ ('res_id', 'in', list(doc_ids)),
+ ('partner_id', '=', self.env.user.partner_id.id),
+ ])
+ fol_mids = [follower.res_id for follower in followers]
+ notified_ids += [mid for mid, message in message_values.items()
+ if message.get('model') == doc_model and
+ message.get('res_id') in fol_mids and
+ message.get('message_type') != 'user_notification'
+ ]
+
+ messages_to_check = messages_to_check.difference(set(notified_ids))
+ if not messages_to_check:
+ return
+
+ if not self.browse(messages_to_check).exists():
+ return
+ raise AccessError(
+ _('The requested operation cannot be completed due to security restrictions. Please contact your system administrator.\n\n(Document type: %s, Operation: %s)', self._description, operation)
+ + ' - ({} {}, {} {})'.format(_('Records:'), list(messages_to_check)[:6], _('User:'), self._uid)
+ )
+
+ @api.model_create_multi
+ def create(self, values_list):
+ tracking_values_list = []
+ for values in values_list:
+ if 'email_from' not in values: # needed to compute reply_to
+ author_id, email_from = self.env['mail.thread']._message_compute_author(values.get('author_id'), email_from=None, raise_exception=False)
+ values['email_from'] = email_from
+ if not values.get('message_id'):
+ values['message_id'] = self._get_message_id(values)
+ if 'reply_to' not in values:
+ values['reply_to'] = self._get_reply_to(values)
+ if 'record_name' not in values and 'default_record_name' not in self.env.context:
+ values['record_name'] = self._get_record_name(values)
+
+ if 'attachment_ids' not in values:
+ values['attachment_ids'] = []
+ # extract base64 images
+ if 'body' in values:
+ Attachments = self.env['ir.attachment'].with_context(clean_context(self._context))
+ data_to_url = {}
+ def base64_to_boundary(match):
+ key = match.group(2)
+ if not data_to_url.get(key):
+ name = match.group(4) if match.group(4) else 'image%s' % len(data_to_url)
+ try:
+ attachment = Attachments.create({
+ 'name': name,
+ 'datas': match.group(2),
+ 'res_model': values.get('model'),
+ 'res_id': values.get('res_id'),
+ })
+ except binascii_error:
+ _logger.warning("Impossible to create an attachment out of badly formated base64 embedded image. Image has been removed.")
+ return match.group(3) # group(3) is the url ending single/double quote matched by the regexp
+ else:
+ attachment.generate_access_token()
+ values['attachment_ids'].append((4, attachment.id))
+ data_to_url[key] = ['/web/image/%s?access_token=%s' % (attachment.id, attachment.access_token), name]
+ return '%s%s alt="%s"' % (data_to_url[key][0], match.group(3), data_to_url[key][1])
+ values['body'] = _image_dataurl.sub(base64_to_boundary, tools.ustr(values['body']))
+
+ # delegate creation of tracking after the create as sudo to avoid access rights issues
+ tracking_values_list.append(values.pop('tracking_value_ids', False))
+
+ messages = super(Message, self).create(values_list)
+
+ check_attachment_access = []
+ if all(isinstance(command, int) or command[0] in (4, 6) for values in values_list for command in values.get('attachment_ids')):
+ for values in values_list:
+ for command in values.get('attachment_ids'):
+ if isinstance(command, int):
+ check_attachment_access += [command]
+ elif command[0] == 6:
+ check_attachment_access += command[2]
+ else: # command[0] == 4:
+ check_attachment_access += [command[1]]
+ else:
+ check_attachment_access = messages.mapped('attachment_ids').ids # fallback on read if any unknow command
+ if check_attachment_access:
+ self.env['ir.attachment'].browse(check_attachment_access).check(mode='read')
+
+ for message, values, tracking_values_cmd in zip(messages, values_list, tracking_values_list):
+ if tracking_values_cmd:
+ vals_lst = [dict(cmd[2], mail_message_id=message.id) for cmd in tracking_values_cmd if len(cmd) == 3 and cmd[0] == 0]
+ other_cmd = [cmd for cmd in tracking_values_cmd if len(cmd) != 3 or cmd[0] != 0]
+ if vals_lst:
+ self.env['mail.tracking.value'].sudo().create(vals_lst)
+ if other_cmd:
+ message.sudo().write({'tracking_value_ids': tracking_values_cmd})
+
+ if message.is_thread_message(values):
+ message._invalidate_documents(values.get('model'), values.get('res_id'))
+
+ return messages
+
+ def read(self, fields=None, load='_classic_read'):
+ """ Override to explicitely call check_access_rule, that is not called
+ by the ORM. It instead directly fetches ir.rules and apply them. """
+ self.check_access_rule('read')
+ return super(Message, self).read(fields=fields, load=load)
+
+ def write(self, vals):
+ record_changed = 'model' in vals or 'res_id' in vals
+ if record_changed or 'message_type' in vals:
+ self._invalidate_documents()
+ res = super(Message, self).write(vals)
+ if vals.get('attachment_ids'):
+ for mail in self:
+ mail.attachment_ids.check(mode='read')
+ if 'notification_ids' in vals or record_changed:
+ self._invalidate_documents()
+ return res
+
+ def unlink(self):
+ # cascade-delete attachments that are directly attached to the message (should only happen
+ # for mail.messages that act as parent for a standalone mail.mail record).
+ if not self:
+ return True
+ self.check_access_rule('unlink')
+ self.mapped('attachment_ids').filtered(
+ lambda attach: attach.res_model == self._name and (attach.res_id in self.ids or attach.res_id == 0)
+ ).unlink()
+ for elem in self:
+ if elem.is_thread_message():
+ elem._invalidate_documents()
+ return super(Message, self).unlink()
+
+ @api.model
+ def _read_group_raw(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
+ if not self.env.is_admin():
+ raise AccessError(_("Only administrators are allowed to use grouped read on message model"))
+
+ return super(Message, self)._read_group_raw(
+ domain=domain, fields=fields, groupby=groupby, offset=offset,
+ limit=limit, orderby=orderby, lazy=lazy,
+ )
+
+ def export_data(self, fields_to_export):
+ if not self.env.is_admin():
+ raise AccessError(_("Only administrators are allowed to export mail message"))
+
+ return super(Message, self).export_data(fields_to_export)
+
+ # ------------------------------------------------------
+ # DISCUSS API
+ # ------------------------------------------------------
+
+ @api.model
+ def mark_all_as_read(self, domain=None):
+ # not really efficient method: it does one db request for the
+ # search, and one for each message in the result set is_read to True in the
+ # current notifications from the relation.
+ partner_id = self.env.user.partner_id.id
+ notif_domain = [
+ ('res_partner_id', '=', partner_id),
+ ('is_read', '=', False)]
+ if domain:
+ messages = self.search(domain)
+ messages.set_message_done()
+ return messages.ids
+
+ notifications = self.env['mail.notification'].sudo().search(notif_domain)
+ notifications.write({'is_read': True})
+
+ ids = [n['mail_message_id'] for n in notifications.read(['mail_message_id'])]
+
+ notification = {'type': 'mark_as_read', 'message_ids': [id[0] for id in ids], 'needaction_inbox_counter': self.env.user.partner_id.get_needaction_count()}
+ self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', partner_id), notification)
+
+ return ids
+
+ def set_message_done(self):
+ """ Remove the needaction from messages for the current partner. """
+ partner_id = self.env.user.partner_id
+
+ notifications = self.env['mail.notification'].sudo().search([
+ ('mail_message_id', 'in', self.ids),
+ ('res_partner_id', '=', partner_id.id),
+ ('is_read', '=', False)])
+
+ if not notifications:
+ return
+
+ # notifies changes in messages through the bus. To minimize the number of
+ # notifications, we need to group the messages depending on their channel_ids
+ groups = []
+ messages = notifications.mapped('mail_message_id')
+ current_channel_ids = messages[0].channel_ids
+ current_group = []
+ for record in messages:
+ if record.channel_ids == current_channel_ids:
+ current_group.append(record.id)
+ else:
+ groups.append((current_group, current_channel_ids))
+ current_group = [record.id]
+ current_channel_ids = record.channel_ids
+
+ groups.append((current_group, current_channel_ids))
+ current_group = [record.id]
+ current_channel_ids = record.channel_ids
+
+ notifications.write({'is_read': True})
+
+ for (msg_ids, channel_ids) in groups:
+ # channel_ids in result is deprecated and will be removed in a future version
+ notification = {'type': 'mark_as_read', 'message_ids': msg_ids, 'channel_ids': [c.id for c in channel_ids], 'needaction_inbox_counter': self.env.user.partner_id.get_needaction_count()}
+ self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', partner_id.id), notification)
+
+ @api.model
+ def unstar_all(self):
+ """ Unstar messages for the current partner. """
+ partner_id = self.env.user.partner_id.id
+
+ starred_messages = self.search([('starred_partner_ids', 'in', partner_id)])
+ starred_messages.write({'starred_partner_ids': [(3, partner_id)]})
+
+ ids = [m.id for m in starred_messages]
+ notification = {'type': 'toggle_star', 'message_ids': ids, 'starred': False}
+ self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), notification)
+
+ def toggle_message_starred(self):
+ """ Toggle messages as (un)starred. Technically, the notifications related
+ to uid are set to (un)starred.
+ """
+ # a user should always be able to star a message he can read
+ self.check_access_rule('read')
+ starred = not self.starred
+ if starred:
+ self.sudo().write({'starred_partner_ids': [(4, self.env.user.partner_id.id)]})
+ else:
+ self.sudo().write({'starred_partner_ids': [(3, self.env.user.partner_id.id)]})
+
+ notification = {'type': 'toggle_star', 'message_ids': [self.id], 'starred': starred}
+ self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), notification)
+
+ # --------------------------------------------------
+ # MODERATION API
+ # --------------------------------------------------
+
+ def moderate(self, decision, **kwargs):
+ """ Moderate messages. A check is done on moderation status of the
+ current user to ensure we only moderate valid messages. """
+ moderated_channels = self.env.user.moderation_channel_ids
+ to_moderate = [message.id for message in self
+ if message.model == 'mail.channel' and
+ message.res_id in moderated_channels.ids and
+ message.moderation_status == 'pending_moderation']
+ if to_moderate:
+ self.browse(to_moderate)._moderate(decision, **kwargs)
+
+ def _moderate(self, decision, **kwargs):
+ """ :param decision
+ * accept - moderate message and broadcast that message to followers of relevant channels.
+ * reject - message will be deleted from the database without broadcast
+ an email sent to the author with an explanation that the moderators can edit.
+ * discard - message will be deleted from the database without broadcast.
+ * allow - add email address to white list people of specific channel,
+ so that next time if a message come from same email address on same channel,
+ it will be automatically broadcasted to relevant channels without any approval from moderator.
+ * ban - add email address to black list of emails for the specific channel.
+ From next time, a person sending a message using that email address will not need moderation.
+ message_post will not create messages with the corresponding expeditor.
+ """
+ if decision == 'accept':
+ self._moderate_accept()
+ elif decision == 'reject':
+ self._moderate_send_reject_email(kwargs.get('title'), kwargs.get('comment'))
+ self._moderate_discard()
+ elif decision == 'discard':
+ self._moderate_discard()
+ elif decision == 'allow':
+ channels = self.env['mail.channel'].browse(self.mapped('res_id'))
+ for channel in channels:
+ channel._update_moderation_email(
+ list({message.email_from for message in self if message.res_id == channel.id}),
+ 'allow'
+ )
+ self._search_from_same_authors()._moderate_accept()
+ elif decision == 'ban':
+ channels = self.env['mail.channel'].browse(self.mapped('res_id'))
+ for channel in channels:
+ channel._update_moderation_email(
+ list({message.email_from for message in self if message.res_id == channel.id}),
+ 'ban'
+ )
+ self._search_from_same_authors()._moderate_discard()
+
+ def _moderate_accept(self):
+ self.write({
+ 'moderation_status': 'accepted',
+ 'moderator_id': self.env.uid
+ })
+ # proceed with notification process to send notification emails and Inbox messages
+ for message in self:
+ if message.is_thread_message(): # note, since we will only intercept _notify_thread for message posted on channel,
+ # message will always be a thread_message. This check should always be true.
+ self.env[message.model].browse(message.res_id)._notify_thread(message)
+
+ def _moderate_send_reject_email(self, subject, comment):
+ for msg in self:
+ if not msg.email_from:
+ continue
+ body_html = tools.append_content_to_html('<div>%s</div>' % tools.ustr(comment), msg.body, plaintext=False)
+ vals = {
+ 'subject': subject,
+ 'body_html': body_html,
+ 'author_id': self.env.user.partner_id.id,
+ 'email_from': self.env.user.email_formatted or self.env.company.catchall_formatted,
+ 'email_to': msg.email_from,
+ 'auto_delete': True,
+ 'state': 'outgoing'
+ }
+ self.env['mail.mail'].sudo().create(vals)
+
+ def _search_from_same_authors(self):
+ """ Returns all pending moderation messages that have same email_from and
+ same res_id as given recordset. """
+ messages = self.env['mail.message'].sudo()
+ for message in self:
+ messages |= messages.search([
+ ('moderation_status', '=', 'pending_moderation'),
+ ('email_from', '=', message.email_from),
+ ('model', '=', 'mail.channel'),
+ ('res_id', '=', message.res_id)
+ ])
+ return messages
+
+ def _moderate_discard(self):
+ """ Notify deletion of messages to their moderators and authors and then delete them.
+ """
+ channel_ids = self.mapped('res_id')
+ moderators = self.env['mail.channel'].browse(channel_ids).mapped('moderator_ids')
+ authors = self.mapped('author_id')
+ partner_to_pid = {}
+ for moderator in moderators:
+ partner_to_pid.setdefault(moderator.partner_id.id, set())
+ partner_to_pid[moderator.partner_id.id] |= set([message.id for message in self if message.res_id in moderator.moderation_channel_ids.ids])
+ for author in authors:
+ partner_to_pid.setdefault(author.id, set())
+ partner_to_pid[author.id] |= set([message.id for message in self if message.author_id == author])
+
+ notifications = []
+ for partner_id, message_ids in partner_to_pid.items():
+ notifications.append([
+ (self._cr.dbname, 'res.partner', partner_id),
+ {'type': 'deletion', 'message_ids': sorted(list(message_ids))} # sorted to make deterministic for tests
+ ])
+ self.env['bus.bus'].sendmany(notifications)
+ self.unlink()
+
+ def _notify_pending_by_chat(self):
+ """ Generate the bus notifications for the given message and send them
+ to the appropriate moderators and the author (if the author has not been
+ elected moderator meanwhile). The author notification can be considered
+ as a feedback to the author.
+ """
+ self.ensure_one()
+ message = self.message_format()[0]
+ partners = self.env['mail.channel'].browse(self.res_id).mapped('moderator_ids.partner_id')
+ notifications = []
+ for partner in partners:
+ notifications.append([
+ (self._cr.dbname, 'res.partner', partner.id),
+ {'type': 'moderator', 'message': message}
+ ])
+ if self.author_id not in partners:
+ notifications.append([
+ (self._cr.dbname, 'res.partner', self.author_id.id),
+ {'type': 'author', 'message': message}
+ ])
+ self.env['bus.bus'].sendmany(notifications)
+
+ @api.model
+ def _notify_moderators(self):
+ """ Push a notification (Inbox/email) to moderators having messages
+ waiting for moderation. This method is called once a day by a cron.
+ """
+ channels = self.env['mail.channel'].browse(self.search([('moderation_status', '=', 'pending_moderation')]).mapped('res_id'))
+ moderators_to_notify = channels.mapped('moderator_ids')
+ template = self.env.ref('mail.mail_channel_notify_moderation', raise_if_not_found=False)
+ if not template:
+ _logger.warning('Template "mail.mail_channel_notify_moderation" was not found. Cannot send reminder notifications.')
+ return
+ MailThread = self.env['mail.thread'].with_context(mail_notify_author=True)
+ for moderator in moderators_to_notify:
+ MailThread.message_notify(
+ partner_ids=moderator.partner_id.ids,
+ subject=_('Message are pending moderation'), # tocheck: target language
+ body=template._render({'record': moderator.partner_id}, engine='ir.qweb', minimal_qcontext=True),
+ email_from=moderator.company_id.catchall_formatted or moderator.company_id.email_formatted,
+ )
+
+ # ------------------------------------------------------
+ # MESSAGE READ / FETCH / FAILURE API
+ # ------------------------------------------------------
+
+ def _message_format(self, fnames):
+ """Reads values from messages and formats them for the web client."""
+ self.check_access_rule('read')
+ vals_list = self._read_format(fnames)
+ safari = request and request.httprequest.user_agent.browser == 'safari'
+
+ thread_ids_by_model_name = defaultdict(set)
+ for message in self:
+ if message.model and message.res_id:
+ thread_ids_by_model_name[message.model].add(message.res_id)
+
+ for vals in vals_list:
+ message_sudo = self.browse(vals['id']).sudo().with_prefetch(self.ids)
+
+ # Author
+ if message_sudo.author_id:
+ author = (message_sudo.author_id.id, message_sudo.author_id.display_name)
+ else:
+ author = (0, message_sudo.email_from)
+
+ # Attachments
+ main_attachment = self.env['ir.attachment']
+ if message_sudo.attachment_ids and message_sudo.res_id and issubclass(self.pool[message_sudo.model], self.pool['mail.thread']):
+ main_attachment = self.env[message_sudo.model].sudo().browse(message_sudo.res_id).message_main_attachment_id
+ attachment_ids = []
+ for attachment in message_sudo.attachment_ids:
+ attachment_ids.append({
+ 'checksum': attachment.checksum,
+ 'id': attachment.id,
+ 'filename': attachment.name,
+ 'name': attachment.name,
+ 'mimetype': 'application/octet-stream' if safari and attachment.mimetype and 'video' in attachment.mimetype else attachment.mimetype,
+ 'is_main': main_attachment == attachment,
+ 'res_id': attachment.res_id,
+ 'res_model': attachment.res_model,
+ })
+
+ # Tracking values
+ tracking_value_ids = []
+ for tracking in message_sudo.tracking_value_ids:
+ groups = tracking.field_groups
+ if not groups or self.env.is_superuser() or self.user_has_groups(groups):
+ tracking_value_ids.append({
+ 'id': tracking.id,
+ 'changed_field': tracking.field_desc,
+ 'old_value': tracking.get_old_display_value()[0],
+ 'new_value': tracking.get_new_display_value()[0],
+ 'field_type': tracking.field_type,
+ })
+
+ if message_sudo.model and message_sudo.res_id:
+ record_name = self.env[message_sudo.model] \
+ .browse(message_sudo.res_id) \
+ .sudo() \
+ .with_prefetch(thread_ids_by_model_name[message_sudo.model]) \
+ .display_name
+ else:
+ record_name = False
+
+ vals.update({
+ 'author_id': author,
+ 'notifications': message_sudo.notification_ids._filtered_for_web_client()._notification_format(),
+ 'attachment_ids': attachment_ids,
+ 'tracking_value_ids': tracking_value_ids,
+ 'record_name': record_name,
+ })
+
+ return vals_list
+
+ def message_fetch_failed(self):
+ """Returns all messages, sent by the current user, that have errors, in
+ the format expected by the web client."""
+ messages = self.search([
+ ('has_error', '=', True),
+ ('author_id', '=', self.env.user.partner_id.id),
+ ('res_id', '!=', 0),
+ ('model', '!=', False),
+ ('message_type', '!=', 'user_notification')
+ ])
+ return messages._message_notification_format()
+
+ @api.model
+ def message_fetch(self, domain, limit=20, moderated_channel_ids=None):
+ """ Get a limited amount of formatted messages with provided domain.
+ :param domain: the domain to filter messages;
+ :param limit: the maximum amount of messages to get;
+ :param list(int) moderated_channel_ids: if set, it contains the ID
+ of a moderated channel. Fetched messages should include pending
+ moderation messages for moderators. If the current user is not
+ moderator, it should still get self-authored messages that are
+ pending moderation;
+ :returns list(dict).
+ """
+ messages = self.search(domain, limit=limit)
+ if moderated_channel_ids:
+ # Split load moderated and regular messages, as the ORed domain can
+ # cause performance issues on large databases.
+ moderated_messages_dom = [
+ ('model', '=', 'mail.channel'),
+ ('res_id', 'in', moderated_channel_ids),
+ '|',
+ ('author_id', '=', self.env.user.partner_id.id),
+ ('moderation_status', '=', 'pending_moderation'),
+ ]
+ messages |= self.search(moderated_messages_dom, limit=limit)
+ # Truncate the results to `limit`
+ messages = messages.sorted(key='id', reverse=True)[:limit]
+ return messages.message_format()
+
+ def message_format(self):
+ """ Get the message values in the format for web client. Since message values can be broadcasted,
+ computed fields MUST NOT BE READ and broadcasted.
+ :returns list(dict).
+ Example :
+ {
+ 'body': HTML content of the message
+ 'model': u'res.partner',
+ 'record_name': u'Agrolait',
+ 'attachment_ids': [
+ {
+ 'file_type_icon': u'webimage',
+ 'id': 45,
+ 'name': u'sample.png',
+ 'filename': u'sample.png'
+ }
+ ],
+ 'needaction_partner_ids': [], # list of partner ids
+ 'res_id': 7,
+ 'tracking_value_ids': [
+ {
+ 'old_value': "",
+ 'changed_field': "Customer",
+ 'id': 2965,
+ 'new_value': "Axelor"
+ }
+ ],
+ 'author_id': (3, u'Administrator'),
+ 'email_from': 'sacha@pokemon.com' # email address or False
+ 'subtype_id': (1, u'Discussions'),
+ 'channel_ids': [], # list of channel ids
+ 'date': '2015-06-30 08:22:33',
+ 'partner_ids': [[7, "Sacha Du Bourg-Palette"]], # list of partner name_get
+ 'message_type': u'comment',
+ 'id': 59,
+ 'subject': False
+ 'is_note': True # only if the message is a note (subtype == note)
+ 'is_discussion': False # only if the message is a discussion (subtype == discussion)
+ 'is_notification': False # only if the message is a note but is a notification aka not linked to a document like assignation
+ 'moderation_status': 'pending_moderation'
+ }
+ """
+ vals_list = self._message_format(self._get_message_format_fields())
+
+ com_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment')
+ note_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note')
+
+ for vals in vals_list:
+ message_sudo = self.browse(vals['id']).sudo().with_prefetch(self.ids)
+ notifs = message_sudo.notification_ids.filtered(lambda n: n.res_partner_id)
+ vals.update({
+ 'needaction_partner_ids': notifs.filtered(lambda n: not n.is_read).res_partner_id.ids,
+ 'history_partner_ids': notifs.filtered(lambda n: n.is_read).res_partner_id.ids,
+ 'is_note': message_sudo.subtype_id.id == note_id,
+ 'is_discussion': message_sudo.subtype_id.id == com_id,
+ 'subtype_description': message_sudo.subtype_id.description,
+ 'is_notification': vals['message_type'] == 'user_notification',
+ })
+ if vals['model'] and self.env[vals['model']]._original_module:
+ vals['module_icon'] = modules.module.get_module_icon(self.env[vals['model']]._original_module)
+ return vals_list
+
+ def _get_message_format_fields(self):
+ return [
+ 'id', 'body', 'date', 'author_id', 'email_from', # base message fields
+ 'message_type', 'subtype_id', 'subject', # message specific
+ 'model', 'res_id', 'record_name', # document related
+ 'channel_ids', 'partner_ids', # recipients
+ 'starred_partner_ids', # list of partner ids for whom the message is starred
+ 'moderation_status',
+ ]
+
+ def _message_notification_format(self):
+ """Returns the current messages and their corresponding notifications in
+ the format expected by the web client.
+
+ Notifications hold the information about each recipient of a message: if
+ the message was successfully sent or if an exception or bounce occurred.
+ """
+ return [{
+ 'id': message.id,
+ 'res_id': message.res_id,
+ 'model': message.model,
+ 'res_model_name': message.env['ir.model']._get(message.model).display_name,
+ 'date': message.date,
+ 'message_type': message.message_type,
+ 'notifications': message.notification_ids._filtered_for_web_client()._notification_format(),
+ } for message in self]
+
+ def _notify_message_notification_update(self):
+ """Send bus notifications to update status of notifications in the web
+ client. Purpose is to send the updated status per author."""
+ messages = self.env['mail.message']
+ for message in self:
+ # Check if user has access to the record before displaying a notification about it.
+ # In case the user switches from one company to another, it might happen that he doesn't
+ # have access to the record related to the notification. In this case, we skip it.
+ # YTI FIXME: check allowed_company_ids if necessary
+ if message.model and message.res_id:
+ record = self.env[message.model].browse(message.res_id)
+ try:
+ record.check_access_rights('read')
+ record.check_access_rule('read')
+ except AccessError:
+ continue
+ else:
+ messages |= message
+ updates = [[
+ (self._cr.dbname, 'res.partner', author.id),
+ {'type': 'message_notification_update', 'elements': self.env['mail.message'].concat(*author_messages)._message_notification_format()}
+ ] for author, author_messages in groupby(messages.sorted('author_id'), itemgetter('author_id'))]
+ self.env['bus.bus'].sendmany(updates)
+
+ # ------------------------------------------------------
+ # TOOLS
+ # ------------------------------------------------------
+
+ @api.model
+ def _get_record_name(self, values):
+ """ Return the related document name, using name_get. It is done using
+ SUPERUSER_ID, to be sure to have the record name correctly stored. """
+ model = values.get('model', self.env.context.get('default_model'))
+ res_id = values.get('res_id', self.env.context.get('default_res_id'))
+ if not model or not res_id or model not in self.env:
+ return False
+ return self.env[model].sudo().browse(res_id).display_name
+
+ @api.model
+ def _get_reply_to(self, values):
+ """ Return a specific reply_to for the document """
+ model = values.get('model', self._context.get('default_model'))
+ res_id = values.get('res_id', self._context.get('default_res_id')) or False
+ email_from = values.get('email_from')
+ message_type = values.get('message_type')
+ records = None
+ if self.is_thread_message({'model': model, 'res_id': res_id, 'message_type': message_type}):
+ records = self.env[model].browse([res_id])
+ else:
+ records = self.env[model] if model else self.env['mail.thread']
+ return records._notify_get_reply_to(default=email_from)[res_id]
+
+ @api.model
+ def _get_message_id(self, values):
+ if values.get('no_auto_thread', False) is True:
+ message_id = tools.generate_tracking_message_id('reply_to')
+ elif self.is_thread_message(values):
+ message_id = tools.generate_tracking_message_id('%(res_id)s-%(model)s' % values)
+ else:
+ message_id = tools.generate_tracking_message_id('private')
+ return message_id
+
+ def is_thread_message(self, vals=None):
+ if vals:
+ res_id = vals.get('res_id')
+ model = vals.get('model')
+ message_type = vals.get('message_type')
+ else:
+ self.ensure_one()
+ res_id = self.res_id
+ model = self.model
+ message_type = self.message_type
+ return res_id and model and message_type != 'user_notification'
+
+ def _invalidate_documents(self, model=None, res_id=None):
+ """ Invalidate the cache of the documents followed by ``self``. """
+ for record in self:
+ model = model or record.model
+ res_id = res_id or record.res_id
+ if issubclass(self.pool[model], self.pool['mail.thread']):
+ self.env[model].invalidate_cache(fnames=[
+ 'message_ids',
+ 'message_unread',
+ 'message_unread_counter',
+ 'message_needaction',
+ 'message_needaction_counter',
+ ], ids=[res_id])
+
+ def _get_search_domain_share(self):
+ return ['&', '&', ('is_internal', '=', False), ('subtype_id', '!=', False), ('subtype_id.internal', '=', False)]
diff --git a/addons/mail/models/mail_message_subtype.py b/addons/mail/models/mail_message_subtype.py
new file mode 100644
index 00000000..af1ca55e
--- /dev/null
+++ b/addons/mail/models/mail_message_subtype.py
@@ -0,0 +1,110 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, tools
+
+
+class MailMessageSubtype(models.Model):
+ """ Class holding subtype definition for messages. Subtypes allow to tune
+ the follower subscription, allowing only some subtypes to be pushed
+ on the Wall. """
+ _name = 'mail.message.subtype'
+ _description = 'Message subtypes'
+ _order = 'sequence, id'
+
+ name = fields.Char(
+ 'Message Type', required=True, translate=True,
+ help='Message subtype gives a more precise type on the message, '
+ 'especially for system notifications. For example, it can be '
+ 'a notification related to a new record (New), or to a stage '
+ 'change in a process (Stage change). Message subtypes allow to '
+ 'precisely tune the notifications the user want to receive on its wall.')
+ description = fields.Text(
+ 'Description', translate=True,
+ help='Description that will be added in the message posted for this '
+ 'subtype. If void, the name will be added instead.')
+ internal = fields.Boolean(
+ 'Internal Only',
+ help='Messages with internal subtypes will be visible only by employees, aka members of base_user group')
+ parent_id = fields.Many2one(
+ 'mail.message.subtype', string='Parent', ondelete='set null',
+ help='Parent subtype, used for automatic subscription. This field is not '
+ 'correctly named. For example on a project, the parent_id of project '
+ 'subtypes refers to task-related subtypes.')
+ relation_field = fields.Char(
+ 'Relation field',
+ help='Field used to link the related model to the subtype model when '
+ 'using automatic subscription on a related document. The field '
+ 'is used to compute getattr(related_document.relation_field).')
+ res_model = fields.Char('Model', help="Model the subtype applies to. If False, this subtype applies to all models.")
+ default = fields.Boolean('Default', default=True, help="Activated by default when subscribing.")
+ sequence = fields.Integer('Sequence', default=1, help="Used to order subtypes.")
+ hidden = fields.Boolean('Hidden', help="Hide the subtype in the follower options")
+
+ @api.model
+ def create(self, vals):
+ self.clear_caches()
+ return super(MailMessageSubtype, self).create(vals)
+
+ def write(self, vals):
+ self.clear_caches()
+ return super(MailMessageSubtype, self).write(vals)
+
+ def unlink(self):
+ self.clear_caches()
+ return super(MailMessageSubtype, self).unlink()
+
+ @tools.ormcache('model_name')
+ def _get_auto_subscription_subtypes(self, model_name):
+ """ Return data related to auto subscription based on subtype matching.
+ Here model_name indicates child model (like a task) on which we want to
+ make subtype matching based on its parents (like a project).
+
+ Example with tasks and project :
+
+ * generic: discussion, res_model = False
+ * task: new, res_model = project.task
+ * project: task_new, parent_id = new, res_model = project.project, field = project_id
+
+ Returned data
+
+ * child_ids: all subtypes that are generic or related to task (res_model = False or model_name)
+ * def_ids: default subtypes ids (either generic or task specific)
+ * all_int_ids: all internal-only subtypes ids (generic or task or project)
+ * parent: dict(parent subtype id, child subtype id), i.e. {task_new.id: new.id}
+ * relation: dict(parent_model, relation_fields), i.e. {'project.project': ['project_id']}
+ """
+ child_ids, def_ids = list(), list()
+ all_int_ids = list()
+ parent, relation = dict(), dict()
+ subtypes = self.sudo().search([
+ '|', '|', ('res_model', '=', False),
+ ('res_model', '=', model_name),
+ ('parent_id.res_model', '=', model_name)
+ ])
+ for subtype in subtypes:
+ if not subtype.res_model or subtype.res_model == model_name:
+ child_ids += subtype.ids
+ if subtype.default:
+ def_ids += subtype.ids
+ elif subtype.relation_field:
+ parent[subtype.id] = subtype.parent_id.id
+ relation.setdefault(subtype.res_model, set()).add(subtype.relation_field)
+ # required for backward compatibility
+ if subtype.internal:
+ all_int_ids += subtype.ids
+ return child_ids, def_ids, all_int_ids, parent, relation
+
+ @api.model
+ def default_subtypes(self, model_name):
+ """ Retrieve the default subtypes (all, internal, external) for the given model. """
+ subtype_ids, internal_ids, external_ids = self._default_subtypes(model_name)
+ return self.browse(subtype_ids), self.browse(internal_ids), self.browse(external_ids)
+
+ @tools.ormcache('self.env.uid', 'self.env.su', 'model_name')
+ def _default_subtypes(self, model_name):
+ domain = [('default', '=', True),
+ '|', ('res_model', '=', model_name), ('res_model', '=', False)]
+ subtypes = self.search(domain)
+ internal = subtypes.filtered('internal')
+ return subtypes.ids, internal.ids, (subtypes - internal).ids
diff --git a/addons/mail/models/mail_notification.py b/addons/mail/models/mail_notification.py
new file mode 100644
index 00000000..5e87c6ac
--- /dev/null
+++ b/addons/mail/models/mail_notification.py
@@ -0,0 +1,110 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from dateutil.relativedelta import relativedelta
+
+from odoo import api, fields, models
+from odoo.exceptions import AccessError
+from odoo.tools.translate import _
+
+
+class MailNotification(models.Model):
+ _name = 'mail.notification'
+ _table = 'mail_message_res_partner_needaction_rel'
+ _rec_name = 'res_partner_id'
+ _log_access = False
+ _description = 'Message Notifications'
+
+ # origin
+ mail_message_id = fields.Many2one('mail.message', 'Message', index=True, ondelete='cascade', required=True)
+ mail_id = fields.Many2one('mail.mail', 'Mail', index=True, help='Optional mail_mail ID. Used mainly to optimize searches.')
+ # recipient
+ res_partner_id = fields.Many2one('res.partner', 'Recipient', index=True, ondelete='cascade')
+ # status
+ notification_type = fields.Selection([
+ ('inbox', 'Inbox'), ('email', 'Email')
+ ], string='Notification Type', default='inbox', index=True, required=True)
+ notification_status = fields.Selection([
+ ('ready', 'Ready to Send'),
+ ('sent', 'Sent'),
+ ('bounce', 'Bounced'),
+ ('exception', 'Exception'),
+ ('canceled', 'Canceled')
+ ], string='Status', default='ready', index=True)
+ is_read = fields.Boolean('Is Read', index=True)
+ read_date = fields.Datetime('Read Date', copy=False)
+ failure_type = fields.Selection(selection=[
+ ("SMTP", "Connection failed (outgoing mail server problem)"),
+ ("RECIPIENT", "Invalid email address"),
+ ("BOUNCE", "Email address rejected by destination"),
+ ("UNKNOWN", "Unknown error"),
+ ], string='Failure type')
+ failure_reason = fields.Text('Failure reason', copy=False)
+
+ _sql_constraints = [
+ # email notification;: partner is required
+ ('notification_partner_required',
+ "CHECK(notification_type NOT IN ('email', 'inbox') OR res_partner_id IS NOT NULL)",
+ 'Customer is required for inbox / email notification'),
+ ]
+
+ def init(self):
+ self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s',
+ ('mail_notification_res_partner_id_is_read_notification_status_mail_message_id',))
+ if not self._cr.fetchone():
+ self._cr.execute("""
+ CREATE INDEX mail_notification_res_partner_id_is_read_notification_status_mail_message_id
+ ON mail_message_res_partner_needaction_rel (res_partner_id, is_read, notification_status, mail_message_id)
+ """)
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ messages = self.env['mail.message'].browse(vals['mail_message_id'] for vals in vals_list)
+ messages.check_access_rights('read')
+ messages.check_access_rule('read')
+ for vals in vals_list:
+ if vals.get('is_read'):
+ vals['read_date'] = fields.Datetime.now()
+ return super(MailNotification, self).create(vals_list)
+
+ def write(self, vals):
+ if ('mail_message_id' in vals or 'res_partner_id' in vals) and not self.env.is_admin():
+ raise AccessError(_("Can not update the message or recipient of a notification."))
+ if vals.get('is_read'):
+ vals['read_date'] = fields.Datetime.now()
+ return super(MailNotification, self).write(vals)
+
+ def format_failure_reason(self):
+ self.ensure_one()
+ if self.failure_type != 'UNKNOWN':
+ return dict(type(self).failure_type.selection).get(self.failure_type, _('No Error'))
+ else:
+ return _("Unknown error") + ": %s" % (self.failure_reason or '')
+
+ @api.model
+ def _gc_notifications(self, max_age_days=180):
+ domain = [
+ ('is_read', '=', True),
+ ('read_date', '<', fields.Datetime.now() - relativedelta(days=max_age_days)),
+ ('res_partner_id.partner_share', '=', False),
+ ('notification_status', 'in', ('sent', 'canceled'))
+ ]
+ return self.search(domain).unlink()
+
+ def _filtered_for_web_client(self):
+ """Returns only the notifications to show on the web client."""
+ return self.filtered(lambda n:
+ n.notification_type != 'inbox' and
+ (n.notification_status in ['bounce', 'exception', 'canceled'] or n.res_partner_id.partner_share)
+ )
+
+ def _notification_format(self):
+ """Returns the current notifications in the format expected by the web
+ client."""
+ return [{
+ 'id': notif.id,
+ 'notification_type': notif.notification_type,
+ 'notification_status': notif.notification_status,
+ 'failure_type': notif.failure_type,
+ 'res_partner_id': [notif.res_partner_id.id, notif.res_partner_id.display_name] if notif.res_partner_id else False,
+ } for notif in self]
diff --git a/addons/mail/models/mail_render_mixin.py b/addons/mail/models/mail_render_mixin.py
new file mode 100644
index 00000000..4804ee78
--- /dev/null
+++ b/addons/mail/models/mail_render_mixin.py
@@ -0,0 +1,482 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import babel
+import copy
+import functools
+import logging
+import re
+
+import dateutil.relativedelta as relativedelta
+from werkzeug import urls
+
+from odoo import _, api, fields, models, tools
+from odoo.exceptions import UserError
+from odoo.tools import safe_eval
+
+_logger = logging.getLogger(__name__)
+
+
+def format_date(env, date, pattern=False, lang_code=False):
+ try:
+ return tools.format_date(env, date, date_format=pattern, lang_code=lang_code)
+ except babel.core.UnknownLocaleError:
+ return date
+
+
+def format_datetime(env, dt, tz=False, dt_format='medium', lang_code=False):
+ try:
+ return tools.format_datetime(env, dt, tz=tz, dt_format=dt_format, lang_code=lang_code)
+ except babel.core.UnknownLocaleError:
+ return dt
+
+try:
+ # We use a jinja2 sandboxed environment to render mako templates.
+ # Note that the rendering does not cover all the mako syntax, in particular
+ # arbitrary Python statements are not accepted, and not all expressions are
+ # allowed: only "public" attributes (not starting with '_') of objects may
+ # be accessed.
+ # This is done on purpose: it prevents incidental or malicious execution of
+ # Python code that may break the security of the server.
+ from jinja2.sandbox import SandboxedEnvironment
+ jinja_template_env = SandboxedEnvironment(
+ block_start_string="<%",
+ block_end_string="%>",
+ variable_start_string="${",
+ variable_end_string="}",
+ comment_start_string="<%doc>",
+ comment_end_string="</%doc>",
+ line_statement_prefix="%",
+ line_comment_prefix="##",
+ trim_blocks=True, # do not output newline after blocks
+ autoescape=True, # XML/HTML automatic escaping
+ )
+ jinja_template_env.globals.update({
+ 'str': str,
+ 'quote': urls.url_quote,
+ 'urlencode': urls.url_encode,
+ 'datetime': safe_eval.datetime,
+ 'len': len,
+ 'abs': abs,
+ 'min': min,
+ 'max': max,
+ 'sum': sum,
+ 'filter': filter,
+ 'reduce': functools.reduce,
+ 'map': map,
+ 'round': round,
+
+ # dateutil.relativedelta is an old-style class and cannot be directly
+ # instanciated wihtin a jinja2 expression, so a lambda "proxy" is
+ # is needed, apparently.
+ 'relativedelta': lambda *a, **kw : relativedelta.relativedelta(*a, **kw),
+ })
+ jinja_safe_template_env = copy.copy(jinja_template_env)
+ jinja_safe_template_env.autoescape = False
+except ImportError:
+ _logger.warning("jinja2 not available, templating features will not work!")
+
+
+class MailRenderMixin(models.AbstractModel):
+ _name = 'mail.render.mixin'
+ _description = 'Mail Render Mixin'
+
+ # language for rendering
+ lang = fields.Char(
+ 'Language',
+ help="Optional translation language (ISO code) to select when sending out an email. "
+ "If not set, the english version will be used. This should usually be a placeholder expression "
+ "that provides the appropriate language, e.g. ${object.partner_id.lang}.")
+ # expression builder
+ model_object_field = fields.Many2one(
+ 'ir.model.fields', string="Field", store=False,
+ help="Select target field from the related document model.\n"
+ "If it is a relationship field you will be able to select "
+ "a target field at the destination of the relationship.")
+ sub_object = fields.Many2one(
+ 'ir.model', 'Sub-model', readonly=True, store=False,
+ help="When a relationship field is selected as first field, "
+ "this field shows the document model the relationship goes to.")
+ sub_model_object_field = fields.Many2one(
+ 'ir.model.fields', 'Sub-field', store=False,
+ help="When a relationship field is selected as first field, "
+ "this field lets you select the target field within the "
+ "destination document model (sub-model).")
+ null_value = fields.Char('Default Value', store=False, help="Optional value to use if the target field is empty")
+ copyvalue = fields.Char(
+ 'Placeholder Expression', store=False,
+ help="Final placeholder expression, to be copy-pasted in the desired template field.")
+
+ @api.onchange('model_object_field', 'sub_model_object_field', 'null_value')
+ def _onchange_dynamic_placeholder(self):
+ """ Generate the dynamic placeholder """
+ if self.model_object_field:
+ if self.model_object_field.ttype in ['many2one', 'one2many', 'many2many']:
+ model = self.env['ir.model']._get(self.model_object_field.relation)
+ if model:
+ self.sub_object = model.id
+ sub_field_name = self.sub_model_object_field.name
+ self.copyvalue = self._build_expression(self.model_object_field.name,
+ sub_field_name, self.null_value or False)
+ else:
+ self.sub_object = False
+ self.sub_model_object_field = False
+ self.copyvalue = self._build_expression(self.model_object_field.name, False, self.null_value or False)
+ else:
+ self.sub_object = False
+ self.copyvalue = False
+ self.sub_model_object_field = False
+ self.null_value = False
+
+ @api.model
+ def _build_expression(self, field_name, sub_field_name, null_value):
+ """Returns a placeholder expression for use in a template field,
+ based on the values provided in the placeholder assistant.
+
+ :param field_name: main field name
+ :param sub_field_name: sub field name (M2O)
+ :param null_value: default value if the target value is empty
+ :return: final placeholder expression """
+ expression = ''
+ if field_name:
+ expression = "${object." + field_name
+ if sub_field_name:
+ expression += "." + sub_field_name
+ if null_value:
+ expression += " or '''%s'''" % null_value
+ expression += "}"
+ return expression
+
+ # ------------------------------------------------------------
+ # TOOLS
+ # ------------------------------------------------------------
+
+ def _replace_local_links(self, html, base_url=None):
+ """ Replace local links by absolute links. It is required in various
+ cases, for example when sending emails on chatter or sending mass
+ mailings. It replaces
+
+ * href of links (mailto will not match the regex)
+ * src of images (base64 hardcoded data will not match the regex)
+ * styling using url like background-image: url
+
+ It is done using regex because it is shorten than using an html parser
+ to create a potentially complex soupe and hope to have a result that
+ has not been harmed.
+ """
+ if not html:
+ return html
+
+ html = tools.ustr(html)
+
+ def _sub_relative2absolute(match):
+ # compute here to do it only if really necessary + cache will ensure it is done only once
+ # if not base_url
+ if not _sub_relative2absolute.base_url:
+ _sub_relative2absolute.base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url")
+ return match.group(1) + urls.url_join(_sub_relative2absolute.base_url, match.group(2))
+
+ _sub_relative2absolute.base_url = base_url
+ html = re.sub(r"""(<img(?=\s)[^>]*\ssrc=")(/[^/][^"]+)""", _sub_relative2absolute, html)
+ html = re.sub(r"""(<a(?=\s)[^>]*\shref=")(/[^/][^"]+)""", _sub_relative2absolute, html)
+ html = re.sub(r"""(<[^>]+\bstyle="[^"]+\burl\('?)(/[^/'][^'")]+)""", _sub_relative2absolute, html)
+
+ return html
+
+ @api.model
+ def _render_encapsulate(self, layout_xmlid, html, add_context=None, context_record=None):
+ try:
+ template = self.env.ref(layout_xmlid, raise_if_not_found=True)
+ except ValueError:
+ _logger.warning('QWeb template %s not found when rendering encapsulation template.' % (layout_xmlid))
+ else:
+ record_name = context_record.display_name if context_record else ''
+ model_description = self.env['ir.model']._get(context_record._name).display_name if context_record else False
+ template_ctx = {
+ 'body': html,
+ 'record_name': record_name,
+ 'model_description': model_description,
+ 'company': context_record['company_id'] if (context_record and 'company_id' in context_record) else self.env.company,
+ 'record': context_record,
+ }
+ if add_context:
+ template_ctx.update(**add_context)
+
+ html = template._render(template_ctx, engine='ir.qweb', minimal_qcontext=True)
+ html = self.env['mail.render.mixin']._replace_local_links(html)
+ return html
+
+ @api.model
+ def _prepend_preview(self, html, preview):
+ """ Prepare the email body before sending. Add the text preview at the
+ beginning of the mail. The preview text is displayed bellow the mail
+ subject of most mail client (gmail, outlook...).
+
+ :param html: html content for which we want to prepend a preview
+ :param preview: the preview to add before the html content
+ :return: html with preprended preview
+ """
+ if preview:
+ preview = preview.strip()
+
+ if preview:
+ html_preview = f"""
+ <div style="display:none;font-size:1px;height:0px;width:0px;opacity:0;">
+ {tools.html_escape(preview)}
+ </div>
+ """
+ return tools.prepend_html_content(html, html_preview)
+ return html
+
+ # ------------------------------------------------------------
+ # RENDERING
+ # ------------------------------------------------------------
+
+ @api.model
+ def _render_qweb_eval_context(self):
+ """ Prepare qweb evaluation context, containing for all rendering
+
+ * ``user``: current user browse record;
+ * ``ctx```: current context;
+ * various formatting tools;
+ """
+ render_context = {
+ 'format_date': lambda date, date_format=False, lang_code=False: format_date(self.env, date, date_format, lang_code),
+ 'format_datetime': lambda dt, tz=False, dt_format=False, lang_code=False: format_datetime(self.env, dt, tz, dt_format, lang_code),
+ 'format_amount': lambda amount, currency, lang_code=False: tools.format_amount(self.env, amount, currency, lang_code),
+ 'format_duration': lambda value: tools.format_duration(value),
+ 'user': self.env.user,
+ 'ctx': self._context,
+ }
+ return render_context
+
+ @api.model
+ def _render_template_qweb(self, template_src, model, res_ids, add_context=None):
+ """ Render a QWeb template.
+
+ :param str template_src: source QWeb template. It should be a string
+ XmlID allowing to fetch an ir.ui.view;
+ :param str model: see ``MailRenderMixin._render_field)``;
+ :param list res_ids: see ``MailRenderMixin._render_field)``;
+
+ :param dict add_context: additional context to give to renderer. It
+ allows to add values to base rendering context generated by
+ ``MailRenderMixin._render_qweb_eval_context()``;
+
+ :return dict: {res_id: string of rendered template based on record}
+ """
+ view = self.env.ref(template_src, raise_if_not_found=False) or self.env['ir.ui.view']
+ results = dict.fromkeys(res_ids, u"")
+ if not view:
+ return results
+
+ # prepare template variables
+ variables = self._render_qweb_eval_context()
+ if add_context:
+ variables.update(**add_context)
+
+ for record in self.env[model].browse(res_ids):
+ variables['object'] = record
+ try:
+ render_result = view._render(variables, engine='ir.qweb', minimal_qcontext=True)
+ except Exception as e:
+ _logger.info("Failed to render template : %s (%d)" % (template_src, view.id), exc_info=True)
+ raise UserError(_("Failed to render template : %s (%d)", template_src, view.id))
+ results[record.id] = render_result
+
+ return results
+
+ @api.model
+ def _render_jinja_eval_context(self):
+ """ Prepare jinja evaluation context, containing for all rendering
+
+ * ``user``: current user browse record;
+ * ``ctx```: current context, named ctx to avoid clash with jinja
+ internals that already uses context;
+ * various formatting tools;
+ """
+ render_context = {
+ 'format_date': lambda date, date_format=False, lang_code=False: format_date(self.env, date, date_format, lang_code),
+ 'format_datetime': lambda dt, tz=False, dt_format=False, lang_code=False: format_datetime(self.env, dt, tz, dt_format, lang_code),
+ 'format_amount': lambda amount, currency, lang_code=False: tools.format_amount(self.env, amount, currency, lang_code),
+ 'format_duration': lambda value: tools.format_duration(value),
+ 'user': self.env.user,
+ 'ctx': self._context,
+ }
+ return render_context
+
+ @api.model
+ def _render_template_jinja(self, template_txt, model, res_ids, add_context=None):
+ """ Render a string-based template on records given by a model and a list
+ of IDs, using jinja.
+
+ In addition to the generic evaluation context given by _render_jinja_eval_context
+ some new variables are added, depending on each record
+
+ * ``object``: record based on which the template is rendered;
+
+ :param str template_txt: template text to render
+ :param str model: model name of records on which we want to perform rendering
+ :param list res_ids: list of ids of records (all belonging to same model)
+
+ :return dict: {res_id: string of rendered template based on record}
+ """
+ # TDE FIXME: remove that brol (6dde919bb9850912f618b561cd2141bffe41340c)
+ no_autoescape = self._context.get('safe')
+ results = dict.fromkeys(res_ids, u"")
+ if not template_txt:
+ return results
+
+ # try to load the template
+ try:
+ jinja_env = jinja_safe_template_env if no_autoescape else jinja_template_env
+ template = jinja_env.from_string(tools.ustr(template_txt))
+ except Exception:
+ _logger.info("Failed to load template %r", template_txt, exc_info=True)
+ return results
+
+ # prepare template variables
+ variables = self._render_jinja_eval_context()
+ if add_context:
+ variables.update(**add_context)
+ safe_eval.check_values(variables)
+
+ # TDE CHECKME
+ # records = self.env[model].browse(it for it in res_ids if it) # filter to avoid browsing [None]
+ if any(r is None for r in res_ids):
+ raise ValueError(_('Unsuspected None'))
+
+ for record in self.env[model].browse(res_ids):
+ variables['object'] = record
+ try:
+ render_result = template.render(variables)
+ except Exception as e:
+ _logger.info("Failed to render template : %s" % e, exc_info=True)
+ raise UserError(_("Failed to render template : %s", e))
+ if render_result == u"False":
+ render_result = u""
+ results[record.id] = render_result
+
+ return results
+
+ @api.model
+ def _render_template_postprocess(self, rendered):
+ """ Tool method for post processing. In this method we ensure local
+ links ('/shop/Basil-1') are replaced by global links ('https://www.
+ mygardin.com/hop/Basil-1').
+
+ :param rendered: result of ``_render_template``
+
+ :return dict: updated version of rendered
+ """
+ for res_id, html in rendered.items():
+ rendered[res_id] = self._replace_local_links(html)
+ return rendered
+
+ @api.model
+ def _render_template(self, template_src, model, res_ids, engine='jinja', add_context=None, post_process=False):
+ """ Render the given string on records designed by model / res_ids using
+ the given rendering engine. Currently only jinja or qweb are supported.
+
+ :param str template_src: template text to render (jinja) or xml id of view (qweb)
+ this could be cleaned but hey, we are in a rush
+ :param str model: model name of records on which we want to perform rendering
+ :param list res_ids: list of ids of records (all belonging to same model)
+ :param string engine: jinja
+ :param post_process: see ``MailRenderMixin._render_field``;
+
+ :return dict: {res_id: string of rendered template based on record}
+ """
+ if not isinstance(res_ids, (list, tuple)):
+ raise ValueError(_('Template rendering should be called only using on a list of IDs.'))
+ if engine not in ('jinja', 'qweb'):
+ raise ValueError(_('Template rendering supports only jinja or qweb.'))
+
+ if engine == 'qweb':
+ rendered = self._render_template_qweb(template_src, model, res_ids, add_context=add_context)
+ else:
+ rendered = self._render_template_jinja(template_src, model, res_ids, add_context=add_context)
+ if post_process:
+ rendered = self._render_template_postprocess(rendered)
+
+ return rendered
+
+ def _render_lang(self, res_ids):
+ """ Given some record ids, return the lang for each record based on
+ lang field of template or through specific context-based key.
+
+ :param list res_ids: list of ids of records (all belonging to same model
+ defined by self.model)
+
+ :return dict: {res_id: lang code (i.e. en_US)}
+ """
+ self.ensure_one()
+ if not isinstance(res_ids, (list, tuple)):
+ raise ValueError(_('Template rendering for language should be called with a list of IDs.'))
+
+ if self.env.context.get('template_preview_lang'):
+ return dict((res_id, self.env.context['template_preview_lang']) for res_id in res_ids)
+ else:
+ rendered_langs = self._render_template(self.lang, self.model, res_ids)
+ return dict((res_id, lang)
+ for res_id, lang in rendered_langs.items())
+
+ def _classify_per_lang(self, res_ids):
+ """ Given some record ids, return for computed each lang a contextualized
+ template and its subset of res_ids.
+
+ :param list res_ids: list of ids of records (all belonging to same model
+ defined by self.model)
+
+ :return dict: {lang: (template with lang=lang_code if specific lang computed
+ or template, res_ids targeted by that language}
+ """
+ self.ensure_one()
+
+ lang_to_res_ids = {}
+ for res_id, lang in self._render_lang(res_ids).items():
+ lang_to_res_ids.setdefault(lang, []).append(res_id)
+
+ return dict(
+ (lang, (self.with_context(lang=lang) if lang else self, lang_res_ids))
+ for lang, lang_res_ids in lang_to_res_ids.items()
+ )
+
+ def _render_field(self, field, res_ids,
+ compute_lang=False, set_lang=False,
+ post_process=False):
+ """ Given some record ids, render a template located on field on all
+ records. ``field`` should be a field of self (i.e. ``body_html`` on
+ ``mail.template``). res_ids are record IDs linked to ``model`` field
+ on self.
+
+ :param list res_ids: list of ids of records (all belonging to same model
+ defined by ``self.model``)
+
+ :param boolean compute_lang: compute language to render on translated
+ version of the template instead of default (probably english) one.
+ Language will be computed based on ``self.lang``;
+ :param string set_lang: force language for rendering. It should be a
+ valid lang code matching an activate res.lang. Checked only if
+ ``compute_lang`` is False;
+ :param boolean post_process: perform a post processing on rendered result
+ (notably html links management). See``_render_template_postprocess``);
+
+ :return dict: {res_id: string of rendered template based on record}
+ """
+ self.ensure_one()
+ if compute_lang:
+ templates_res_ids = self._classify_per_lang(res_ids)
+ elif set_lang:
+ templates_res_ids = {set_lang: (self.with_context(lang=set_lang), res_ids)}
+ else:
+ templates_res_ids = {self._context.get('lang'): (self, res_ids)}
+
+ return dict(
+ (res_id, rendered)
+ for lang, (template, tpl_res_ids) in templates_res_ids.items()
+ for res_id, rendered in template._render_template(
+ template[field], template.model, tpl_res_ids,
+ post_process=post_process
+ ).items()
+ )
diff --git a/addons/mail/models/mail_shortcode.py b/addons/mail/models/mail_shortcode.py
new file mode 100644
index 00000000..70d7f90e
--- /dev/null
+++ b/addons/mail/models/mail_shortcode.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class MailShortcode(models.Model):
+ """ Shortcode
+ Canned Responses, allowing the user to defined shortcuts in its message. Should be applied before storing message in database.
+ Emoji allowing replacing text with image for visual effect. Should be applied when the message is displayed (only for final rendering).
+ These shortcodes are global and are available for every user.
+ """
+
+ _name = 'mail.shortcode'
+ _description = 'Canned Response / Shortcode'
+ source = fields.Char('Shortcut', required=True, index=True, help="The shortcut which must be replaced in the Chat Messages")
+ substitution = fields.Text('Substitution', required=True, index=True, help="The escaped html code replacing the shortcut")
+ description = fields.Char('Description')
+ message_ids = fields.Many2one('mail.message', string="Messages", store=False)
diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py
new file mode 100644
index 00000000..0e8e2cc8
--- /dev/null
+++ b/addons/mail/models/mail_template.py
@@ -0,0 +1,296 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import base64
+import logging
+
+
+from odoo import _, api, fields, models, tools
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+class MailTemplate(models.Model):
+ "Templates for sending email"
+ _name = "mail.template"
+ _inherit = ['mail.render.mixin']
+ _description = 'Email Templates'
+ _order = 'name'
+
+ @api.model
+ def default_get(self, fields):
+ res = super(MailTemplate, self).default_get(fields)
+ if res.get('model'):
+ res['model_id'] = self.env['ir.model']._get(res.pop('model')).id
+ return res
+
+ # description
+ name = fields.Char('Name')
+ model_id = fields.Many2one('ir.model', 'Applies to', help="The type of document this template can be used with")
+ model = fields.Char('Related Document Model', related='model_id.model', index=True, store=True, readonly=True)
+ subject = fields.Char('Subject', translate=True, help="Subject (placeholders may be used here)")
+ email_from = fields.Char('From',
+ help="Sender address (placeholders may be used here). If not set, the default "
+ "value will be the author's email alias if configured, or email address.")
+ # recipients
+ use_default_to = fields.Boolean(
+ 'Default recipients',
+ help="Default recipients of the record:\n"
+ "- partner (using id on a partner or the partner_id field) OR\n"
+ "- email (using email_from or email field)")
+ email_to = fields.Char('To (Emails)', help="Comma-separated recipient addresses (placeholders may be used here)")
+ partner_to = fields.Char('To (Partners)',
+ help="Comma-separated ids of recipient partners (placeholders may be used here)")
+ email_cc = fields.Char('Cc', help="Carbon copy recipients (placeholders may be used here)")
+ reply_to = fields.Char('Reply-To', help="Preferred response address (placeholders may be used here)")
+ # content
+ body_html = fields.Html('Body', translate=True, sanitize=False)
+ attachment_ids = fields.Many2many('ir.attachment', 'email_template_attachment_rel', 'email_template_id',
+ 'attachment_id', 'Attachments',
+ help="You may attach files to this template, to be added to all "
+ "emails created from this template")
+ report_name = fields.Char('Report Filename', translate=True,
+ help="Name to use for the generated report file (may contain placeholders)\n"
+ "The extension can be omitted and will then come from the report type.")
+ report_template = fields.Many2one('ir.actions.report', 'Optional report to print and attach')
+ # options
+ mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing Mail Server', readonly=False,
+ help="Optional preferred server for outgoing mails. If not set, the highest "
+ "priority one will be used.")
+ scheduled_date = fields.Char('Scheduled Date', help="If set, the queue manager will send the email after the date. If not set, the email will be send as soon as possible. Jinja2 placeholders may be used.")
+ auto_delete = fields.Boolean(
+ 'Auto Delete', default=True,
+ 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.")
+ # contextual action
+ ref_ir_act_window = fields.Many2one('ir.actions.act_window', 'Sidebar action', readonly=True, copy=False,
+ help="Sidebar action to make this template available on records "
+ "of the related document model")
+
+ def unlink(self):
+ self.unlink_action()
+ return super(MailTemplate, self).unlink()
+
+ @api.returns('self', lambda value: value.id)
+ def copy(self, default=None):
+ default = dict(default or {},
+ name=_("%s (copy)", self.name))
+ return super(MailTemplate, self).copy(default=default)
+
+ def unlink_action(self):
+ for template in self:
+ if template.ref_ir_act_window:
+ template.ref_ir_act_window.unlink()
+ return True
+
+ def create_action(self):
+ ActWindow = self.env['ir.actions.act_window']
+ view = self.env.ref('mail.email_compose_message_wizard_form')
+
+ for template in self:
+ button_name = _('Send Mail (%s)', template.name)
+ action = ActWindow.create({
+ 'name': button_name,
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'mail.compose.message',
+ 'context': "{'default_composition_mode': 'mass_mail', 'default_template_id' : %d, 'default_use_template': True}" % (template.id),
+ 'view_mode': 'form,tree',
+ 'view_id': view.id,
+ 'target': 'new',
+ 'binding_model_id': template.model_id.id,
+ })
+ template.write({'ref_ir_act_window': action.id})
+
+ return True
+
+ # ------------------------------------------------------------
+ # MESSAGE/EMAIL VALUES GENERATION
+ # ------------------------------------------------------------
+
+ def generate_recipients(self, results, res_ids):
+ """Generates the recipients of the template. Default values can ben generated
+ instead of the template values if requested by template or context.
+ Emails (email_to, email_cc) can be transformed into partners if requested
+ in the context. """
+ self.ensure_one()
+
+ if self.use_default_to or self._context.get('tpl_force_default_to'):
+ records = self.env[self.model].browse(res_ids).sudo()
+ default_recipients = records._message_get_default_recipients()
+ for res_id, recipients in default_recipients.items():
+ results[res_id].pop('partner_to', None)
+ results[res_id].update(recipients)
+
+ records_company = None
+ if self._context.get('tpl_partners_only') and self.model and results and 'company_id' in self.env[self.model]._fields:
+ records = self.env[self.model].browse(results.keys()).read(['company_id'])
+ records_company = {rec['id']: (rec['company_id'][0] if rec['company_id'] else None) for rec in records}
+
+ for res_id, values in results.items():
+ partner_ids = values.get('partner_ids', list())
+ if self._context.get('tpl_partners_only'):
+ mails = tools.email_split(values.pop('email_to', '')) + tools.email_split(values.pop('email_cc', ''))
+ Partner = self.env['res.partner']
+ if records_company:
+ Partner = Partner.with_context(default_company_id=records_company[res_id])
+ for mail in mails:
+ partner = Partner.find_or_create(mail)
+ partner_ids.append(partner.id)
+ partner_to = values.pop('partner_to', '')
+ if partner_to:
+ # placeholders could generate '', 3, 2 due to some empty field values
+ tpl_partner_ids = [int(pid) for pid in partner_to.split(',') if pid]
+ partner_ids += self.env['res.partner'].sudo().browse(tpl_partner_ids).exists().ids
+ results[res_id]['partner_ids'] = partner_ids
+ return results
+
+ def generate_email(self, res_ids, fields):
+ """Generates an email from the template for given the given model based on
+ records given by res_ids.
+
+ :param res_id: id of the record to use for rendering the template (model
+ is taken from template definition)
+ :returns: a dict containing all relevant fields for creating a new
+ mail.mail entry, with one extra key ``attachments``, in the
+ format [(report_name, data)] where data is base64 encoded.
+ """
+ self.ensure_one()
+ multi_mode = True
+ if isinstance(res_ids, int):
+ res_ids = [res_ids]
+ multi_mode = False
+
+ results = dict()
+ for lang, (template, template_res_ids) in self._classify_per_lang(res_ids).items():
+ for field in fields:
+ template = template.with_context(safe=(field == 'subject'))
+ generated_field_values = template._render_field(
+ field, template_res_ids,
+ post_process=(field == 'body_html')
+ )
+ for res_id, field_value in generated_field_values.items():
+ results.setdefault(res_id, dict())[field] = field_value
+ # compute recipients
+ if any(field in fields for field in ['email_to', 'partner_to', 'email_cc']):
+ results = template.generate_recipients(results, template_res_ids)
+ # update values for all res_ids
+ for res_id in template_res_ids:
+ values = results[res_id]
+ if values.get('body_html'):
+ values['body'] = tools.html_sanitize(values['body_html'])
+ # technical settings
+ values.update(
+ mail_server_id=template.mail_server_id.id or False,
+ auto_delete=template.auto_delete,
+ model=template.model,
+ res_id=res_id or False,
+ attachment_ids=[attach.id for attach in template.attachment_ids],
+ )
+
+ # Add report in attachments: generate once for all template_res_ids
+ if template.report_template:
+ for res_id in template_res_ids:
+ attachments = []
+ report_name = template._render_field('report_name', [res_id])[res_id]
+ report = template.report_template
+ report_service = report.report_name
+
+ if report.report_type in ['qweb-html', 'qweb-pdf']:
+ result, format = report._render_qweb_pdf([res_id])
+ else:
+ res = report._render([res_id])
+ if not res:
+ raise UserError(_('Unsupported report type %s found.', report.report_type))
+ result, format = res
+
+ # TODO in trunk, change return format to binary to match message_post expected format
+ result = base64.b64encode(result)
+ if not report_name:
+ report_name = 'report.' + report_service
+ ext = "." + format
+ if not report_name.endswith(ext):
+ report_name += ext
+ attachments.append((report_name, result))
+ results[res_id]['attachments'] = attachments
+
+ return multi_mode and results or results[res_ids[0]]
+
+ # ------------------------------------------------------------
+ # EMAIL
+ # ------------------------------------------------------------
+
+ def _send_check_access(self, res_ids):
+ records = self.env[self.model].browse(res_ids)
+ records.check_access_rights('read')
+ records.check_access_rule('read')
+
+ def send_mail(self, res_id, force_send=False, raise_exception=False, email_values=None, notif_layout=False):
+ """ Generates a new mail.mail. Template is rendered on record given by
+ res_id and model coming from template.
+
+ :param int res_id: id of the record to render the template
+ :param bool force_send: send email immediately; otherwise use the mail
+ queue (recommended);
+ :param dict email_values: update generated mail with those values to further
+ customize the mail;
+ :param str notif_layout: optional notification layout to encapsulate the
+ generated email;
+ :returns: id of the mail.mail that was created """
+
+ # Grant access to send_mail only if access to related document
+ self.ensure_one()
+ self._send_check_access([res_id])
+
+ Attachment = self.env['ir.attachment'] # TDE FIXME: should remove default_type from context
+
+ # create a mail_mail based on values, without attachments
+ values = self.generate_email(res_id, ['subject', 'body_html', 'email_from', 'email_to', 'partner_to', 'email_cc', 'reply_to', 'scheduled_date'])
+ values['recipient_ids'] = [(4, pid) for pid in values.get('partner_ids', list())]
+ values['attachment_ids'] = [(4, aid) for aid in values.get('attachment_ids', list())]
+ values.update(email_values or {})
+ attachment_ids = values.pop('attachment_ids', [])
+ attachments = values.pop('attachments', [])
+ # add a protection against void email_from
+ if 'email_from' in values and not values.get('email_from'):
+ values.pop('email_from')
+ # encapsulate body
+ if notif_layout and values['body_html']:
+ try:
+ template = self.env.ref(notif_layout, raise_if_not_found=True)
+ except ValueError:
+ _logger.warning('QWeb template %s not found when sending template %s. Sending without layouting.' % (notif_layout, self.name))
+ else:
+ record = self.env[self.model].browse(res_id)
+ model = self.env['ir.model']._get(record._name)
+
+ if self.lang:
+ lang = self._render_lang([res_id])[res_id]
+ template = template.with_context(lang=lang)
+ model = model.with_context(lang=lang)
+
+ template_ctx = {
+ 'message': self.env['mail.message'].sudo().new(dict(body=values['body_html'], record_name=record.display_name)),
+ 'model_description': model.display_name,
+ 'company': 'company_id' in record and record['company_id'] or self.env.company,
+ 'record': record,
+ }
+ body = template._render(template_ctx, engine='ir.qweb', minimal_qcontext=True)
+ values['body_html'] = self.env['mail.render.mixin']._replace_local_links(body)
+ mail = self.env['mail.mail'].sudo().create(values)
+
+ # manage attachments
+ for attachment in attachments:
+ attachment_data = {
+ 'name': attachment[0],
+ 'datas': attachment[1],
+ 'type': 'binary',
+ 'res_model': 'mail.message',
+ 'res_id': mail.mail_message_id.id,
+ }
+ attachment_ids.append((4, Attachment.create(attachment_data).id))
+ if attachment_ids:
+ mail.write({'attachment_ids': attachment_ids})
+
+ if force_send:
+ mail.send(raise_exception=raise_exception)
+ return mail.id # TDE CLEANME: return mail + api.returns ?
diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py
new file mode 100644
index 00000000..3b63ef0f
--- /dev/null
+++ b/addons/mail/models/mail_thread.py
@@ -0,0 +1,2883 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import ast
+import base64
+import datetime
+import dateutil
+import email
+import email.policy
+import hashlib
+import hmac
+import lxml
+import logging
+import pytz
+import re
+import socket
+import time
+import threading
+
+from collections import namedtuple
+from email.message import EmailMessage
+from email import message_from_string, policy
+from lxml import etree
+from werkzeug import urls
+from xmlrpc import client as xmlrpclib
+
+from odoo import _, api, exceptions, fields, models, tools, registry, SUPERUSER_ID
+from odoo.exceptions import MissingError
+from odoo.osv import expression
+
+from odoo.tools import ustr
+from odoo.tools.misc import clean_context, split_every
+
+_logger = logging.getLogger(__name__)
+
+
+class MailThread(models.AbstractModel):
+ ''' mail_thread model is meant to be inherited by any model that needs to
+ act as a discussion topic on which messages can be attached. Public
+ methods are prefixed with ``message_`` in order to avoid name
+ collisions with methods of the models that will inherit from this class.
+
+ ``mail.thread`` defines fields used to handle and display the
+ communication history. ``mail.thread`` also manages followers of
+ inheriting classes. All features and expected behavior are managed
+ by mail.thread. Widgets has been designed for the 7.0 and following
+ versions of Odoo.
+
+ Inheriting classes are not required to implement any method, as the
+ default implementation will work for any model. However it is common
+ to override at least the ``message_new`` and ``message_update``
+ methods (calling ``super``) to add model-specific behavior at
+ creation and update of a thread when processing incoming emails.
+
+ Options:
+ - _mail_flat_thread: if set to True, all messages without parent_id
+ are automatically attached to the first message posted on the
+ ressource. If set to False, the display of Chatter is done using
+ threads, and no parent_id is automatically set.
+
+ MailThread features can be somewhat controlled through context keys :
+
+ - ``mail_create_nosubscribe``: at create or message_post, do not subscribe
+ uid to the record thread
+ - ``mail_create_nolog``: at create, do not log the automatic '<Document>
+ created' message
+ - ``mail_notrack``: at create and write, do not perform the value tracking
+ creating messages
+ - ``tracking_disable``: at create and write, perform no MailThread features
+ (auto subscription, tracking, post, ...)
+ - ``mail_notify_force_send``: if less than 50 email notifications to send,
+ send them directly instead of using the queue; True by default
+ '''
+ _name = 'mail.thread'
+ _description = 'Email Thread'
+ _mail_flat_thread = True # flatten the discussino history
+ _mail_post_access = 'write' # access required on the document to post on it
+ _Attachment = namedtuple('Attachment', ('fname', 'content', 'info'))
+
+ message_is_follower = fields.Boolean(
+ 'Is Follower', compute='_compute_is_follower', search='_search_is_follower')
+ message_follower_ids = fields.One2many(
+ 'mail.followers', 'res_id', string='Followers', groups='base.group_user')
+ message_partner_ids = fields.Many2many(
+ comodel_name='res.partner', string='Followers (Partners)',
+ compute='_get_followers', search='_search_follower_partners',
+ groups='base.group_user')
+ message_channel_ids = fields.Many2many(
+ comodel_name='mail.channel', string='Followers (Channels)',
+ compute='_get_followers', search='_search_follower_channels',
+ groups='base.group_user')
+ message_ids = fields.One2many(
+ 'mail.message', 'res_id', string='Messages',
+ domain=lambda self: [('message_type', '!=', 'user_notification')], auto_join=True)
+ message_unread = fields.Boolean(
+ 'Unread Messages', compute='_get_message_unread',
+ help="If checked, new messages require your attention.")
+ message_unread_counter = fields.Integer(
+ 'Unread Messages Counter', compute='_get_message_unread',
+ help="Number of unread messages")
+ message_needaction = fields.Boolean(
+ 'Action Needed', compute='_get_message_needaction', search='_search_message_needaction',
+ help="If checked, new messages require your attention.")
+ message_needaction_counter = fields.Integer(
+ 'Number of Actions', compute='_get_message_needaction',
+ help="Number of messages which requires an action")
+ message_has_error = fields.Boolean(
+ 'Message Delivery error', compute='_compute_message_has_error', search='_search_message_has_error',
+ help="If checked, some messages have a delivery error.")
+ message_has_error_counter = fields.Integer(
+ 'Number of errors', compute='_compute_message_has_error',
+ help="Number of messages with delivery error")
+ message_attachment_count = fields.Integer('Attachment Count', compute='_compute_message_attachment_count', groups="base.group_user")
+ message_main_attachment_id = fields.Many2one(string="Main Attachment", comodel_name='ir.attachment', index=True, copy=False)
+
+ @api.depends('message_follower_ids')
+ def _get_followers(self):
+ for thread in self:
+ thread.message_partner_ids = thread.message_follower_ids.mapped('partner_id')
+ thread.message_channel_ids = thread.message_follower_ids.mapped('channel_id')
+
+ @api.model
+ def _search_follower_partners(self, operator, operand):
+ """Search function for message_follower_ids
+
+ Do not use with operator 'not in'. Use instead message_is_followers
+ """
+ # TOFIX make it work with not in
+ assert operator != "not in", "Do not search message_follower_ids with 'not in'"
+ followers = self.env['mail.followers'].sudo().search([
+ ('res_model', '=', self._name),
+ ('partner_id', operator, operand)])
+ # using read() below is much faster than followers.mapped('res_id')
+ return [('id', 'in', [res['res_id'] for res in followers.read(['res_id'])])]
+
+ @api.model
+ def _search_follower_channels(self, operator, operand):
+ """Search function for message_follower_ids
+
+ Do not use with operator 'not in'. Use instead message_is_followers
+ """
+ # TOFIX make it work with not in
+ assert operator != "not in", "Do not search message_follower_ids with 'not in'"
+ followers = self.env['mail.followers'].sudo().search([
+ ('res_model', '=', self._name),
+ ('channel_id', operator, operand)])
+ # using read() below is much faster than followers.mapped('res_id')
+ return [('id', 'in', [res['res_id'] for res in followers.read(['res_id'])])]
+
+ @api.depends('message_follower_ids')
+ def _compute_is_follower(self):
+ followers = self.env['mail.followers'].sudo().search([
+ ('res_model', '=', self._name),
+ ('res_id', 'in', self.ids),
+ ('partner_id', '=', self.env.user.partner_id.id),
+ ])
+ # using read() below is much faster than followers.mapped('res_id')
+ following_ids = [res['res_id'] for res in followers.read(['res_id'])]
+ for record in self:
+ record.message_is_follower = record.id in following_ids
+
+ @api.model
+ def _search_is_follower(self, operator, operand):
+ followers = self.env['mail.followers'].sudo().search([
+ ('res_model', '=', self._name),
+ ('partner_id', '=', self.env.user.partner_id.id),
+ ])
+ # Cases ('message_is_follower', '=', True) or ('message_is_follower', '!=', False)
+ if (operator == '=' and operand) or (operator == '!=' and not operand):
+ # using read() below is much faster than followers.mapped('res_id')
+ return [('id', 'in', [res['res_id'] for res in followers.read(['res_id'])])]
+ else:
+ # using read() below is much faster than followers.mapped('res_id')
+ return [('id', 'not in', [res['res_id'] for res in followers.read(['res_id'])])]
+
+ def _get_message_unread(self):
+ partner_id = self.env.user.partner_id.id
+ res = dict.fromkeys(self.ids, 0)
+ if self.ids:
+ # search for unread messages, directly in SQL to improve performances
+ self._cr.execute(""" SELECT msg.res_id FROM mail_message msg
+ RIGHT JOIN mail_message_mail_channel_rel rel
+ ON rel.mail_message_id = msg.id
+ RIGHT JOIN mail_channel_partner cp
+ ON (cp.channel_id = rel.mail_channel_id AND cp.partner_id = %s AND
+ (cp.seen_message_id IS NULL OR cp.seen_message_id < msg.id))
+ WHERE msg.model = %s AND msg.res_id = ANY(%s) AND
+ msg.message_type != 'user_notification' AND
+ (msg.author_id IS NULL OR msg.author_id != %s) AND
+ (msg.message_type not in ('notification', 'user_notification') OR msg.model != 'mail.channel')""",
+ (partner_id, self._name, list(self.ids), partner_id,))
+ for result in self._cr.fetchall():
+ res[result[0]] += 1
+
+ for record in self:
+ record.message_unread_counter = res.get(record._origin.id, 0)
+ record.message_unread = bool(record.message_unread_counter)
+
+ def _get_message_needaction(self):
+ res = dict.fromkeys(self.ids, 0)
+ if self.ids:
+ # search for unread messages, directly in SQL to improve performances
+ self._cr.execute(""" SELECT msg.res_id FROM mail_message msg
+ RIGHT JOIN mail_message_res_partner_needaction_rel rel
+ ON rel.mail_message_id = msg.id AND rel.res_partner_id = %s AND (rel.is_read = false OR rel.is_read IS NULL)
+ WHERE msg.model = %s AND msg.res_id in %s AND msg.message_type != 'user_notification'""",
+ (self.env.user.partner_id.id, self._name, tuple(self.ids),))
+ for result in self._cr.fetchall():
+ res[result[0]] += 1
+
+ for record in self:
+ record.message_needaction_counter = res.get(record._origin.id, 0)
+ record.message_needaction = bool(record.message_needaction_counter)
+
+ @api.model
+ def _search_message_needaction(self, operator, operand):
+ return [('message_ids.needaction', operator, operand)]
+
+ def _compute_message_has_error(self):
+ res = {}
+ if self.ids:
+ self._cr.execute(""" SELECT msg.res_id, COUNT(msg.res_id) FROM mail_message msg
+ RIGHT JOIN mail_message_res_partner_needaction_rel rel
+ ON rel.mail_message_id = msg.id AND rel.notification_status in ('exception','bounce')
+ WHERE msg.author_id = %s AND msg.model = %s AND msg.res_id in %s AND msg.message_type != 'user_notification'
+ GROUP BY msg.res_id""",
+ (self.env.user.partner_id.id, self._name, tuple(self.ids),))
+ res.update(self._cr.fetchall())
+
+ for record in self:
+ record.message_has_error_counter = res.get(record._origin.id, 0)
+ record.message_has_error = bool(record.message_has_error_counter)
+
+ @api.model
+ def _search_message_has_error(self, operator, operand):
+ message_ids = self.env['mail.message']._search([('has_error', operator, operand), ('author_id', '=', self.env.user.partner_id.id)])
+ return [('message_ids', 'in', message_ids)]
+
+ def _compute_message_attachment_count(self):
+ read_group_var = self.env['ir.attachment'].read_group([('res_id', 'in', self.ids), ('res_model', '=', self._name)],
+ fields=['res_id'],
+ groupby=['res_id'])
+
+ attachment_count_dict = dict((d['res_id'], d['res_id_count']) for d in read_group_var)
+ for record in self:
+ record.message_attachment_count = attachment_count_dict.get(record.id, 0)
+
+ # ------------------------------------------------------------
+ # CRUD
+ # ------------------------------------------------------------
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """ Chatter override :
+ - subscribe uid
+ - subscribe followers of parent
+ - log a creation message
+ """
+ if self._context.get('tracking_disable'):
+ threads = super(MailThread, self).create(vals_list)
+ threads._discard_tracking()
+ return threads
+
+ threads = super(MailThread, self).create(vals_list)
+ # subscribe uid unless asked not to
+ if not self._context.get('mail_create_nosubscribe'):
+ for thread in threads:
+ self.env['mail.followers']._insert_followers(
+ thread._name, thread.ids, self.env.user.partner_id.ids,
+ None, None, None,
+ customer_ids=[],
+ check_existing=False
+ )
+
+ # auto_subscribe: take values and defaults into account
+ create_values_list = {}
+ for thread, values in zip(threads, vals_list):
+ create_values = dict(values)
+ for key, val in self._context.items():
+ if key.startswith('default_') and key[8:] not in create_values:
+ create_values[key[8:]] = val
+ thread._message_auto_subscribe(create_values, followers_existing_policy='update')
+ create_values_list[thread.id] = create_values
+
+ # automatic logging unless asked not to (mainly for various testing purpose)
+ if not self._context.get('mail_create_nolog'):
+ threads_no_subtype = self.env[self._name]
+ for thread in threads:
+ subtype = thread._creation_subtype()
+ if subtype: # if we have a subtype, post message to notify users from _message_auto_subscribe
+ thread.sudo().message_post(subtype_id=subtype.id, author_id=self.env.user.partner_id.id)
+ else:
+ threads_no_subtype += thread
+ if threads_no_subtype:
+ bodies = dict(
+ (thread.id, thread._creation_message())
+ for thread in threads_no_subtype)
+ threads_no_subtype._message_log_batch(bodies=bodies)
+
+ # post track template if a tracked field changed
+ threads._discard_tracking()
+ if not self._context.get('mail_notrack'):
+ fnames = self._get_tracked_fields()
+ for thread in threads:
+ create_values = create_values_list[thread.id]
+ changes = [fname for fname in fnames if create_values.get(fname)]
+ # based on tracked field to stay consistent with write
+ # we don't consider that a falsy field is a change, to stay consistent with previous implementation,
+ # but we may want to change that behaviour later.
+ thread._message_track_post_template(changes)
+
+ return threads
+
+ def write(self, values):
+ if self._context.get('tracking_disable'):
+ return super(MailThread, self).write(values)
+
+ if not self._context.get('mail_notrack'):
+ self._prepare_tracking(self._fields)
+
+ # Perform write
+ result = super(MailThread, self).write(values)
+
+ # update followers
+ self._message_auto_subscribe(values)
+
+ return result
+
+ def unlink(self):
+ """ Override unlink to delete messages and followers. This cannot be
+ cascaded, because link is done through (res_model, res_id). """
+ if not self:
+ return True
+ # discard pending tracking
+ self._discard_tracking()
+ self.env['mail.message'].search([('model', '=', self._name), ('res_id', 'in', self.ids)]).sudo().unlink()
+ res = super(MailThread, self).unlink()
+ self.env['mail.followers'].sudo().search(
+ [('res_model', '=', self._name), ('res_id', 'in', self.ids)]
+ ).unlink()
+ return res
+
+ def copy_data(self, default=None):
+ # avoid tracking multiple temporary changes during copy
+ return super(MailThread, self.with_context(mail_notrack=True)).copy_data(default=default)
+
+ @api.model
+ def get_empty_list_help(self, help):
+ """ Override of BaseModel.get_empty_list_help() to generate an help message
+ that adds alias information. """
+ model = self._context.get('empty_list_help_model')
+ res_id = self._context.get('empty_list_help_id')
+ catchall_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain")
+ document_name = self._context.get('empty_list_help_document_name', _('document'))
+ nothing_here = not help
+ alias = None
+
+ if catchall_domain and model and res_id: # specific res_id -> find its alias (i.e. section_id specified)
+ record = self.env[model].sudo().browse(res_id)
+ # check that the alias effectively creates new records
+ if record.alias_id and record.alias_id.alias_name and \
+ record.alias_id.alias_model_id and \
+ record.alias_id.alias_model_id.model == self._name and \
+ record.alias_id.alias_force_thread_id == 0:
+ alias = record.alias_id
+ if not alias and catchall_domain and model: # no res_id or res_id not linked to an alias -> generic help message, take a generic alias of the model
+ Alias = self.env['mail.alias']
+ aliases = Alias.search([
+ ("alias_parent_model_id.model", "=", model),
+ ("alias_name", "!=", False),
+ ('alias_force_thread_id', '=', False),
+ ('alias_parent_thread_id', '=', False)], order='id ASC')
+ if aliases and len(aliases) == 1:
+ alias = aliases[0]
+
+ if alias:
+ email_link = "<a href='mailto:%(email)s'>%(email)s</a>" % {'email': alias.display_name}
+ if nothing_here:
+ return "<p class='o_view_nocontent_smiling_face'>%(dyn_help)s</p>" % {
+ 'dyn_help': _("Add a new %(document)s or send an email to %(email_link)s",
+ document=document_name,
+ email_link=email_link,
+ )
+ }
+ # do not add alias two times if it was added previously
+ if "oe_view_nocontent_alias" not in help:
+ return "%(static_help)s<p class='oe_view_nocontent_alias'>%(dyn_help)s</p>" % {
+ 'static_help': help,
+ 'dyn_help': _("Create new %(document)s by sending an email to %(email_link)s",
+ document=document_name,
+ email_link=email_link,
+ )
+ }
+
+ if nothing_here:
+ return "<p class='o_view_nocontent_smiling_face'>%(dyn_help)s</p>" % {
+ 'dyn_help': _("Create new %(document)s", document=document_name),
+ }
+
+ return help
+
+ # ------------------------------------------------------
+ # MODELS / CRUD HELPERS
+ # ------------------------------------------------------
+
+ def _compute_field_value(self, field):
+ if not self._context.get('tracking_disable') and not self._context.get('mail_notrack'):
+ self._prepare_tracking(f.name for f in self.pool.field_computed[field] if f.store)
+
+ return super()._compute_field_value(field)
+
+ def _creation_subtype(self):
+ """ Give the subtypes triggered by the creation of a record
+
+ :returns: a subtype browse record (empty if no subtype is triggered)
+ """
+ return self.env['mail.message.subtype']
+
+ def _get_creation_message(self):
+ """ Deprecated, remove in 14+ """
+ return self._creation_message()
+
+ def _creation_message(self):
+ """ Get the creation message to log into the chatter at the record's creation.
+ :returns: The message's body to log.
+ """
+ self.ensure_one()
+ doc_name = self.env['ir.model']._get(self._name).name
+ return _('%s created', doc_name)
+
+ @api.model
+ def get_mail_message_access(self, res_ids, operation, model_name=None):
+ """ Deprecated, remove with v14+ """
+ return self._get_mail_message_access(res_ids, operation, model_name=model_name)
+
+ @api.model
+ def _get_mail_message_access(self, res_ids, operation, model_name=None):
+ """ mail.message check permission rules for related document. This method is
+ meant to be inherited in order to implement addons-specific behavior.
+ A common behavior would be to allow creating messages when having read
+ access rule on the document, for portal document such as issues. """
+
+ DocModel = self.env[model_name] if model_name else self
+ create_allow = getattr(DocModel, '_mail_post_access', 'write')
+
+ if operation in ['write', 'unlink']:
+ check_operation = 'write'
+ elif operation == 'create' and create_allow in ['create', 'read', 'write', 'unlink']:
+ check_operation = create_allow
+ elif operation == 'create':
+ check_operation = 'write'
+ else:
+ check_operation = operation
+ return check_operation
+
+ def _valid_field_parameter(self, field, name):
+ # allow tracking on models inheriting from 'mail.thread'
+ return name == 'tracking' or super()._valid_field_parameter(field, name)
+
+ def with_lang(self):
+ """ Deprecated, remove in 14+ """
+ return self._fallback_lang()
+
+ def _fallback_lang(self):
+ if not self._context.get("lang"):
+ return self.with_context(lang=self.env.user.lang)
+ return self
+
+ # ------------------------------------------------------
+ # WRAPPERS AND TOOLS
+ # ------------------------------------------------------
+
+ def message_change_thread(self, new_thread):
+ """
+ Transfer the list of the mail thread messages from an model to another
+
+ :param id : the old res_id of the mail.message
+ :param new_res_id : the new res_id of the mail.message
+ :param new_model : the name of the new model of the mail.message
+
+ Example : my_lead.message_change_thread(my_project_task)
+ will transfer the context of the thread of my_lead to my_project_task
+ """
+ self.ensure_one()
+ # get the subtype of the comment Message
+ subtype_comment = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment')
+
+ # get the ids of the comment and not-comment of the thread
+ # TDE check: sudo on mail.message, to be sure all messages are moved ?
+ MailMessage = self.env['mail.message']
+ msg_comment = MailMessage.search([
+ ('model', '=', self._name),
+ ('res_id', '=', self.id),
+ ('message_type', '!=', 'user_notification'),
+ ('subtype_id', '=', subtype_comment)])
+ msg_not_comment = MailMessage.search([
+ ('model', '=', self._name),
+ ('res_id', '=', self.id),
+ ('message_type', '!=', 'user_notification'),
+ ('subtype_id', '!=', subtype_comment)])
+
+ # update the messages
+ msg_comment.write({"res_id": new_thread.id, "model": new_thread._name})
+ msg_not_comment.write({"res_id": new_thread.id, "model": new_thread._name, "subtype_id": None})
+ return True
+
+ # ------------------------------------------------------
+ # TRACKING / LOG
+ # ------------------------------------------------------
+
+ def _prepare_tracking(self, fields):
+ """ Prepare the tracking of ``fields`` for ``self``.
+
+ :param fields: iterable of fields names to potentially track
+ """
+ fnames = self._get_tracked_fields().intersection(fields)
+ if not fnames:
+ return
+ self.env.cr.precommit.add(self._finalize_tracking)
+ initial_values = self.env.cr.precommit.data.setdefault(f'mail.tracking.{self._name}', {})
+ for record in self:
+ if not record.id:
+ continue
+ values = initial_values.setdefault(record.id, {})
+ if values is not None:
+ for fname in fnames:
+ values.setdefault(fname, record[fname])
+
+ def _discard_tracking(self):
+ """ Prevent any tracking of fields on ``self``. """
+ if not self._get_tracked_fields():
+ return
+ self.env.cr.precommit.add(self._finalize_tracking)
+ initial_values = self.env.cr.precommit.data.setdefault(f'mail.tracking.{self._name}', {})
+ # disable tracking by setting initial values to None
+ for id_ in self.ids:
+ initial_values[id_] = None
+
+ def _finalize_tracking(self):
+ """ Generate the tracking messages for the records that have been
+ prepared with ``_prepare_tracking``.
+ """
+ initial_values = self.env.cr.precommit.data.pop(f'mail.tracking.{self._name}', {})
+ ids = [id_ for id_, vals in initial_values.items() if vals]
+ if not ids:
+ return
+ records = self.browse(ids).sudo()
+ fnames = self._get_tracked_fields()
+ context = clean_context(self._context)
+ tracking = records.with_context(context).message_track(fnames, initial_values)
+ for record in records:
+ changes, tracking_value_ids = tracking.get(record.id, (None, None))
+ record._message_track_post_template(changes)
+ # this method is called after the main flush() and just before commit();
+ # we have to flush() again in case we triggered some recomputations
+ self.flush()
+
+ @tools.ormcache('self.env.uid', 'self.env.su')
+ def _get_tracked_fields(self):
+ """ Return the set of tracked fields names for the current model. """
+ fields = {
+ name
+ for name, field in self._fields.items()
+ if getattr(field, 'tracking', None) or getattr(field, 'track_visibility', None)
+ }
+
+ return fields and set(self.fields_get(fields))
+
+ def _message_track_post_template(self, changes):
+ if not changes:
+ return True
+ # Clean the context to get rid of residual default_* keys
+ # that could cause issues afterward during the mail.message
+ # generation. Example: 'default_parent_id' would refer to
+ # the parent_id of the current record that was used during
+ # its creation, but could refer to wrong parent message id,
+ # leading to a traceback in case the related message_id
+ # doesn't exist
+ self = self.with_context(clean_context(self._context))
+ templates = self._track_template(changes)
+ for field_name, (template, post_kwargs) in templates.items():
+ if not template:
+ continue
+ if isinstance(template, str):
+ self._fallback_lang().message_post_with_view(template, **post_kwargs)
+ else:
+ self._fallback_lang().message_post_with_template(template.id, **post_kwargs)
+ return True
+
+ def _track_template(self, changes):
+ return dict()
+
+ def message_track(self, tracked_fields, initial_values):
+ """ Track updated values. Comparing the initial and current values of
+ the fields given in tracked_fields, it generates a message containing
+ the updated values. This message can be linked to a mail.message.subtype
+ given by the ``_track_subtype`` method.
+
+ :param tracked_fields: iterable of field names to track
+ :param initial_values: mapping {record_id: {field_name: value}}
+ :return: mapping {record_id: (changed_field_names, tracking_value_ids)}
+ containing existing records only
+ """
+ if not tracked_fields:
+ return True
+
+ tracked_fields = self.fields_get(tracked_fields)
+ tracking = dict()
+ for record in self:
+ try:
+ tracking[record.id] = record._message_track(tracked_fields, initial_values[record.id])
+ except MissingError:
+ continue
+
+ for record in self:
+ changes, tracking_value_ids = tracking.get(record.id, (None, None))
+ if not changes:
+ continue
+
+ # find subtypes and post messages or log if no subtype found
+ subtype = False
+ # By passing this key, that allows to let the subtype empty and so don't sent email because partners_to_notify from mail_message._notify will be empty
+ if not self._context.get('mail_track_log_only'):
+ subtype = record._track_subtype(dict((col_name, initial_values[record.id][col_name]) for col_name in changes))
+ if subtype:
+ if not subtype.exists():
+ _logger.debug('subtype "%s" not found' % subtype.name)
+ continue
+ record.message_post(subtype_id=subtype.id, tracking_value_ids=tracking_value_ids)
+ elif tracking_value_ids:
+ record._message_log(tracking_value_ids=tracking_value_ids)
+
+ return tracking
+
+ def static_message_track(self, record, tracked_fields, initial):
+ """ Deprecated, remove in v14+ """
+ return record._mail_track(tracked_fields, initial)
+
+ def _message_track(self, tracked_fields, initial):
+ """ Moved to ``BaseModel._mail_track()`` """
+ return self._mail_track(tracked_fields, initial)
+
+ def _track_subtype(self, init_values):
+ """ Give the subtypes triggered by the changes on the record according
+ to values that have been updated.
+
+ :param init_values: the original values of the record; only modified fields
+ are present in the dict
+ :type init_values: dict
+ :returns: a subtype browse record or False if no subtype is trigerred
+ """
+ return False
+
+ # ------------------------------------------------------
+ # MAIL GATEWAY
+ # ------------------------------------------------------
+
+ def _routing_warn(self, error_message, message_id, route, raise_exception=True):
+ """ Tools method used in _routing_check_route: whether to log a warning or raise an error """
+ short_message = _("Mailbox unavailable - %s", error_message)
+ full_message = ('Routing mail with Message-Id %s: route %s: %s' %
+ (message_id, route, error_message))
+ _logger.info(full_message)
+ if raise_exception:
+ # sender should not see private diagnostics info, just the error
+ raise ValueError(short_message)
+
+ def _routing_create_bounce_email(self, email_from, body_html, message, **mail_values):
+ bounce_to = tools.decode_message_header(message, 'Return-Path') or email_from
+ bounce_mail_values = {
+ 'author_id': False,
+ 'body_html': body_html,
+ 'subject': 'Re: %s' % message.get('subject'),
+ 'email_to': bounce_to,
+ 'auto_delete': True,
+ }
+ bounce_from = self.env['ir.mail_server']._get_default_bounce_address()
+ if bounce_from:
+ bounce_mail_values['email_from'] = tools.formataddr(('MAILER-DAEMON', bounce_from))
+ elif self.env['ir.config_parameter'].sudo().get_param("mail.catchall.alias") not in message['To']:
+ bounce_mail_values['email_from'] = tools.decode_message_header(message, 'To')
+ else:
+ bounce_mail_values['email_from'] = tools.formataddr(('MAILER-DAEMON', self.env.user.email_normalized))
+ bounce_mail_values.update(mail_values)
+ self.env['mail.mail'].sudo().create(bounce_mail_values).send()
+
+ @api.model
+ def _routing_handle_bounce(self, email_message, message_dict):
+ """ Handle bounce of incoming email. Based on values of the bounce (email
+ and related partner, send message and its messageID)
+
+ * find blacklist-enabled records with email_normalized = bounced email
+ and call ``_message_receive_bounce`` on each of them to propagate
+ bounce information through various records linked to same email;
+ * if not already done (i.e. if original record is not blacklist enabled
+ like a bounce on an applicant), find record linked to bounced message
+ and call ``_message_receive_bounce``;
+
+ :param email_message: incoming email;
+ :type email_message: email.message;
+ :param message_dict: dictionary holding already-parsed values and in
+ which bounce-related values will be added;
+ :type message_dict: dictionary;
+ """
+ bounced_record, bounced_record_done = False, False
+ bounced_email, bounced_partner = message_dict['bounced_email'], message_dict['bounced_partner']
+ bounced_msg_id, bounced_message = message_dict['bounced_msg_id'], message_dict['bounced_message']
+
+ if bounced_email:
+ bounced_model, bounced_res_id = bounced_message.model, bounced_message.res_id
+
+ if bounced_model and bounced_model in self.env and bounced_res_id:
+ bounced_record = self.env[bounced_model].sudo().browse(bounced_res_id).exists()
+
+ bl_models = self.env['ir.model'].sudo().search(['&', ('is_mail_blacklist', '=', True), ('model', '!=', 'mail.thread.blacklist')])
+ for model in [bl_model for bl_model in bl_models if bl_model.model in self.env]: # transient test mode
+ rec_bounce_w_email = self.env[model.model].sudo().search([('email_normalized', '=', bounced_email)])
+ rec_bounce_w_email._message_receive_bounce(bounced_email, bounced_partner)
+ bounced_record_done = bounced_record_done or (bounced_record and model.model == bounced_model and bounced_record in rec_bounce_w_email)
+
+ # set record as bounced unless already done due to blacklist mixin
+ if bounced_record and not bounced_record_done and issubclass(type(bounced_record), self.pool['mail.thread']):
+ bounced_record._message_receive_bounce(bounced_email, bounced_partner)
+
+ if bounced_partner and bounced_message:
+ self.env['mail.notification'].sudo().search([
+ ('mail_message_id', '=', bounced_message.id),
+ ('res_partner_id', 'in', bounced_partner.ids)]
+ ).write({'notification_status': 'bounce'})
+
+ if bounced_record:
+ _logger.info('Routing mail from %s to %s with Message-Id %s: not routing bounce email from %s replying to %s (model %s ID %s)',
+ message_dict['email_from'], message_dict['to'], message_dict['message_id'], bounced_email, bounced_msg_id, bounced_model, bounced_res_id)
+ elif bounced_email:
+ _logger.info('Routing mail from %s to %s with Message-Id %s: not routing bounce email from %s replying to %s (no document found)',
+ message_dict['email_from'], message_dict['to'], message_dict['message_id'], bounced_email, bounced_msg_id)
+ else:
+ _logger.info('Routing mail from %s to %s with Message-Id %s: not routing bounce email.',
+ message_dict['email_from'], message_dict['to'], message_dict['message_id'])
+
+ @api.model
+ def _routing_check_route(self, message, message_dict, route, raise_exception=True):
+ """ Verify route validity. Check and rules:
+ 1 - if thread_id -> check that document effectively exists; otherwise
+ fallback on a message_new by resetting thread_id
+ 2 - check that message_update exists if thread_id is set; or at least
+ that message_new exist
+ 3 - if there is an alias, check alias_contact:
+ 'followers' and thread_id:
+ check on target document that the author is in the followers
+ 'followers' and alias_parent_thread_id:
+ check on alias parent document that the author is in the
+ followers
+ 'partners': check that author_id id set
+
+ :param message: an email.message instance
+ :param message_dict: dictionary of values that will be given to
+ mail_message.create()
+ :param route: route to check which is a tuple (model, thread_id,
+ custom_values, uid, alias)
+ :param raise_exception: if an error occurs, tell whether to raise an error
+ or just log a warning and try other processing or
+ invalidate route
+ """
+
+ assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
+ assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
+
+ message_id = message_dict['message_id']
+ email_from = message_dict['email_from']
+ author_id = message_dict.get('author_id')
+ model, thread_id, alias = route[0], route[1], route[4]
+ record_set = None
+
+ # Wrong model
+ if not model:
+ self._routing_warn(_('target model unspecified'), message_id, route, raise_exception)
+ return ()
+ elif model not in self.env:
+ self._routing_warn(_('unknown target model %s', model), message_id, route, raise_exception)
+ return ()
+ record_set = self.env[model].browse(thread_id) if thread_id else self.env[model]
+
+ # Existing Document: check if exists and model accepts the mailgateway; if not, fallback on create if allowed
+ if thread_id:
+ if not record_set.exists():
+ self._routing_warn(
+ _('reply to missing document (%(model)s,%(thread)s), fall back on document creation', model=model, thread=thread_id),
+ message_id,
+ route,
+ False
+ )
+ thread_id = None
+ elif not hasattr(record_set, 'message_update'):
+ self._routing_warn(_('reply to model %s that does not accept document update, fall back on document creation', model), message_id, route, False)
+ thread_id = None
+
+ # New Document: check model accepts the mailgateway
+ if not thread_id and model and not hasattr(record_set, 'message_new'):
+ self._routing_warn(_('model %s does not accept document creation', model), message_id, route, raise_exception)
+ return ()
+
+ # Update message author. We do it now because we need it for aliases (contact settings)
+ if not author_id:
+ if record_set:
+ authors = self._mail_find_partner_from_emails([email_from], records=record_set)
+ elif alias and alias.alias_parent_model_id and alias.alias_parent_thread_id:
+ records = self.env[alias.alias_parent_model_id.model].browse(alias.alias_parent_thread_id)
+ authors = self._mail_find_partner_from_emails([email_from], records=records)
+ else:
+ authors = self._mail_find_partner_from_emails([email_from], records=None)
+ if authors:
+ message_dict['author_id'] = authors[0].id
+
+ # Alias: check alias_contact settings
+ if alias:
+ if thread_id:
+ obj = record_set[0]
+ elif alias.alias_parent_model_id and alias.alias_parent_thread_id:
+ obj = self.env[alias.alias_parent_model_id.model].browse(alias.alias_parent_thread_id)
+ else:
+ obj = self.env[model]
+ error_message = obj._alias_get_error_message(message, message_dict, alias)
+ if error_message:
+ self._routing_warn(
+ _('alias %(name)s: %(error)s', name=alias.alias_name, error=error_message or _('unknown error')),
+ message_id,
+ route,
+ False
+ )
+ body = alias._get_alias_bounced_body(message_dict)
+ self._routing_create_bounce_email(email_from, body, message, references=message_id)
+ return False
+
+ return (model, thread_id, route[2], route[3], route[4])
+
+ @api.model
+ def _routing_reset_bounce(self, email_message, message_dict):
+ """Called by ``message_process`` when a new mail is received from an email address.
+ If the email is related to a partner, we consider that the number of message_bounce
+ is not relevant anymore as the email is valid - as we received an email from this
+ address. The model is here hardcoded because we cannot know with which model the
+ incomming mail match. We consider that if a mail arrives, we have to clear bounce for
+ each model having bounce count.
+
+ :param email_from: email address that sent the incoming email."""
+ valid_email = message_dict['email_from']
+ if valid_email:
+ bl_models = self.env['ir.model'].sudo().search(['&', ('is_mail_blacklist', '=', True), ('model', '!=', 'mail.thread.blacklist')])
+ for model in [bl_model for bl_model in bl_models if bl_model.model in self.env]: # transient test mode
+ self.env[model.model].sudo().search([('message_bounce', '>', 0), ('email_normalized', '=', valid_email)])._message_reset_bounce(valid_email)
+
+ @api.model
+ def message_route(self, message, message_dict, model=None, thread_id=None, custom_values=None):
+ """ Attempt to figure out the correct target model, thread_id,
+ custom_values and user_id to use for an incoming message.
+ Multiple values may be returned, if a message had multiple
+ recipients matching existing mail.aliases, for example.
+
+ The following heuristics are used, in this order:
+
+ * if the message replies to an existing thread by having a Message-Id
+ that matches an existing mail_message.message_id, we take the original
+ message model/thread_id pair and ignore custom_value as no creation will
+ take place;
+ * look for a mail.alias entry matching the message recipients and use the
+ corresponding model, thread_id, custom_values and user_id. This could
+ lead to a thread update or creation depending on the alias;
+ * fallback on provided ``model``, ``thread_id`` and ``custom_values``;
+ * raise an exception as no route has been found
+
+ :param string message: an email.message instance
+ :param dict message_dict: dictionary holding parsed message variables
+ :param string model: the fallback model to use if the message does not match
+ any of the currently configured mail aliases (may be None if a matching
+ alias is supposed to be present)
+ :type dict custom_values: optional dictionary of default field values
+ to pass to ``message_new`` if a new record needs to be created.
+ Ignored if the thread record already exists, and also if a matching
+ mail.alias was found (aliases define their own defaults)
+ :param int thread_id: optional ID of the record/thread from ``model`` to
+ which this mail should be attached. Only used if the message does not
+ reply to an existing thread and does not match any mail alias.
+ :return: list of routes [(model, thread_id, custom_values, user_id, alias)]
+
+ :raises: ValueError, TypeError
+ """
+ if not isinstance(message, EmailMessage):
+ raise TypeError('message must be an email.message.EmailMessage at this point')
+ catchall_alias = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.alias")
+ bounce_alias = self.env['ir.config_parameter'].sudo().get_param("mail.bounce.alias")
+ bounce_alias_static = tools.str2bool(self.env['ir.config_parameter'].sudo().get_param("mail.bounce.alias.static", "False"))
+ fallback_model = model
+
+ # get email.message.Message variables for future processing
+ local_hostname = socket.gethostname()
+ message_id = message_dict['message_id']
+
+ # compute references to find if message is a reply to an existing thread
+ thread_references = message_dict['references'] or message_dict['in_reply_to']
+ msg_references = [
+ re.sub(r'[\r\n\t ]+', r'', ref) # "Unfold" buggy references
+ for ref in tools.mail_header_msgid_re.findall(thread_references)
+ if 'reply_to' not in ref
+ ]
+ mail_messages = self.env['mail.message'].sudo().search([('message_id', 'in', msg_references)], limit=1, order='id desc, message_id')
+ is_a_reply = bool(mail_messages)
+ reply_model, reply_thread_id = mail_messages.model, mail_messages.res_id
+
+ # author and recipients
+ email_from = message_dict['email_from']
+ email_from_localpart = (tools.email_split(email_from) or [''])[0].split('@', 1)[0].lower()
+ email_to = message_dict['to']
+ email_to_localparts = [
+ e.split('@', 1)[0].lower()
+ for e in (tools.email_split(email_to) or [''])
+ ]
+ # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
+ # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
+ rcpt_tos_localparts = [
+ e.split('@')[0].lower()
+ for e in tools.email_split(message_dict['recipients'])
+ ]
+ rcpt_tos_valid_localparts = [to for to in rcpt_tos_localparts]
+
+ # 0. Handle bounce: verify whether this is a bounced email and use it to collect bounce data and update notifications for customers
+ # Bounce regex: typical form of bounce is bounce_alias+128-crm.lead-34@domain
+ # group(1) = the mail ID; group(2) = the model (if any); group(3) = the record ID
+ # Bounce message (not alias)
+ # See http://datatracker.ietf.org/doc/rfc3462/?include_text=1
+ # As all MTA does not respect this RFC (googlemail is one of them),
+ # we also need to verify if the message come from "mailer-daemon"
+ # If not a bounce: reset bounce information
+ if bounce_alias and any(email.startswith(bounce_alias) for email in email_to_localparts):
+ bounce_re = re.compile("%s\+(\d+)-?([\w.]+)?-?(\d+)?" % re.escape(bounce_alias), re.UNICODE)
+ bounce_match = bounce_re.search(email_to)
+ if bounce_match:
+ self._routing_handle_bounce(message, message_dict)
+ return []
+ if bounce_alias and bounce_alias_static and any(email == bounce_alias for email in email_to_localparts):
+ self._routing_handle_bounce(message, message_dict)
+ return []
+ if message.get_content_type() == 'multipart/report' or email_from_localpart == 'mailer-daemon':
+ self._routing_handle_bounce(message, message_dict)
+ return []
+ self._routing_reset_bounce(message, message_dict)
+
+ # 1. Handle reply
+ # if destination = alias with different model -> consider it is a forward and not a reply
+ # if destination = alias with same model -> check contact settings as they still apply
+ if reply_model and reply_thread_id:
+ other_model_aliases = self.env['mail.alias'].search([
+ '&', '&',
+ ('alias_name', '!=', False),
+ ('alias_name', 'in', email_to_localparts),
+ ('alias_model_id.model', '!=', reply_model),
+ ])
+ if other_model_aliases:
+ is_a_reply = False
+ rcpt_tos_valid_localparts = [to for to in rcpt_tos_valid_localparts if to in other_model_aliases.mapped('alias_name')]
+
+ if is_a_reply:
+ dest_aliases = self.env['mail.alias'].search([
+ ('alias_name', 'in', rcpt_tos_localparts),
+ ('alias_model_id.model', '=', reply_model)
+ ], limit=1)
+
+ user_id = self._mail_find_user_for_gateway(email_from, alias=dest_aliases).id or self._uid
+ route = self._routing_check_route(
+ message, message_dict,
+ (reply_model, reply_thread_id, custom_values, user_id, dest_aliases),
+ raise_exception=False)
+ if route:
+ _logger.info(
+ 'Routing mail from %s to %s with Message-Id %s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s',
+ email_from, email_to, message_id, reply_model, reply_thread_id, custom_values, self._uid)
+ return [route]
+ elif route is False:
+ return []
+
+ # 2. Handle new incoming email by checking aliases and applying their settings
+ if rcpt_tos_localparts:
+ # no route found for a matching reference (or reply), so parent is invalid
+ message_dict.pop('parent_id', None)
+
+ # check it does not directly contact catchall
+ if catchall_alias and email_to_localparts and all(email_localpart == catchall_alias for email_localpart in email_to_localparts):
+ _logger.info('Routing mail from %s to %s with Message-Id %s: direct write to catchall, bounce', email_from, email_to, message_id)
+ body = self.env.ref('mail.mail_bounce_catchall')._render({
+ 'message': message,
+ }, engine='ir.qweb')
+ self._routing_create_bounce_email(email_from, body, message, references=message_id, reply_to=self.env.company.email)
+ return []
+
+ dest_aliases = self.env['mail.alias'].search([('alias_name', 'in', rcpt_tos_valid_localparts)])
+ if dest_aliases:
+ routes = []
+ for alias in dest_aliases:
+ user_id = self._mail_find_user_for_gateway(email_from, alias=alias).id or self._uid
+ route = (alias.alias_model_id.model, alias.alias_force_thread_id, ast.literal_eval(alias.alias_defaults), user_id, alias)
+ route = self._routing_check_route(message, message_dict, route, raise_exception=True)
+ if route:
+ _logger.info(
+ 'Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
+ email_from, email_to, message_id, route)
+ routes.append(route)
+ return routes
+
+ # 3. Fallback to the provided parameters, if they work
+ if fallback_model:
+ # no route found for a matching reference (or reply), so parent is invalid
+ message_dict.pop('parent_id', None)
+ user_id = self._mail_find_user_for_gateway(email_from).id or self._uid
+ route = self._routing_check_route(
+ message, message_dict,
+ (fallback_model, thread_id, custom_values, user_id, None),
+ raise_exception=True)
+ if route:
+ _logger.info(
+ 'Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
+ email_from, email_to, message_id, fallback_model, thread_id, custom_values, user_id)
+ return [route]
+
+ # ValueError if no routes found and if no bounce occured
+ raise ValueError(
+ 'No possible route found for incoming message from %s to %s (Message-Id %s:). '
+ 'Create an appropriate mail.alias or force the destination model.' %
+ (email_from, email_to, message_id)
+ )
+
+ @api.model
+ def _message_route_process(self, message, message_dict, routes):
+ self = self.with_context(attachments_mime_plainxml=True) # import XML attachments as text
+ # postpone setting message_dict.partner_ids after message_post, to avoid double notifications
+ original_partner_ids = message_dict.pop('partner_ids', [])
+ thread_id = False
+ for model, thread_id, custom_values, user_id, alias in routes or ():
+ subtype_id = False
+ related_user = self.env['res.users'].browse(user_id)
+ Model = self.env[model].with_context(mail_create_nosubscribe=True, mail_create_nolog=True)
+ if not (thread_id and hasattr(Model, 'message_update') or hasattr(Model, 'message_new')):
+ raise ValueError(
+ "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" %
+ (message_dict['message_id'], model)
+ )
+
+ # disabled subscriptions during message_new/update to avoid having the system user running the
+ # email gateway become a follower of all inbound messages
+ ModelCtx = Model.with_user(related_user).sudo()
+ if thread_id and hasattr(ModelCtx, 'message_update'):
+ thread = ModelCtx.browse(thread_id)
+ thread.message_update(message_dict)
+ else:
+ # if a new thread is created, parent is irrelevant
+ message_dict.pop('parent_id', None)
+ thread = ModelCtx.message_new(message_dict, custom_values)
+ thread_id = thread.id
+ subtype_id = thread._creation_subtype().id
+
+ # replies to internal message are considered as notes, but parent message
+ # author is added in recipients to ensure he is notified of a private answer
+ parent_message = False
+ if message_dict.get('parent_id'):
+ parent_message = self.env['mail.message'].sudo().browse(message_dict['parent_id'])
+ partner_ids = []
+ if not subtype_id:
+ if message_dict.get('is_internal'):
+ subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note')
+ if parent_message and parent_message.author_id:
+ partner_ids = [parent_message.author_id.id]
+ else:
+ subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment')
+
+ post_params = dict(subtype_id=subtype_id, partner_ids=partner_ids, **message_dict)
+ # remove computational values not stored on mail.message and avoid warnings when creating it
+ for x in ('from', 'to', 'cc', 'recipients', 'references', 'in_reply_to', 'bounced_email', 'bounced_message', 'bounced_msg_id', 'bounced_partner'):
+ post_params.pop(x, None)
+ new_msg = False
+ if thread._name == 'mail.thread': # message with parent_id not linked to record
+ new_msg = thread.message_notify(**post_params)
+ else:
+ # parsing should find an author independently of user running mail gateway, and ensure it is not odoobot
+ partner_from_found = message_dict.get('author_id') and message_dict['author_id'] != self.env['ir.model.data'].xmlid_to_res_id('base.partner_root')
+ thread = thread.with_context(mail_create_nosubscribe=not partner_from_found)
+ new_msg = thread.message_post(**post_params)
+
+ if new_msg and original_partner_ids:
+ # postponed after message_post, because this is an external message and we don't want to create
+ # duplicate emails due to notifications
+ new_msg.write({'partner_ids': original_partner_ids})
+ return thread_id
+
+ @api.model
+ def message_process(self, model, message, custom_values=None,
+ save_original=False, strip_attachments=False,
+ thread_id=None):
+ """ Process an incoming RFC2822 email message, relying on
+ ``mail.message.parse()`` for the parsing operation,
+ and ``message_route()`` to figure out the target model.
+
+ Once the target model is known, its ``message_new`` method
+ is called with the new message (if the thread record did not exist)
+ or its ``message_update`` method (if it did).
+
+ :param string model: the fallback model to use if the message
+ does not match any of the currently configured mail aliases
+ (may be None if a matching alias is supposed to be present)
+ :param message: source of the RFC2822 message
+ :type message: string or xmlrpclib.Binary
+ :type dict custom_values: optional dictionary of field values
+ to pass to ``message_new`` if a new record needs to be created.
+ Ignored if the thread record already exists, and also if a
+ matching mail.alias was found (aliases define their own defaults)
+ :param bool save_original: whether to keep a copy of the original
+ email source attached to the message after it is imported.
+ :param bool strip_attachments: whether to strip all attachments
+ before processing the message, in order to save some space.
+ :param int thread_id: optional ID of the record/thread from ``model``
+ to which this mail should be attached. When provided, this
+ overrides the automatic detection based on the message
+ headers.
+ """
+ # extract message bytes - we are forced to pass the message as binary because
+ # we don't know its encoding until we parse its headers and hence can't
+ # convert it to utf-8 for transport between the mailgate script and here.
+ if isinstance(message, xmlrpclib.Binary):
+ message = bytes(message.data)
+ if isinstance(message, str):
+ message = message.encode('utf-8')
+ message = email.message_from_bytes(message, policy=email.policy.SMTP)
+
+ # parse the message, verify we are not in a loop by checking message_id is not duplicated
+ msg_dict = self.message_parse(message, save_original=save_original)
+ if strip_attachments:
+ msg_dict.pop('attachments', None)
+
+ existing_msg_ids = self.env['mail.message'].search([('message_id', '=', msg_dict['message_id'])], limit=1)
+ if existing_msg_ids:
+ _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
+ msg_dict.get('email_from'), msg_dict.get('to'), msg_dict.get('message_id'))
+ return False
+
+ # find possible routes for the message
+ routes = self.message_route(message, msg_dict, model, thread_id, custom_values)
+ thread_id = self._message_route_process(message, msg_dict, routes)
+ return thread_id
+
+ @api.model
+ def message_new(self, msg_dict, custom_values=None):
+ """Called by ``message_process`` when a new message is received
+ for a given thread model, if the message did not belong to
+ an existing thread.
+ The default behavior is to create a new record of the corresponding
+ model (based on some very basic info extracted from the message).
+ Additional behavior may be implemented by overriding this method.
+
+ :param dict msg_dict: a map containing the email details and
+ attachments. See ``message_process`` and
+ ``mail.message.parse`` for details.
+ :param dict custom_values: optional dictionary of additional
+ field values to pass to create()
+ when creating the new thread record.
+ Be careful, these values may override
+ any other values coming from the message.
+ :rtype: int
+ :return: the id of the newly created thread object
+ """
+ data = {}
+ if isinstance(custom_values, dict):
+ data = custom_values.copy()
+ fields = self.fields_get()
+ name_field = self._rec_name or 'name'
+ if name_field in fields and not data.get('name'):
+ data[name_field] = msg_dict.get('subject', '')
+ return self.create(data)
+
+ def message_update(self, msg_dict, update_vals=None):
+ """Called by ``message_process`` when a new message is received
+ for an existing thread. The default behavior is to update the record
+ with update_vals taken from the incoming email.
+ Additional behavior may be implemented by overriding this
+ method.
+ :param dict msg_dict: a map containing the email details and
+ attachments. See ``message_process`` and
+ ``mail.message.parse()`` for details.
+ :param dict update_vals: a dict containing values to update records
+ given their ids; if the dict is None or is
+ void, no write operation is performed.
+ """
+ if update_vals:
+ self.write(update_vals)
+ return True
+
+ def _message_receive_bounce(self, email, partner):
+ """Called by ``message_process`` when a bounce email (such as Undelivered
+ Mail Returned to Sender) is received for an existing thread. The default
+ behavior is to do nothing. This method is meant to be overridden in various
+ modules to add some specific behavior like blacklist management or mass
+ mailing statistics update. check is an integer ``message_bounce`` column exists.
+ If it is the case, its content is incremented.
+
+ :param string email: email that caused the bounce;
+ :param record partner: partner matching the bounced email address, if any;
+ """
+ pass
+
+ def _message_reset_bounce(self, email):
+ """Called by ``message_process`` when an email is considered as not being
+ a bounce. The default behavior is to do nothing. This method is meant to
+ be overridden in various modules to add some specific behavior like
+ blacklist management.
+
+ :param string email: email for which to reset bounce information
+ """
+ pass
+
+ def _message_parse_extract_payload_postprocess(self, message, payload_dict):
+ """ Perform some cleaning / postprocess in the body and attachments
+ extracted from the email. Note that this processing is specific to the
+ mail module, and should not contain security or generic html cleaning.
+ Indeed those aspects should be covered by the html_sanitize method
+ located in tools. """
+ body, attachments = payload_dict['body'], payload_dict['attachments']
+ if not body:
+ return payload_dict
+ try:
+ root = lxml.html.fromstring(body)
+ except ValueError:
+ # In case the email client sent XHTML, fromstring will fail because 'Unicode strings
+ # with encoding declaration are not supported'.
+ root = lxml.html.fromstring(body.encode('utf-8'))
+
+ postprocessed = False
+ to_remove = []
+ for node in root.iter():
+ if 'o_mail_notification' in (node.get('class') or '') or 'o_mail_notification' in (node.get('summary') or ''):
+ postprocessed = True
+ if node.getparent() is not None:
+ to_remove.append(node)
+ if node.tag == 'img' and node.get('src', '').startswith('cid:'):
+ cid = node.get('src').split(':', 1)[1]
+ related_attachment = [attach for attach in attachments if attach[2] and attach[2].get('cid') == cid]
+ if related_attachment:
+ node.set('data-filename', related_attachment[0][0])
+ postprocessed = True
+
+ for node in to_remove:
+ node.getparent().remove(node)
+ if postprocessed:
+ body = etree.tostring(root, pretty_print=False, encoding='unicode')
+ return {'body': body, 'attachments': attachments}
+
+ def _message_parse_extract_payload(self, message, save_original=False):
+ """Extract body as HTML and attachments from the mail message"""
+ attachments = []
+ body = u''
+ if save_original:
+ attachments.append(self._Attachment('original_email.eml', message.as_string(), {}))
+
+ # Be careful, content-type may contain tricky content like in the
+ # following example so test the MIME type with startswith()
+ #
+ # Content-Type: multipart/related;
+ # boundary="_004_3f1e4da175f349248b8d43cdeb9866f1AMSPR06MB343eurprd06pro_";
+ # type="text/html"
+ if message.get_content_maintype() == 'text':
+ encoding = message.get_content_charset()
+ body = message.get_content()
+ body = tools.ustr(body, encoding, errors='replace')
+ if message.get_content_type() == 'text/plain':
+ # text/plain -> <pre/>
+ body = tools.append_content_to_html(u'', body, preserve=True)
+ else:
+ alternative = False
+ mixed = False
+ html = u''
+ for part in message.walk():
+ if part.get_content_type() == 'multipart/alternative':
+ alternative = True
+ if part.get_content_type() == 'multipart/mixed':
+ mixed = True
+ if part.get_content_maintype() == 'multipart':
+ continue # skip container
+
+ filename = part.get_filename() # I may not properly handle all charsets
+ encoding = part.get_content_charset() # None if attachment
+
+ # 0) Inline Attachments -> attachments, with a third part in the tuple to match cid / attachment
+ if filename and part.get('content-id'):
+ inner_cid = part.get('content-id').strip('><')
+ attachments.append(self._Attachment(filename, part.get_content(), {'cid': inner_cid}))
+ continue
+ # 1) Explicit Attachments -> attachments
+ if filename or part.get('content-disposition', '').strip().startswith('attachment'):
+ attachments.append(self._Attachment(filename or 'attachment', part.get_content(), {}))
+ continue
+ # 2) text/plain -> <pre/>
+ if part.get_content_type() == 'text/plain' and (not alternative or not body):
+ body = tools.append_content_to_html(body, tools.ustr(part.get_content(),
+ encoding, errors='replace'), preserve=True)
+ # 3) text/html -> raw
+ elif part.get_content_type() == 'text/html':
+ # mutlipart/alternative have one text and a html part, keep only the second
+ # mixed allows several html parts, append html content
+ append_content = not alternative or (html and mixed)
+ html = tools.ustr(part.get_content(), encoding, errors='replace')
+ if not append_content:
+ body = html
+ else:
+ body = tools.append_content_to_html(body, html, plaintext=False)
+ # we only strip_classes here everything else will be done in by html field of mail.message
+ body = tools.html_sanitize(body, sanitize_tags=False, strip_classes=True)
+ # 4) Anything else -> attachment
+ else:
+ attachments.append(self._Attachment(filename or 'attachment', part.get_content(), {}))
+
+ return self._message_parse_extract_payload_postprocess(message, {'body': body, 'attachments': attachments})
+
+ def _message_parse_extract_bounce(self, email_message, message_dict):
+ """ Parse email and extract bounce information to be used in future
+ processing.
+
+ :param email_message: an email.message instance;
+ :param message_dict: dictionary holding already-parsed values;
+
+ :return dict: bounce-related values will be added, containing
+
+ * bounced_email: email that bounced (normalized);
+ * bounce_partner: res.partner recordset whose email_normalized =
+ bounced_email;
+ * bounced_msg_id: list of message_ID references (<...@myserver>) linked
+ to the email that bounced;
+ * bounced_message: if found, mail.message recordset matching bounced_msg_id;
+ """
+ if not isinstance(email_message, EmailMessage):
+ raise TypeError('message must be an email.message.EmailMessage at this point')
+
+ email_part = next((part for part in email_message.walk() if part.get_content_type() in {'message/rfc822', 'text/rfc822-headers'}), None)
+ dsn_part = next((part for part in email_message.walk() if part.get_content_type() == 'message/delivery-status'), None)
+
+ bounced_email = False
+ bounced_partner = self.env['res.partner'].sudo()
+ if dsn_part and len(dsn_part.get_payload()) > 1:
+ dsn = dsn_part.get_payload()[1]
+ final_recipient_data = tools.decode_message_header(dsn, 'Final-Recipient')
+ bounced_email = tools.email_normalize(final_recipient_data.split(';', 1)[1].strip())
+ if bounced_email:
+ bounced_partner = self.env['res.partner'].sudo().search([('email_normalized', '=', bounced_email)])
+
+ bounced_msg_id = False
+ bounced_message = self.env['mail.message'].sudo()
+ if email_part:
+ if email_part.get_content_type() == 'text/rfc822-headers':
+ # Convert the message body into a message itself
+ email_payload = message_from_string(email_part.get_payload(), policy=policy.SMTP)
+ else:
+ email_payload = email_part.get_payload()[0]
+ bounced_msg_id = tools.mail_header_msgid_re.findall(tools.decode_message_header(email_payload, 'Message-Id'))
+ if bounced_msg_id:
+ bounced_message = self.env['mail.message'].sudo().search([('message_id', 'in', bounced_msg_id)])
+
+ return {
+ 'bounced_email': bounced_email,
+ 'bounced_partner': bounced_partner,
+ 'bounced_msg_id': bounced_msg_id,
+ 'bounced_message': bounced_message,
+ }
+
+ @api.model
+ def message_parse(self, message, save_original=False):
+ """ Parses an email.message.Message representing an RFC-2822 email
+ and returns a generic dict holding the message details.
+
+ :param message: email to parse
+ :type message: email.message.Message
+ :param bool save_original: whether the returned dict should include
+ an ``original`` attachment containing the source of the message
+ :rtype: dict
+ :return: A dict with the following structure, where each field may not
+ be present if missing in original message::
+
+ { 'message_id': msg_id,
+ 'subject': subject,
+ 'email_from': from,
+ 'to': to + delivered-to,
+ 'cc': cc,
+ 'recipients': delivered-to + to + cc + resent-to + resent-cc,
+ 'partner_ids': partners found based on recipients emails,
+ 'body': unified_body,
+ 'references': references,
+ 'in_reply_to': in-reply-to,
+ 'parent_id': parent mail.message based on in_reply_to or references,
+ 'is_internal': answer to an internal message (note),
+ 'date': date,
+ 'attachments': [('file1', 'bytes'),
+ ('file2', 'bytes')}
+ }
+ """
+ if not isinstance(message, EmailMessage):
+ raise ValueError(_('Message should be a valid EmailMessage instance'))
+ msg_dict = {'message_type': 'email'}
+
+ message_id = message.get('Message-Id')
+ if not message_id:
+ # Very unusual situation, be we should be fault-tolerant here
+ message_id = "<%s@localhost>" % time.time()
+ _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
+ msg_dict['message_id'] = message_id.strip()
+
+ if message.get('Subject'):
+ msg_dict['subject'] = tools.decode_message_header(message, 'Subject')
+
+ email_from = tools.decode_message_header(message, 'From')
+ email_cc = tools.decode_message_header(message, 'cc')
+ email_from_list = tools.email_split_and_format(email_from)
+ email_cc_list = tools.email_split_and_format(email_cc)
+ msg_dict['email_from'] = email_from_list[0] if email_from_list else email_from
+ msg_dict['from'] = msg_dict['email_from'] # compatibility for message_new
+ msg_dict['cc'] = ','.join(email_cc_list) if email_cc_list else email_cc
+ # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
+ # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
+ msg_dict['recipients'] = ','.join(set(formatted_email
+ for address in [
+ tools.decode_message_header(message, 'Delivered-To'),
+ tools.decode_message_header(message, 'To'),
+ tools.decode_message_header(message, 'Cc'),
+ tools.decode_message_header(message, 'Resent-To'),
+ tools.decode_message_header(message, 'Resent-Cc')
+ ] if address
+ for formatted_email in tools.email_split_and_format(address))
+ )
+ msg_dict['to'] = ','.join(set(formatted_email
+ for address in [
+ tools.decode_message_header(message, 'Delivered-To'),
+ tools.decode_message_header(message, 'To')
+ ] if address
+ for formatted_email in tools.email_split_and_format(address))
+ )
+ partner_ids = [x.id for x in self._mail_find_partner_from_emails(tools.email_split(msg_dict['recipients']), records=self) if x]
+ msg_dict['partner_ids'] = partner_ids
+ # compute references to find if email_message is a reply to an existing thread
+ msg_dict['references'] = tools.decode_message_header(message, 'References')
+ msg_dict['in_reply_to'] = tools.decode_message_header(message, 'In-Reply-To').strip()
+
+ if message.get('Date'):
+ try:
+ date_hdr = tools.decode_message_header(message, 'Date')
+ parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
+ if parsed_date.utcoffset() is None:
+ # naive datetime, so we arbitrarily decide to make it
+ # UTC, there's no better choice. Should not happen,
+ # as RFC2822 requires timezone offset in Date headers.
+ stored_date = parsed_date.replace(tzinfo=pytz.utc)
+ else:
+ stored_date = parsed_date.astimezone(tz=pytz.utc)
+ except Exception:
+ _logger.info('Failed to parse Date header %r in incoming mail '
+ 'with message-id %r, assuming current date/time.',
+ message.get('Date'), message_id)
+ stored_date = datetime.datetime.now()
+ msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
+
+ parent_ids = False
+ if msg_dict['in_reply_to']:
+ parent_ids = self.env['mail.message'].search([('message_id', '=', msg_dict['in_reply_to'])], limit=1)
+ if msg_dict['references'] and not parent_ids:
+ references_msg_id_list = tools.mail_header_msgid_re.findall(msg_dict['references'])
+ parent_ids = self.env['mail.message'].search([('message_id', 'in', [x.strip() for x in references_msg_id_list])], limit=1)
+ if parent_ids:
+ msg_dict['parent_id'] = parent_ids.id
+ msg_dict['is_internal'] = parent_ids.subtype_id and parent_ids.subtype_id.internal or False
+
+ msg_dict.update(self._message_parse_extract_payload(message, save_original=save_original))
+ msg_dict.update(self._message_parse_extract_bounce(message, msg_dict))
+ return msg_dict
+
+ # ------------------------------------------------------
+ # RECIPIENTS MANAGEMENT TOOLS
+ # ------------------------------------------------------
+
+ @api.model
+ def _message_get_default_recipients_on_records(self, records):
+ """ Moved to ``BaseModel._message_get_default_recipients()`` """
+ return records._message_get_default_recipients()
+
+ def _message_add_suggested_recipient(self, result, partner=None, email=None, reason=''):
+ """ Called by _message_get_suggested_recipients, to add a suggested
+ recipient in the result dictionary. The form is :
+ partner_id, partner_name<partner_email> or partner_name, reason """
+ self.ensure_one()
+ if email and not partner:
+ # get partner info from email
+ partner_info = self._message_partner_info_from_emails([email])[0]
+ if partner_info.get('partner_id'):
+ partner = self.env['res.partner'].sudo().browse([partner_info['partner_id']])[0]
+ if email and email in [val[1] for val in result[self.ids[0]]]: # already existing email -> skip
+ return result
+ if partner and partner in self.message_partner_ids: # recipient already in the followers -> skip
+ return result
+ if partner and partner.id in [val[0] for val in result[self.ids[0]]]: # already existing partner ID -> skip
+ return result
+ if partner and partner.email: # complete profile: id, name <email>
+ result[self.ids[0]].append((partner.id, partner.email_formatted, reason))
+ elif partner: # incomplete profile: id, name
+ result[self.ids[0]].append((partner.id, '%s' % (partner.name), reason))
+ else: # unknown partner, we are probably managing an email address
+ result[self.ids[0]].append((False, email, reason))
+ return result
+
+ def _message_get_suggested_recipients(self):
+ """ Returns suggested recipients for ids. Those are a list of
+ tuple (partner_id, partner_name, reason), to be managed by Chatter. """
+ result = dict((res_id, []) for res_id in self.ids)
+ if 'user_id' in self._fields:
+ for obj in self.sudo(): # SUPERUSER because of a read on res.users that would crash otherwise
+ if not obj.user_id or not obj.user_id.partner_id:
+ continue
+ obj._message_add_suggested_recipient(result, partner=obj.user_id.partner_id, reason=self._fields['user_id'].string)
+ return result
+
+ def _mail_search_on_user(self, normalized_emails, extra_domain=False):
+ """ Find partners linked to users, given an email address that will
+ be normalized. Search is done as sudo on res.users model to avoid domain
+ on partner like ('user_ids', '!=', False) that would not be efficient. """
+ domain = [('email_normalized', 'in', normalized_emails)]
+ if extra_domain:
+ domain = expression.AND([domain, extra_domain])
+ partners = self.env['res.users'].sudo().search(domain, order='name ASC').mapped('partner_id')
+ # return a search on partner to filter results current user should not see (multi company for example)
+ return self.env['res.partner'].search([('id', 'in', partners.ids)])
+
+ def _mail_search_on_partner(self, normalized_emails, extra_domain=False):
+ domain = [('email_normalized', 'in', normalized_emails)]
+ if extra_domain:
+ domain = expression.AND([domain, extra_domain])
+ return self.env['res.partner'].search(domain)
+
+ def _mail_find_user_for_gateway(self, email, alias=None):
+ """ Utility method to find user from email address that can create documents
+ in the target model. Purpose is to link document creation to users whenever
+ possible, for example when creating document through mailgateway.
+
+ Heuristic
+
+ * alias owner record: fetch in its followers for user with matching email;
+ * find any user with matching emails;
+ * try alias owner as fallback;
+
+ Note that standard search order is applied.
+
+ :param str email: will be sanitized and parsed to find email;
+ :param mail.alias alias: optional alias. Used to fetch owner followers
+ or fallback user (alias owner);
+ :param fallback_model: if not alias, related model to check access rights;
+
+ :return res.user user: user matching email or void recordset if none found
+ """
+ # find normalized emails and exclude aliases (to avoid subscribing alias emails to records)
+ normalized_email = tools.email_normalize(email)
+ catchall_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain")
+ if normalized_email and catchall_domain:
+ left_part = normalized_email.split('@')[0] if normalized_email.split('@')[1] == catchall_domain.lower() else False
+ if left_part:
+ if self.env['mail.alias'].sudo().search_count([('alias_name', '=', left_part)]):
+ return self.env['res.users']
+
+ if alias and alias.alias_parent_model_id and alias.alias_parent_thread_id:
+ followers = self.env['mail.followers'].search([
+ ('res_model', '=', alias.alias_parent_model_id.model),
+ ('res_id', '=', alias.alias_parent_thread_id)]
+ ).mapped('partner_id')
+ else:
+ followers = self.env['res.partner']
+
+ follower_users = self.env['res.users'].search([
+ ('partner_id', 'in', followers.ids), ('email_normalized', '=', normalized_email)
+ ], limit=1) if followers else self.env['res.users']
+ matching_user = follower_users[0] if follower_users else self.env['res.users']
+ if matching_user:
+ return matching_user
+
+ if not matching_user:
+ std_users = self.env['res.users'].sudo().search([('email_normalized', '=', normalized_email)], limit=1, order='name ASC')
+ matching_user = std_users[0] if std_users else self.env['res.users']
+ if matching_user:
+ return matching_user
+
+ if not matching_user and alias and alias.alias_user_id:
+ matching_user = alias and alias.alias_user_id
+ if matching_user:
+ return matching_user
+
+ return matching_user
+
+ @api.model
+ def _mail_find_partner_from_emails(self, emails, records=None, force_create=False):
+ """ Utility method to find partners from email addresses. If no partner is
+ found, create new partners if force_create is enabled. Search heuristics
+
+ * 1: check in records (record set) followers if records is mail.thread
+ enabled and if check_followers parameter is enabled;
+ * 2: search for partners with user;
+ * 3: search for partners;
+
+ :param records: record set on which to check followers;
+ :param list emails: list of email addresses for finding partner;
+ :param boolean force_create: create a new partner if not found
+
+ :return list partners: a list of partner records ordered as given emails.
+ If no partner has been found and/or created for a given emails its
+ matching partner is an empty record.
+ """
+ if records and issubclass(type(records), self.pool['mail.thread']):
+ followers = records.mapped('message_partner_ids')
+ else:
+ followers = self.env['res.partner']
+ catchall_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain")
+
+ # first, build a normalized email list and remove those linked to aliases to avoid adding aliases as partners
+ normalized_emails = [tools.email_normalize(contact) for contact in emails if tools.email_normalize(contact)]
+ if catchall_domain:
+ domain_left_parts = [email.split('@')[0] for email in normalized_emails if email and email.split('@')[1] == catchall_domain.lower()]
+ if domain_left_parts:
+ found_alias_names = self.env['mail.alias'].sudo().search([('alias_name', 'in', domain_left_parts)]).mapped('alias_name')
+ normalized_emails = [email for email in normalized_emails if email.split('@')[0] not in found_alias_names]
+
+ done_partners = [follower for follower in followers if follower.email_normalized in normalized_emails]
+ remaining = [email for email in normalized_emails if email not in [partner.email_normalized for partner in done_partners]]
+
+ user_partners = self._mail_search_on_user(remaining)
+ done_partners += [user_partner for user_partner in user_partners]
+ remaining = [email for email in normalized_emails if email not in [partner.email_normalized for partner in done_partners]]
+
+ partners = self._mail_search_on_partner(remaining)
+ done_partners += [partner for partner in partners]
+ remaining = [email for email in normalized_emails if email not in [partner.email_normalized for partner in done_partners]]
+
+ # iterate and keep ordering
+ partners = []
+ for contact in emails:
+ normalized_email = tools.email_normalize(contact)
+ partner = next((partner for partner in done_partners if partner.email_normalized == normalized_email), self.env['res.partner'])
+ if not partner and force_create and normalized_email in normalized_emails:
+ partner = self.env['res.partner'].browse(self.env['res.partner'].name_create(contact)[0])
+ partners.append(partner)
+ return partners
+
+ def _message_partner_info_from_emails(self, emails, link_mail=False):
+ """ Convert a list of emails into a list partner_ids and a list
+ new_partner_ids. The return value is non conventional because
+ it is meant to be used by the mail widget.
+
+ :return dict: partner_ids and new_partner_ids """
+ self.ensure_one()
+ MailMessage = self.env['mail.message'].sudo()
+ partners = self._mail_find_partner_from_emails(emails, records=self)
+ result = list()
+ for idx, contact in enumerate(emails):
+ partner = partners[idx]
+ partner_info = {'full_name': partner.email_formatted if partner else contact, 'partner_id': partner.id}
+ result.append(partner_info)
+ # link mail with this from mail to the new partner id
+ if link_mail and partner:
+ MailMessage.search([
+ ('email_from', '=ilike', partner.email_normalized),
+ ('author_id', '=', False)
+ ]).write({'author_id': partner.id})
+ return result
+
+ # ------------------------------------------------------
+ # MESSAGE POST API
+ # ------------------------------------------------------
+
+ def _message_post_process_attachments(self, attachments, attachment_ids, message_values):
+ """ Preprocess attachments for mail_thread.message_post() or mail_mail.create().
+
+ :param list attachments: list of attachment tuples in the form ``(name,content)``, #todo xdo update that
+ where content is NOT base64 encoded
+ :param list attachment_ids: a list of attachment ids, not in tomany command form
+ :param dict message_data: model: the model of the attachments parent record,
+ res_id: the id of the attachments parent record
+ """
+ return_values = {}
+ body = message_values.get('body')
+ model = message_values['model']
+ res_id = message_values['res_id']
+
+ m2m_attachment_ids = []
+ if attachment_ids:
+ # taking advantage of cache looks better in this case, to check
+ filtered_attachment_ids = self.env['ir.attachment'].sudo().browse(attachment_ids).filtered(
+ lambda a: a.res_model == 'mail.compose.message' and a.create_uid.id == self._uid)
+ # update filtered (pending) attachments to link them to the proper record
+ if filtered_attachment_ids:
+ filtered_attachment_ids.write({'res_model': model, 'res_id': res_id})
+ # prevent public and portal users from using attachments that are not theirs
+ if not self.env.user.has_group('base.group_user'):
+ attachment_ids = filtered_attachment_ids.ids
+
+ m2m_attachment_ids += [(4, id) for id in attachment_ids]
+ # Handle attachments parameter, that is a dictionary of attachments
+
+ if attachments: # generate
+ cids_in_body = set()
+ names_in_body = set()
+ cid_list = []
+ name_list = []
+
+ if body:
+ root = lxml.html.fromstring(tools.ustr(body))
+ # first list all attachments that will be needed in body
+ for node in root.iter('img'):
+ if node.get('src', '').startswith('cid:'):
+ cids_in_body.add(node.get('src').split('cid:')[1])
+ elif node.get('data-filename'):
+ names_in_body.add(node.get('data-filename'))
+ attachement_values_list = []
+
+ # generate values
+ for attachment in attachments:
+ cid = False
+ if len(attachment) == 2:
+ name, content = attachment
+ elif len(attachment) == 3:
+ name, content, info = attachment
+ cid = info and info.get('cid')
+ else:
+ continue
+ if isinstance(content, str):
+ content = content.encode('utf-8')
+ elif isinstance(content, EmailMessage):
+ content = content.as_bytes()
+ elif content is None:
+ continue
+ attachement_values= {
+ 'name': name,
+ 'datas': base64.b64encode(content),
+ 'type': 'binary',
+ 'description': name,
+ 'res_model': model,
+ 'res_id': res_id,
+ }
+ if body and (cid and cid in cids_in_body or name in names_in_body):
+ attachement_values['access_token'] = self.env['ir.attachment']._generate_access_token()
+ attachement_values_list.append(attachement_values)
+ # keep cid and name list synced with attachement_values_list length to match ids latter
+ cid_list.append(cid)
+ name_list.append(name)
+ new_attachments = self.env['ir.attachment'].create(attachement_values_list)
+ cid_mapping = {}
+ name_mapping = {}
+ for counter, new_attachment in enumerate(new_attachments):
+ cid = cid_list[counter]
+ if 'access_token' in attachement_values_list[counter]:
+ if cid:
+ cid_mapping[cid] = (new_attachment.id, attachement_values_list[counter]['access_token'])
+ name = name_list[counter]
+ name_mapping[name] = (new_attachment.id, attachement_values_list[counter]['access_token'])
+ m2m_attachment_ids.append((4, new_attachment.id))
+
+ # note: right know we are only taking attachments and ignoring attachment_ids.
+ if (cid_mapping or name_mapping) and body:
+ postprocessed = False
+ for node in root.iter('img'):
+ attachment_data = False
+ if node.get('src', '').startswith('cid:'):
+ cid = node.get('src').split('cid:')[1]
+ attachment_data = cid_mapping.get(cid)
+ if not attachment_data and node.get('data-filename'):
+ attachment_data = name_mapping.get(node.get('data-filename'), False)
+ if attachment_data:
+ node.set('src', '/web/image/%s?access_token=%s' % attachment_data)
+ postprocessed = True
+ if postprocessed:
+ return_values['body'] = lxml.html.tostring(root, pretty_print=False, encoding='UTF-8')
+ return_values['attachment_ids'] = m2m_attachment_ids
+ return return_values
+
+ @api.returns('mail.message', lambda value: value.id)
+ def message_post(self, *,
+ body='', subject=None, message_type='notification',
+ email_from=None, author_id=None, parent_id=False,
+ subtype_xmlid=None, subtype_id=False, partner_ids=None, channel_ids=None,
+ attachments=None, attachment_ids=None,
+ add_sign=True, record_name=False,
+ **kwargs):
+ """ Post a new message in an existing thread, returning the new
+ mail.message ID.
+ :param str body: body of the message, usually raw HTML that will
+ be sanitized
+ :param str subject: subject of the message
+ :param str message_type: see mail_message.message_type field. Can be anything but
+ user_notification, reserved for message_notify
+ :param int parent_id: handle thread formation
+ :param int subtype_id: subtype_id of the message, mainly use fore
+ followers mechanism
+ :param list(int) partner_ids: partner_ids to notify
+ :param list(int) channel_ids: channel_ids to notify
+ :param list(tuple(str,str), tuple(str,str, dict) or int) attachments : list of attachment tuples in the form
+ ``(name,content)`` or ``(name,content, info)``, where content is NOT base64 encoded
+ :param list id attachment_ids: list of existing attachement to link to this message
+ -Should only be setted by chatter
+ -Attachement object attached to mail.compose.message(0) will be attached
+ to the related document.
+ Extra keyword arguments will be used as default column values for the
+ new mail.message record.
+ :return int: ID of newly created mail.message
+ """
+ self.ensure_one() # should always be posted on a record, use message_notify if no record
+ # split message additional values from notify additional values
+ msg_kwargs = dict((key, val) for key, val in kwargs.items() if key in self.env['mail.message']._fields)
+ notif_kwargs = dict((key, val) for key, val in kwargs.items() if key not in msg_kwargs)
+
+ if self._name == 'mail.thread' or not self.id or message_type == 'user_notification':
+ raise ValueError('message_post should only be call to post message on record. Use message_notify instead')
+
+ if 'model' in msg_kwargs or 'res_id' in msg_kwargs:
+ raise ValueError("message_post doesn't support model and res_id parameters anymore. Please call message_post on record.")
+ if 'subtype' in kwargs:
+ raise ValueError("message_post doesn't support subtype parameter anymore. Please give a valid subtype_id or subtype_xmlid value instead.")
+
+ self = self._fallback_lang() # add lang to context imediatly since it will be usefull in various flows latter.
+
+ # Explicit access rights check, because display_name is computed as sudo.
+ self.check_access_rights('read')
+ self.check_access_rule('read')
+ record_name = record_name or self.display_name
+
+ partner_ids = set(partner_ids or [])
+ channel_ids = set(channel_ids or [])
+
+ if any(not isinstance(pc_id, int) for pc_id in partner_ids | channel_ids):
+ raise ValueError('message_post partner_ids and channel_ids must be integer list, not commands')
+
+ # Find the message's author
+ author_id, email_from = self._message_compute_author(author_id, email_from, raise_exception=True)
+
+ if subtype_xmlid:
+ subtype_id = self.env['ir.model.data'].xmlid_to_res_id(subtype_xmlid)
+ if not subtype_id:
+ subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note')
+
+ # automatically subscribe recipients if asked to
+ if self._context.get('mail_post_autofollow') and partner_ids:
+ self.message_subscribe(list(partner_ids))
+
+ MailMessage_sudo = self.env['mail.message'].sudo()
+ if self._mail_flat_thread and not parent_id:
+ parent_message = MailMessage_sudo.search([('res_id', '=', self.id), ('model', '=', self._name), ('message_type', '!=', 'user_notification')], order="id ASC", limit=1)
+ # parent_message searched in sudo for performance, only used for id.
+ # Note that with sudo we will match message with internal subtypes.
+ parent_id = parent_message.id if parent_message else False
+ elif parent_id:
+ old_parent_id = parent_id
+ parent_message = MailMessage_sudo.search([('id', '=', parent_id), ('parent_id', '!=', False)], limit=1)
+ # avoid loops when finding ancestors
+ processed_list = []
+ if parent_message:
+ new_parent_id = parent_message.parent_id and parent_message.parent_id.id
+ while (new_parent_id and new_parent_id not in processed_list):
+ processed_list.append(new_parent_id)
+ parent_message = parent_message.parent_id
+ parent_id = parent_message.id
+
+ values = dict(msg_kwargs)
+ values.update({
+ 'author_id': author_id,
+ 'email_from': email_from,
+ 'model': self._name,
+ 'res_id': self.id,
+ 'body': body,
+ 'subject': subject or False,
+ 'message_type': message_type,
+ 'parent_id': parent_id,
+ 'subtype_id': subtype_id,
+ 'partner_ids': partner_ids,
+ 'channel_ids': channel_ids,
+ 'add_sign': add_sign,
+ 'record_name': record_name,
+ })
+ attachments = attachments or []
+ attachment_ids = attachment_ids or []
+ attachement_values = self._message_post_process_attachments(attachments, attachment_ids, values)
+ values.update(attachement_values) # attachement_ids, [body]
+
+ new_message = self._message_create(values)
+
+ # Set main attachment field if necessary
+ self._message_set_main_attachment_id(values['attachment_ids'])
+
+ if values['author_id'] and values['message_type'] != 'notification' and not self._context.get('mail_create_nosubscribe'):
+ if self.env['res.partner'].browse(values['author_id']).active: # we dont want to add odoobot/inactive as a follower
+ self._message_subscribe([values['author_id']])
+
+ self._message_post_after_hook(new_message, values)
+ self._notify_thread(new_message, values, **notif_kwargs)
+ return new_message
+
+ def _message_set_main_attachment_id(self, attachment_ids): # todo move this out of mail.thread
+ if not self._abstract and attachment_ids and not self.message_main_attachment_id:
+ all_attachments = self.env['ir.attachment'].browse([attachment_tuple[1] for attachment_tuple in attachment_ids])
+ prioritary_attachments = all_attachments.filtered(lambda x: x.mimetype.endswith('pdf')) \
+ or all_attachments.filtered(lambda x: x.mimetype.startswith('image')) \
+ or all_attachments
+ self.sudo().with_context(tracking_disable=True).write({'message_main_attachment_id': prioritary_attachments[0].id})
+
+ def _message_post_after_hook(self, message, msg_vals):
+ """ Hook to add custom behavior after having posted the message. Both
+ message and computed value are given, to try to lessen query count by
+ using already-computed values instead of having to rebrowse things. """
+ pass
+
+ # ------------------------------------------------------
+ # MESSAGE POST TOOLS
+ # ------------------------------------------------------
+
+ def message_post_with_view(self, views_or_xmlid, **kwargs):
+ """ Helper method to send a mail / post a message using a view_id to
+ render using the ir.qweb engine. This method is stand alone, because
+ there is nothing in template and composer that allows to handle
+ views in batch. This method should probably disappear when templates
+ handle ir ui views. """
+ values = kwargs.pop('values', None) or dict()
+ try:
+ from odoo.addons.http_routing.models.ir_http import slug
+ values['slug'] = slug
+ except ImportError:
+ values['slug'] = lambda self: self.id
+ if isinstance(views_or_xmlid, str):
+ views = self.env.ref(views_or_xmlid, raise_if_not_found=False)
+ else:
+ views = views_or_xmlid
+ if not views:
+ return
+ for record in self:
+ values['object'] = record
+ rendered_template = views._render(values, engine='ir.qweb', minimal_qcontext=True)
+ kwargs['body'] = rendered_template
+ record.message_post_with_template(False, **kwargs)
+
+ def message_post_with_template(self, template_id, email_layout_xmlid=None, auto_commit=False, **kwargs):
+ """ Helper method to send a mail with a template
+ :param template_id : the id of the template to render to create the body of the message
+ :param **kwargs : parameter to create a mail.compose.message woaerd (which inherit from mail.message)
+ """
+ # Get composition mode, or force it according to the number of record in self
+ if not kwargs.get('composition_mode'):
+ kwargs['composition_mode'] = 'comment' if len(self.ids) == 1 else 'mass_mail'
+ if not kwargs.get('message_type'):
+ kwargs['message_type'] = 'notification'
+ res_id = kwargs.get('res_id', self.ids and self.ids[0] or 0)
+ res_ids = kwargs.get('res_id') and [kwargs['res_id']] or self.ids
+
+ # Create the composer
+ composer = self.env['mail.compose.message'].with_context(
+ active_id=res_id,
+ active_ids=res_ids,
+ active_model=kwargs.get('model', self._name),
+ default_composition_mode=kwargs['composition_mode'],
+ default_model=kwargs.get('model', self._name),
+ default_res_id=res_id,
+ default_template_id=template_id,
+ custom_layout=email_layout_xmlid,
+ ).create(kwargs)
+ # Simulate the onchange (like trigger in form the view) only
+ # when having a template in single-email mode
+ if template_id:
+ update_values = composer.onchange_template_id(template_id, kwargs['composition_mode'], self._name, res_id)['value']
+ composer.write(update_values)
+ return composer.send_mail(auto_commit=auto_commit)
+
+ def message_notify(self, *,
+ partner_ids=False, parent_id=False, model=False, res_id=False,
+ author_id=None, email_from=None, body='', subject=False, **kwargs):
+ """ Shortcut allowing to notify partners of messages that shouldn't be
+ displayed on a document. It pushes notifications on inbox or by email depending
+ on the user configuration, like other notifications. """
+ if self:
+ self.ensure_one()
+ # split message additional values from notify additional values
+ msg_kwargs = dict((key, val) for key, val in kwargs.items() if key in self.env['mail.message']._fields)
+ notif_kwargs = dict((key, val) for key, val in kwargs.items() if key not in msg_kwargs)
+
+ author_id, email_from = self._message_compute_author(author_id, email_from, raise_exception=True)
+
+ if not partner_ids:
+ _logger.warning('Message notify called without recipient_ids, skipping')
+ return self.env['mail.message']
+
+ if not (model and res_id): # both value should be set or none should be set (record)
+ model = False
+ res_id = False
+
+ MailThread = self.env['mail.thread']
+ values = {
+ 'parent_id': parent_id,
+ 'model': self._name if self else False,
+ 'res_id': self.id if self else False,
+ 'message_type': 'user_notification',
+ 'subject': subject,
+ 'body': body,
+ 'author_id': author_id,
+ 'email_from': email_from,
+ 'partner_ids': partner_ids,
+ 'subtype_id': self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note'),
+ 'is_internal': True,
+ 'record_name': False,
+ 'reply_to': MailThread._notify_get_reply_to(default=email_from, records=None)[False],
+ 'message_id': tools.generate_tracking_message_id('message-notify'),
+ }
+ values.update(msg_kwargs)
+ new_message = MailThread._message_create(values)
+ MailThread._notify_thread(new_message, values, **notif_kwargs)
+ return new_message
+
+ def _message_log(self, *, body='', author_id=None, email_from=None, subject=False, message_type='notification', **kwargs):
+ """ Shortcut allowing to post note on a document. It does not perform
+ any notification and pre-computes some values to have a short code
+ as optimized as possible. This method is private as it does not check
+ access rights and perform the message creation as sudo to speedup
+ the log process. This method should be called within methods where
+ access rights are already granted to avoid privilege escalation. """
+ self.ensure_one()
+ author_id, email_from = self._message_compute_author(author_id, email_from, raise_exception=False)
+
+ message_values = {
+ 'subject': subject,
+ 'body': body,
+ 'author_id': author_id,
+ 'email_from': email_from,
+ 'message_type': message_type,
+ 'model': kwargs.get('model', self._name),
+ 'res_id': self.ids[0] if self.ids else False,
+ 'subtype_id': self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note'),
+ 'is_internal': True,
+ 'record_name': False,
+ 'reply_to': self.env['mail.thread']._notify_get_reply_to(default=email_from, records=None)[False],
+ 'message_id': tools.generate_tracking_message_id('message-notify'), # why? this is all but a notify
+ }
+ message_values.update(kwargs)
+ return self.sudo()._message_create(message_values)
+
+ def _message_log_batch(self, bodies, author_id=None, email_from=None, subject=False, message_type='notification'):
+ """ Shortcut allowing to post notes on a batch of documents. It achieve the
+ same purpose as _message_log, done in batch to speedup quick note log.
+
+ :param bodies: dict {record_id: body}
+ """
+ author_id, email_from = self._message_compute_author(author_id, email_from, raise_exception=False)
+
+ base_message_values = {
+ 'subject': subject,
+ 'author_id': author_id,
+ 'email_from': email_from,
+ 'message_type': message_type,
+ 'model': self._name,
+ 'subtype_id': self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note'),
+ 'is_internal': True,
+ 'record_name': False,
+ 'reply_to': self.env['mail.thread']._notify_get_reply_to(default=email_from, records=None)[False],
+ 'message_id': tools.generate_tracking_message_id('message-notify'), # why? this is all but a notify
+ }
+ values_list = [dict(base_message_values,
+ res_id=record.id,
+ body=bodies.get(record.id, ''))
+ for record in self]
+ return self.sudo()._message_create(values_list)
+
+ def _message_compute_author(self, author_id=None, email_from=None, raise_exception=True):
+ """ Tool method computing author information for messages. Purpose is
+ to ensure maximum coherence between author / current user / email_from
+ when sending emails. """
+ if author_id is None:
+ if email_from:
+ author = self._mail_find_partner_from_emails([email_from])[0]
+ else:
+ author = self.env.user.partner_id
+ email_from = author.email_formatted
+ author_id = author.id
+
+ if email_from is None:
+ if author_id:
+ author = self.env['res.partner'].browse(author_id)
+ email_from = author.email_formatted
+
+ # superuser mode without author email -> probably public user; anyway we don't want to crash
+ if not email_from and not self.env.su and raise_exception:
+ raise exceptions.UserError(_("Unable to log message, please configure the sender's email address."))
+
+ return author_id, email_from
+
+ def _message_create(self, values_list):
+ if not isinstance(values_list, (list)):
+ values_list = [values_list]
+ create_values_list = []
+ for values in values_list:
+ create_values = dict(values)
+ # Avoid warnings about non-existing fields
+ for x in ('from', 'to', 'cc', 'canned_response_ids'):
+ create_values.pop(x, None)
+ create_values['partner_ids'] = [(4, pid) for pid in create_values.get('partner_ids', [])]
+ create_values['channel_ids'] = [(4, cid) for cid in create_values.get('channel_ids', [])]
+ create_values_list.append(create_values)
+ if 'default_child_ids' in self._context:
+ ctx = {key: val for key, val in self._context.items() if key != 'default_child_ids'}
+ self = self.with_context(ctx)
+ return self.env['mail.message'].create(create_values_list)
+
+ # ------------------------------------------------------
+ # NOTIFICATION API
+ # ------------------------------------------------------
+
+ def _notify_thread(self, message, msg_vals=False, notify_by_email=True, **kwargs):
+ """ Main notification method. This method basically does two things
+
+ * call ``_notify_compute_recipients`` that computes recipients to
+ notify based on message record or message creation values if given
+ (to optimize performance if we already have data computed);
+ * performs the notification process by calling the various notification
+ methods implemented;
+
+ This method cnn be overridden to intercept and postpone notification
+ mechanism like mail.channel moderation.
+
+ :param message: mail.message record to notify;
+ :param msg_vals: dictionary of values used to create the message. If given
+ it is used instead of accessing ``self`` to lessen query count in some
+ simple cases where no notification is actually required;
+
+ Kwargs allow to pass various parameters that are given to sub notification
+ methods. See those methods for more details about the additional parameters.
+ Parameters used for email-style notifications
+ """
+ msg_vals = msg_vals if msg_vals else {}
+ rdata = self._notify_compute_recipients(message, msg_vals)
+ if not rdata:
+ return False
+
+ message_values = {}
+ if rdata['channels']:
+ message_values['channel_ids'] = [(6, 0, [r['id'] for r in rdata['channels']])]
+
+ self._notify_record_by_inbox(message, rdata, msg_vals=msg_vals, **kwargs)
+ if notify_by_email:
+ self._notify_record_by_email(message, rdata, msg_vals=msg_vals, **kwargs)
+
+ return rdata
+
+ def _notify_record_by_inbox(self, message, recipients_data, msg_vals=False, **kwargs):
+ """ Notification method: inbox. Do two main things
+
+ * create an inbox notification for users;
+ * create channel / message link (channel_ids field of mail.message);
+ * send bus notifications;
+
+ TDE/XDO TODO: flag rdata directly, with for example r['notif'] = 'ocn_client' and r['needaction']=False
+ and correctly override notify_recipients
+ """
+ channel_ids = [r['id'] for r in recipients_data['channels']]
+ if channel_ids:
+ message.write({'channel_ids': [(6, 0, channel_ids)]})
+
+ inbox_pids = [r['id'] for r in recipients_data['partners'] if r['notif'] == 'inbox']
+ if inbox_pids:
+ notif_create_values = [{
+ 'mail_message_id': message.id,
+ 'res_partner_id': pid,
+ 'notification_type': 'inbox',
+ 'notification_status': 'sent',
+ } for pid in inbox_pids]
+ self.env['mail.notification'].sudo().create(notif_create_values)
+
+ bus_notifications = []
+ if inbox_pids or channel_ids:
+ message_format_values = False
+ if inbox_pids:
+ message_format_values = message.message_format()[0]
+ for partner_id in inbox_pids:
+ bus_notifications.append([(self._cr.dbname, 'ir.needaction', partner_id), dict(message_format_values)])
+ if channel_ids:
+ channels = self.env['mail.channel'].sudo().browse(channel_ids)
+ bus_notifications += channels._channel_message_notifications(message, message_format_values)
+
+ if bus_notifications:
+ self.env['bus.bus'].sudo().sendmany(bus_notifications)
+
+ def _notify_record_by_email(self, message, recipients_data, msg_vals=False,
+ model_description=False, mail_auto_delete=True, check_existing=False,
+ force_send=True, send_after_commit=True,
+ **kwargs):
+ """ Method to send email linked to notified messages.
+
+ :param message: mail.message record to notify;
+ :param recipients_data: see ``_notify_thread``;
+ :param msg_vals: see ``_notify_thread``;
+
+ :param model_description: model description used in email notification process
+ (computed if not given);
+ :param mail_auto_delete: delete notification emails once sent;
+ :param check_existing: check for existing notifications to update based on
+ mailed recipient, otherwise create new notifications;
+
+ :param force_send: send emails directly instead of using queue;
+ :param send_after_commit: if force_send, tells whether to send emails after
+ the transaction has been committed using a post-commit hook;
+ """
+ partners_data = [r for r in recipients_data['partners'] if r['notif'] == 'email']
+ if not partners_data:
+ return True
+
+ model = msg_vals.get('model') if msg_vals else message.model
+ model_name = model_description or (self._fallback_lang().env['ir.model']._get(model).display_name if model else False) # one query for display name
+ recipients_groups_data = self._notify_classify_recipients(partners_data, model_name, msg_vals=msg_vals)
+
+ if not recipients_groups_data:
+ return True
+ force_send = self.env.context.get('mail_notify_force_send', force_send)
+
+ template_values = self._notify_prepare_template_context(message, msg_vals, model_description=model_description) # 10 queries
+
+ email_layout_xmlid = msg_vals.get('email_layout_xmlid') if msg_vals else message.email_layout_xmlid
+ template_xmlid = email_layout_xmlid if email_layout_xmlid else 'mail.message_notification_email'
+ try:
+ base_template = self.env.ref(template_xmlid, raise_if_not_found=True).with_context(lang=template_values['lang']) # 1 query
+ except ValueError:
+ _logger.warning('QWeb template %s not found when sending notification emails. Sending without layouting.' % (template_xmlid))
+ base_template = False
+
+ mail_subject = message.subject or (message.record_name and 'Re: %s' % message.record_name) # in cache, no queries
+ # prepare notification mail values
+ base_mail_values = {
+ 'mail_message_id': message.id,
+ 'mail_server_id': message.mail_server_id.id, # 2 query, check acces + read, may be useless, Falsy, when will it be used?
+ 'auto_delete': mail_auto_delete,
+ # due to ir.rule, user have no right to access parent message if message is not published
+ 'references': message.parent_id.sudo().message_id if message.parent_id else False,
+ 'subject': mail_subject,
+ }
+ base_mail_values = self._notify_by_email_add_values(base_mail_values)
+
+ # Clean the context to get rid of residual default_* keys that could cause issues during
+ # the mail.mail creation.
+ # Example: 'default_state' would refer to the default state of a previously created record
+ # from another model that in turns triggers an assignation notification that ends up here.
+ # This will lead to a traceback when trying to create a mail.mail with this state value that
+ # doesn't exist.
+ SafeMail = self.env['mail.mail'].sudo().with_context(clean_context(self._context))
+ SafeNotification = self.env['mail.notification'].sudo().with_context(clean_context(self._context))
+ emails = self.env['mail.mail'].sudo()
+
+ # loop on groups (customer, portal, user, ... + model specific like group_sale_salesman)
+ notif_create_values = []
+ recipients_max = 50
+ for recipients_group_data in recipients_groups_data:
+ # generate notification email content
+ recipients_ids = recipients_group_data.pop('recipients')
+ render_values = {**template_values, **recipients_group_data}
+ # {company, is_discussion, lang, message, model_description, record, record_name, signature, subtype, tracking_values, website_url}
+ # {actions, button_access, has_button_access, recipients}
+
+ if base_template:
+ mail_body = base_template._render(render_values, engine='ir.qweb', minimal_qcontext=True)
+ else:
+ mail_body = message.body
+ mail_body = self.env['mail.render.mixin']._replace_local_links(mail_body)
+
+ # create email
+ for recipients_ids_chunk in split_every(recipients_max, recipients_ids):
+ recipient_values = self._notify_email_recipient_values(recipients_ids_chunk)
+ email_to = recipient_values['email_to']
+ recipient_ids = recipient_values['recipient_ids']
+
+ create_values = {
+ 'body_html': mail_body,
+ 'subject': mail_subject,
+ 'recipient_ids': [(4, pid) for pid in recipient_ids],
+ }
+ if email_to:
+ create_values['email_to'] = email_to
+ create_values.update(base_mail_values) # mail_message_id, mail_server_id, auto_delete, references, headers
+ email = SafeMail.create(create_values)
+
+ if email and recipient_ids:
+ tocreate_recipient_ids = list(recipient_ids)
+ if check_existing:
+ existing_notifications = self.env['mail.notification'].sudo().search([
+ ('mail_message_id', '=', message.id),
+ ('notification_type', '=', 'email'),
+ ('res_partner_id', 'in', tocreate_recipient_ids)
+ ])
+ if existing_notifications:
+ tocreate_recipient_ids = [rid for rid in recipient_ids if rid not in existing_notifications.mapped('res_partner_id.id')]
+ existing_notifications.write({
+ 'notification_status': 'ready',
+ 'mail_id': email.id,
+ })
+ notif_create_values += [{
+ 'mail_message_id': message.id,
+ 'res_partner_id': recipient_id,
+ 'notification_type': 'email',
+ 'mail_id': email.id,
+ 'is_read': True, # discard Inbox notification
+ 'notification_status': 'ready',
+ } for recipient_id in tocreate_recipient_ids]
+ emails |= email
+
+ if notif_create_values:
+ SafeNotification.create(notif_create_values)
+
+ # NOTE:
+ # 1. for more than 50 followers, use the queue system
+ # 2. do not send emails immediately if the registry is not loaded,
+ # to prevent sending email during a simple update of the database
+ # using the command-line.
+ test_mode = getattr(threading.currentThread(), 'testing', False)
+ if force_send and len(emails) < recipients_max and (not self.pool._init or test_mode):
+ # unless asked specifically, send emails after the transaction to
+ # avoid side effects due to emails being sent while the transaction fails
+ if not test_mode and send_after_commit:
+ email_ids = emails.ids
+ dbname = self.env.cr.dbname
+ _context = self._context
+
+ @self.env.cr.postcommit.add
+ def send_notifications():
+ db_registry = registry(dbname)
+ with api.Environment.manage(), db_registry.cursor() as cr:
+ env = api.Environment(cr, SUPERUSER_ID, _context)
+ env['mail.mail'].browse(email_ids).send()
+ else:
+ emails.send()
+
+ return True
+
+ @api.model
+ def _notify_prepare_template_context(self, message, msg_vals, model_description=False, mail_auto_delete=True):
+ # compute send user and its related signature
+ signature = ''
+ user = self.env.user
+ author = message.env['res.partner'].browse(msg_vals.get('author_id')) if msg_vals else message.author_id
+ model = msg_vals.get('model') if msg_vals else message.model
+ add_sign = msg_vals.get('add_sign') if msg_vals else message.add_sign
+ subtype_id = msg_vals.get('subtype_id') if msg_vals else message.subtype_id.id
+ message_id = message.id
+ record_name = msg_vals.get('record_name') if msg_vals else message.record_name
+ author_user = user if user.partner_id == author else author.user_ids[0] if author and author.user_ids else False
+ # trying to use user (self.env.user) instead of browing user_ids if he is the author will give a sudo user,
+ # improving access performances and cache usage.
+ if author_user:
+ user = author_user
+ if add_sign:
+ signature = user.signature
+ else:
+ if add_sign:
+ signature = "<p>-- <br/>%s</p>" % author.name
+
+ # company value should fall back on env.company if:
+ # - no company_id field on record
+ # - company_id field available but not set
+ company = self.company_id.sudo() if self and 'company_id' in self and self.company_id else self.env.company
+ if company.website:
+ website_url = 'http://%s' % company.website if not company.website.lower().startswith(('http:', 'https:')) else company.website
+ else:
+ website_url = False
+
+ # Retrieve the language in which the template was rendered, in order to render the custom
+ # layout in the same language.
+ # TDE FIXME: this whole brol should be cleaned !
+ lang = self.env.context.get('lang')
+ if {'default_template_id', 'default_model', 'default_res_id'} <= self.env.context.keys():
+ template = self.env['mail.template'].browse(self.env.context['default_template_id'])
+ if template and template.lang:
+ lang = template._render_lang([self.env.context['default_res_id']])[self.env.context['default_res_id']]
+
+ if not model_description and model:
+ model_description = self.env['ir.model'].with_context(lang=lang)._get(model).display_name
+
+ tracking = []
+ if msg_vals.get('tracking_value_ids', True) if msg_vals else bool(self): # could be tracking
+ for tracking_value in self.env['mail.tracking.value'].sudo().search([('mail_message_id', '=', message.id)]):
+ groups = tracking_value.field_groups
+ if not groups or self.env.is_superuser() or self.user_has_groups(groups):
+ tracking.append((tracking_value.field_desc,
+ tracking_value.get_old_display_value()[0],
+ tracking_value.get_new_display_value()[0]))
+
+ is_discussion = subtype_id == self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment')
+
+ return {
+ 'message': message,
+ 'signature': signature,
+ 'website_url': website_url,
+ 'company': company,
+ 'model_description': model_description,
+ 'record': self,
+ 'record_name': record_name,
+ 'tracking_values': tracking,
+ 'is_discussion': is_discussion,
+ 'subtype': message.subtype_id,
+ 'lang': lang,
+ }
+
+ def _notify_by_email_add_values(self, base_mail_values):
+ """ Add model-specific values to the dictionary used to create the
+ notification email. Its base behavior is to compute model-specific
+ headers.
+
+ :param dict base_mail_values: base mail.mail values, holding message
+ to notify (mail_message_id and its fields), server, references, subject.
+ """
+ headers = self._notify_email_headers()
+ if headers:
+ base_mail_values['headers'] = headers
+ return base_mail_values
+
+ def _notify_compute_recipients(self, message, msg_vals):
+ """ Compute recipients to notify based on subtype and followers. This
+ method returns data structured as expected for ``_notify_recipients``. """
+ msg_sudo = message.sudo()
+ # get values from msg_vals or from message if msg_vals doen't exists
+ pids = msg_vals.get('partner_ids', []) if msg_vals else msg_sudo.partner_ids.ids
+ cids = msg_vals.get('channel_ids', []) if msg_vals else msg_sudo.channel_ids.ids
+ message_type = msg_vals.get('message_type') if msg_vals else msg_sudo.message_type
+ subtype_id = msg_vals.get('subtype_id') if msg_vals else msg_sudo.subtype_id.id
+ # is it possible to have record but no subtype_id ?
+ recipient_data = {
+ 'partners': [],
+ 'channels': [],
+ }
+ res = self.env['mail.followers']._get_recipient_data(self, message_type, subtype_id, pids, cids)
+ if not res:
+ return recipient_data
+
+ author_id = msg_vals.get('author_id') or message.author_id.id
+ for pid, cid, active, pshare, ctype, notif, groups in res:
+ if pid and pid == author_id and not self.env.context.get('mail_notify_author'): # do not notify the author of its own messages
+ continue
+ if pid:
+ if active is False:
+ continue
+ pdata = {'id': pid, 'active': active, 'share': pshare, 'groups': groups or []}
+ if notif == 'inbox':
+ recipient_data['partners'].append(dict(pdata, notif=notif, type='user'))
+ elif not pshare and notif: # has an user and is not shared, is therefore user
+ recipient_data['partners'].append(dict(pdata, notif=notif, type='user'))
+ elif pshare and notif: # has an user but is shared, is therefore portal
+ recipient_data['partners'].append(dict(pdata, notif=notif, type='portal'))
+ else: # has no user, is therefore customer
+ recipient_data['partners'].append(dict(pdata, notif=notif if notif else 'email', type='customer'))
+ elif cid:
+ recipient_data['channels'].append({'id': cid, 'notif': notif, 'type': ctype})
+
+ # add partner ids in email channels
+ email_cids = [r['id'] for r in recipient_data['channels'] if r['notif'] == 'email']
+ if email_cids:
+ # we are doing a similar search in ocn_client
+ # Could be interesting to make everything in a single query.
+ # ocn_client: (searching all partners linked to channels of type chat).
+ # here : (searching all partners linked to channels with notif email if email is not the author one)
+ # TDE FIXME: use email_sanitized
+ email_from = msg_vals.get('email_from') or message.email_from
+ email_from = self.env['res.partner']._parse_partner_name(email_from)[1]
+ exept_partner = [r['id'] for r in recipient_data['partners']]
+ if author_id:
+ exept_partner.append(author_id)
+
+ sql_query = """ select distinct on (p.id) p.id from res_partner p
+ left join mail_channel_partner mcp on p.id = mcp.partner_id
+ left join mail_channel c on c.id = mcp.channel_id
+ left join res_users u on p.id = u.partner_id
+ where (u.notification_type != 'inbox' or u.id is null)
+ and (p.email != ANY(%s) or p.email is null)
+ and c.id = ANY(%s)
+ and p.id != ANY(%s)"""
+
+ self.env.cr.execute(sql_query, (([email_from], ), (email_cids, ), (exept_partner, )))
+ for partner_id in self._cr.fetchall():
+ # ocn_client: will add partners to recipient recipient_data. more ocn notifications. We neeed to filter them maybe
+ recipient_data['partners'].append({'id': partner_id[0], 'share': True, 'active': True, 'notif': 'email', 'type': 'channel_email', 'groups': []})
+
+ return recipient_data
+
+ @api.model
+ def _notify_encode_link(self, base_link, params):
+ secret = self.env['ir.config_parameter'].sudo().get_param('database.secret')
+ token = '%s?%s' % (base_link, ' '.join('%s=%s' % (key, params[key]) for key in sorted(params)))
+ hm = hmac.new(secret.encode('utf-8'), token.encode('utf-8'), hashlib.sha1).hexdigest()
+ return hm
+
+ def _notify_get_action_link(self, link_type, **kwargs):
+ """ Prepare link to an action: view document, follow document, ... """
+ params = {
+ 'model': kwargs.get('model', self._name),
+ 'res_id': kwargs.get('res_id', self.ids and self.ids[0] or False),
+ }
+ # whitelist accepted parameters: action (deprecated), token (assign), access_token
+ # (view), auth_signup_token and auth_login (for auth_signup support)
+ params.update(dict(
+ (key, value)
+ for key, value in kwargs.items()
+ if key in ('action', 'token', 'access_token', 'auth_signup_token', 'auth_login')
+ ))
+
+ if link_type in ['view', 'assign', 'follow', 'unfollow']:
+ base_link = '/mail/%s' % link_type
+ elif link_type == 'controller':
+ controller = kwargs.get('controller')
+ params.pop('model')
+ base_link = '%s' % controller
+ else:
+ return ''
+
+ if link_type not in ['view']:
+ token = self._notify_encode_link(base_link, params)
+ params['token'] = token
+
+ link = '%s?%s' % (base_link, urls.url_encode(params))
+ if self:
+ link = self[0].get_base_url() + link
+
+ return link
+
+ def _notify_get_groups(self, msg_vals=None):
+ """ Return groups used to classify recipients of a notification email.
+ Groups is a list of tuple containing of form (group_name, group_func,
+ group_data) where
+ * group_name is an identifier used only to be able to override and manipulate
+ groups. Default groups are user (recipients linked to an employee user),
+ portal (recipients linked to a portal user) and customer (recipients not
+ linked to any user). An example of override use would be to add a group
+ linked to a res.groups like Hr Officers to set specific action buttons to
+ them.
+ * group_func is a function pointer taking a partner record as parameter. This
+ method will be applied on recipients to know whether they belong to a given
+ group or not. Only first matching group is kept. Evaluation order is the
+ list order.
+ * group_data is a dict containing parameters for the notification email
+ * has_button_access: whether to display Access <Document> in email. True
+ by default for new groups, False for portal / customer.
+ * button_access: dict with url and title of the button
+ * actions: list of action buttons to display in the notification email.
+ Each action is a dict containing url and title of the button.
+ Groups has a default value that you can find in mail_thread
+ ``_notify_classify_recipients`` method.
+ """
+ return [
+ (
+ 'user',
+ lambda pdata: pdata['type'] == 'user',
+ {}
+ ), (
+ 'portal',
+ lambda pdata: pdata['type'] == 'portal',
+ {'has_button_access': False}
+ ), (
+ 'customer',
+ lambda pdata: True,
+ {'has_button_access': False}
+ )
+ ]
+
+ def _notify_classify_recipients(self, recipient_data, model_name, msg_vals=None):
+ """ Classify recipients to be notified of a message in groups to have
+ specific rendering depending on their group. For example users could
+ have access to buttons customers should not have in their emails.
+ Module-specific grouping should be done by overriding ``_notify_get_groups``
+ method defined here-under.
+ :param recipient_data:todo xdo UPDATE ME
+ return example:
+ [{
+ 'actions': [],
+ 'button_access': {'title': 'View Simple Chatter Model',
+ 'url': '/mail/view?model=mail.test.simple&res_id=1497'},
+ 'has_button_access': False,
+ 'recipients': [11]
+ },
+ {
+ 'actions': [],
+ 'button_access': {'title': 'View Simple Chatter Model',
+ 'url': '/mail/view?model=mail.test.simple&res_id=1497'},
+ 'has_button_access': False,
+ 'recipients': [4, 5, 6]
+ },
+ {
+ 'actions': [],
+ 'button_access': {'title': 'View Simple Chatter Model',
+ 'url': '/mail/view?model=mail.test.simple&res_id=1497'},
+ 'has_button_access': True,
+ 'recipients': [10, 11, 12]
+ }]
+ only return groups with recipients
+ """
+ # keep a local copy of msg_vals as it may be modified to include more information about groups or links
+ local_msg_vals = dict(msg_vals) if msg_vals else {}
+ groups = self._notify_get_groups(msg_vals=local_msg_vals)
+ access_link = self._notify_get_action_link('view', **local_msg_vals)
+
+ if model_name:
+ view_title = _('View %s', model_name)
+ else:
+ view_title = _('View')
+
+ # fill group_data with default_values if they are not complete
+ for group_name, group_func, group_data in groups:
+ group_data.setdefault('notification_group_name', group_name)
+ group_data.setdefault('notification_is_customer', False)
+ group_data.setdefault('has_button_access', True)
+ group_button_access = group_data.setdefault('button_access', {})
+ group_button_access.setdefault('url', access_link)
+ group_button_access.setdefault('title', view_title)
+ group_data.setdefault('actions', list())
+ group_data.setdefault('recipients', list())
+
+ # classify recipients in each group
+ for recipient in recipient_data:
+ for group_name, group_func, group_data in groups:
+ if group_func(recipient):
+ group_data['recipients'].append(recipient['id'])
+ break
+
+ result = []
+ for group_name, group_method, group_data in groups:
+ if group_data['recipients']:
+ result.append(group_data)
+
+ return result
+
+ @api.model
+ def _notify_get_reply_to_on_records(self, default=None, records=None, company=None, doc_names=None):
+ """ Moved to ``BaseModel._notify_get_reply_to()`` """
+ records = records if records else self
+ return records._notify_get_reply_to(default=default, company=company, doc_names=doc_names)
+
+ def _notify_email_recipient_values(self, recipient_ids):
+ """ Format email notification recipient values to store on the notification
+ mail.mail. Basic method just set the recipient partners as mail_mail
+ recipients. Override to generate other mail values like email_to or
+ email_cc.
+ :param recipient_ids: res.partner recordset to notify
+ """
+ return {
+ 'email_to': False,
+ 'recipient_ids': recipient_ids,
+ }
+
+ # ------------------------------------------------------
+ # FOLLOWERS API
+ # ------------------------------------------------------
+
+ def message_subscribe(self, partner_ids=None, channel_ids=None, subtype_ids=None):
+ """ Main public API to add followers to a record set. Its main purpose is
+ to perform access rights checks before calling ``_message_subscribe``. """
+ if not self or (not partner_ids and not channel_ids):
+ return True
+
+ partner_ids = partner_ids or []
+ channel_ids = channel_ids or []
+ adding_current = set(partner_ids) == set([self.env.user.partner_id.id])
+ customer_ids = [] if adding_current else None
+
+ if not channel_ids and partner_ids and adding_current:
+ try:
+ self.check_access_rights('read')
+ self.check_access_rule('read')
+ except exceptions.AccessError:
+ return False
+ else:
+ self.check_access_rights('write')
+ self.check_access_rule('write')
+
+ # filter inactive and private addresses
+ if partner_ids and not adding_current:
+ partner_ids = self.env['res.partner'].sudo().search([('id', 'in', partner_ids), ('active', '=', True), ('type', '!=', 'private')]).ids
+
+ return self._message_subscribe(partner_ids, channel_ids, subtype_ids, customer_ids=customer_ids)
+
+ def _message_subscribe(self, partner_ids=None, channel_ids=None, subtype_ids=None, customer_ids=None):
+ """ Main private API to add followers to a record set. This method adds
+ partners and channels, given their IDs, as followers of all records
+ contained in the record set.
+
+ If subtypes are given existing followers are erased with new subtypes.
+ If default one have to be computed only missing followers will be added
+ with default subtypes matching the record set model.
+
+ This private method does not specifically check for access right. Use
+ ``message_subscribe`` public API when not sure about access rights.
+
+ :param customer_ids: see ``_insert_followers`` """
+ if not self:
+ return True
+
+ if not subtype_ids:
+ self.env['mail.followers']._insert_followers(
+ self._name, self.ids, partner_ids, None, channel_ids, None,
+ customer_ids=customer_ids, check_existing=True, existing_policy='skip')
+ else:
+ self.env['mail.followers']._insert_followers(
+ self._name, self.ids,
+ partner_ids, dict((pid, subtype_ids) for pid in partner_ids),
+ channel_ids, dict((cid, subtype_ids) for cid in channel_ids),
+ customer_ids=customer_ids, check_existing=True, existing_policy='replace')
+
+ return True
+
+ def message_unsubscribe(self, partner_ids=None, channel_ids=None):
+ """ Remove partners from the records followers. """
+ # not necessary for computation, but saves an access right check
+ if not partner_ids and not channel_ids:
+ return True
+ user_pid = self.env.user.partner_id.id
+ if not channel_ids and set(partner_ids) == set([user_pid]):
+ self.check_access_rights('read')
+ self.check_access_rule('read')
+ else:
+ self.check_access_rights('write')
+ self.check_access_rule('write')
+ self.env['mail.followers'].sudo().search([
+ ('res_model', '=', self._name),
+ ('res_id', 'in', self.ids),
+ '|',
+ ('partner_id', 'in', partner_ids or []),
+ ('channel_id', 'in', channel_ids or [])
+ ]).unlink()
+
+ def _message_auto_subscribe_followers(self, updated_values, default_subtype_ids):
+ """ Optional method to override in addons inheriting from mail.thread.
+ Return a list tuples containing (
+ partner ID,
+ subtype IDs (or False if model-based default subtypes),
+ QWeb template XML ID for notification (or False is no specific
+ notification is required),
+ ), aka partners and their subtype and possible notification to send
+ using the auto subscription mechanism linked to updated values.
+
+ Default value of this method is to return the new responsible of
+ documents. This is done using relational fields linking to res.users
+ with track_visibility set. Since OpenERP v7 it is considered as being
+ responsible for the document and therefore standard behavior is to
+ subscribe the user and send him a notification.
+
+ Override this method to change that behavior and/or to add people to
+ notify, using possible custom notification.
+
+ :param updated_values: see ``_message_auto_subscribe``
+ :param default_subtype_ids: coming from ``_get_auto_subscription_subtypes``
+ """
+ fnames = []
+ field = self._fields.get('user_id')
+ user_id = updated_values.get('user_id')
+ if field and user_id and field.comodel_name == 'res.users' and (getattr(field, 'track_visibility', False) or getattr(field, 'tracking', False)):
+ user = self.env['res.users'].sudo().browse(user_id)
+ try: # avoid to make an exists, lets be optimistic and try to read it.
+ if user.active:
+ return [(user.partner_id.id, default_subtype_ids, 'mail.message_user_assigned' if user != self.env.user else False)]
+ except:
+ pass
+ return []
+
+ def _message_auto_subscribe_notify(self, partner_ids, template):
+ """ Notify new followers, using a template to render the content of the
+ notification message. Notifications pushed are done using the standard
+ notification mechanism in mail.thread. It is either inbox either email
+ depending on the partner state: no user (email, customer), share user
+ (email, customer) or classic user (notification_type)
+
+ :param partner_ids: IDs of partner to notify;
+ :param template: XML ID of template used for the notification;
+ """
+ if not self or self.env.context.get('mail_auto_subscribe_no_notify'):
+ return
+ if not self.env.registry.ready: # Don't send notification during install
+ return
+
+ view = self.env['ir.ui.view'].browse(self.env['ir.model.data'].xmlid_to_res_id(template))
+
+ for record in self:
+ model_description = self.env['ir.model']._get(record._name).display_name
+ values = {
+ 'object': record,
+ 'model_description': model_description,
+ 'access_link': record._notify_get_action_link('view'),
+ }
+ assignation_msg = view._render(values, engine='ir.qweb', minimal_qcontext=True)
+ assignation_msg = self.env['mail.render.mixin']._replace_local_links(assignation_msg)
+ record.message_notify(
+ subject=_('You have been assigned to %s', record.display_name),
+ body=assignation_msg,
+ partner_ids=partner_ids,
+ record_name=record.display_name,
+ email_layout_xmlid='mail.mail_notification_light',
+ model_description=model_description,
+ )
+
+ def _message_auto_subscribe(self, updated_values, followers_existing_policy='skip'):
+ """ Handle auto subscription. Auto subscription is done based on two
+ main mechanisms
+
+ * using subtypes parent relationship. For example following a parent record
+ (i.e. project) with subtypes linked to child records (i.e. task). See
+ mail.message.subtype ``_get_auto_subscription_subtypes``;
+ * calling _message_auto_subscribe_notify that returns a list of partner
+ to subscribe, as well as data about the subtypes and notification
+ to send. Base behavior is to subscribe responsible and notify them;
+
+ Adding application-specific auto subscription should be done by overriding
+ ``_message_auto_subscribe_followers``. It should return structured data
+ for new partner to subscribe, with subtypes and eventual notification
+ to perform. See that method for more details.
+
+ :param updated_values: values modifying the record trigerring auto subscription
+ """
+ if not self:
+ return True
+
+ new_partners, new_channels = dict(), dict()
+
+ # return data related to auto subscription based on subtype matching (aka:
+ # default task subtypes or subtypes from project triggering task subtypes)
+ updated_relation = dict()
+ child_ids, def_ids, all_int_ids, parent, relation = self.env['mail.message.subtype']._get_auto_subscription_subtypes(self._name)
+
+ # check effectively modified relation field
+ for res_model, fnames in relation.items():
+ for field in (fname for fname in fnames if updated_values.get(fname)):
+ updated_relation.setdefault(res_model, set()).add(field)
+ udpated_fields = [fname for fnames in updated_relation.values() for fname in fnames if updated_values.get(fname)]
+
+ if udpated_fields:
+ # fetch "parent" subscription data (aka: subtypes on project to propagate on task)
+ doc_data = [(model, [updated_values[fname] for fname in fnames]) for model, fnames in updated_relation.items()]
+ res = self.env['mail.followers']._get_subscription_data(doc_data, None, None, include_pshare=True, include_active=True)
+ for fid, rid, pid, cid, subtype_ids, pshare, active in res:
+ # use project.task_new -> task.new link
+ sids = [parent[sid] for sid in subtype_ids if parent.get(sid)]
+ # add checked subtypes matching model_name
+ sids += [sid for sid in subtype_ids if sid not in parent and sid in child_ids]
+ if pid and active: # auto subscribe only active partners
+ if pshare: # remove internal subtypes for customers
+ new_partners[pid] = set(sids) - set(all_int_ids)
+ else:
+ new_partners[pid] = set(sids)
+ if cid: # never subscribe channels to internal subtypes
+ new_channels[cid] = set(sids) - set(all_int_ids)
+
+ notify_data = dict()
+ res = self._message_auto_subscribe_followers(updated_values, def_ids)
+ for pid, sids, template in res:
+ new_partners.setdefault(pid, sids)
+ if template:
+ partner = self.env['res.partner'].browse(pid)
+ lang = partner.lang if partner else None
+ notify_data.setdefault((template, lang), list()).append(pid)
+
+ self.env['mail.followers']._insert_followers(
+ self._name, self.ids,
+ list(new_partners), new_partners,
+ list(new_channels), new_channels,
+ check_existing=True, existing_policy=followers_existing_policy)
+
+ # notify people from auto subscription, for example like assignation
+ for (template, lang), pids in notify_data.items():
+ self.with_context(lang=lang)._message_auto_subscribe_notify(pids, template)
+
+ return True
+
+ # ------------------------------------------------------
+ # CONTROLLERS
+ # ------------------------------------------------------
+
+ def _get_mail_redirect_suggested_company(self):
+ """ Return the suggested company to be set on the context
+ in case of a mail redirection to the record. To avoid multi
+ company issues when clicking on a link sent by email, this
+ could be called to try setting the most suited company on
+ the allowed_company_ids in the context. This method can be
+ overridden, for example on the hr.leave model, where the
+ most suited company is the company of the leave type, as
+ specified by the ir.rule.
+ """
+ if 'company_id' in self:
+ return self.company_id
+ return False
diff --git a/addons/mail/models/mail_thread_blacklist.py b/addons/mail/models/mail_thread_blacklist.py
new file mode 100644
index 00000000..f17302f3
--- /dev/null
+++ b/addons/mail/models/mail_thread_blacklist.py
@@ -0,0 +1,126 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, tools, _
+from odoo.exceptions import AccessError, UserError
+
+
+class MailBlackListMixin(models.AbstractModel):
+ """ Mixin that is inherited by all model with opt out. This mixin stores a normalized
+ email based on primary_email field.
+
+ A normalized email is considered as :
+ - having a left part + @ + a right part (the domain can be without '.something')
+ - being lower case
+ - having no name before the address. Typically, having no 'Name <>'
+ Ex:
+ - Formatted Email : 'Name <NaMe@DoMaIn.CoM>'
+ - Normalized Email : 'name@domain.com'
+
+ The primary email field can be specified on the parent model, if it differs from the default one ('email')
+ The email_normalized field can than be used on that model to search quickly on emails (by simple comparison
+ and not using time consuming regex anymore).
+
+ Using this email_normalized field, blacklist status is computed.
+
+ Mail Thread capabilities are required for this mixin. """
+
+ _name = 'mail.thread.blacklist'
+ _inherit = ['mail.thread']
+ _description = 'Mail Blacklist mixin'
+ _primary_email = 'email'
+
+ email_normalized = fields.Char(
+ string='Normalized Email', compute="_compute_email_normalized", compute_sudo=True,
+ store=True, invisible=True,
+ help="This field is used to search on email address as the primary email field can contain more than strictly an email address.")
+ # Note : is_blacklisted sould only be used for display. As the compute is not depending on the blacklist,
+ # once read, it won't be re-computed again if the blacklist is modified in the same request.
+ is_blacklisted = fields.Boolean(
+ string='Blacklist', compute="_compute_is_blacklisted", compute_sudo=True, store=False,
+ search="_search_is_blacklisted", groups="base.group_user",
+ help="If the email address is on the blacklist, the contact won't receive mass mailing anymore, from any list")
+ # messaging
+ message_bounce = fields.Integer('Bounce', help="Counter of the number of bounced emails for this contact", default=0)
+
+ @api.depends(lambda self: [self._primary_email])
+ def _compute_email_normalized(self):
+ self._assert_primary_email()
+ for record in self:
+ record.email_normalized = tools.email_normalize(record[self._primary_email])
+
+ @api.model
+ def _search_is_blacklisted(self, operator, value):
+ # Assumes operator is '=' or '!=' and value is True or False
+ self.flush(['email_normalized'])
+ self.env['mail.blacklist'].flush(['email', 'active'])
+ self._assert_primary_email()
+ if operator != '=':
+ if operator == '!=' and isinstance(value, bool):
+ value = not value
+ else:
+ raise NotImplementedError()
+
+ if value:
+ query = """
+ SELECT m.id
+ FROM mail_blacklist bl
+ JOIN %s m
+ ON m.email_normalized = bl.email AND bl.active
+ """
+ else:
+ query = """
+ SELECT m.id
+ FROM %s m
+ LEFT JOIN mail_blacklist bl
+ ON m.email_normalized = bl.email AND bl.active
+ WHERE bl.id IS NULL
+ """
+ self._cr.execute(query % self._table)
+ res = self._cr.fetchall()
+ if not res:
+ return [(0, '=', 1)]
+ return [('id', 'in', [r[0] for r in res])]
+
+ @api.depends('email_normalized')
+ def _compute_is_blacklisted(self):
+ # TODO : Should remove the sudo as compute_sudo defined on methods.
+ # But if user doesn't have access to mail.blacklist, doen't work without sudo().
+ blacklist = set(self.env['mail.blacklist'].sudo().search([
+ ('email', 'in', self.mapped('email_normalized'))]).mapped('email'))
+ for record in self:
+ record.is_blacklisted = record.email_normalized in blacklist
+
+ def _assert_primary_email(self):
+ if not hasattr(self, "_primary_email") or not isinstance(self._primary_email, str):
+ raise UserError(_('Invalid primary email field on model %s', self._name))
+ if self._primary_email not in self._fields or self._fields[self._primary_email].type != 'char':
+ raise UserError(_('Invalid primary email field on model %s', self._name))
+
+ def _message_receive_bounce(self, email, partner):
+ """ Override of mail.thread generic method. Purpose is to increment the
+ bounce counter of the record. """
+ super(MailBlackListMixin, self)._message_receive_bounce(email, partner)
+ for record in self:
+ record.message_bounce = record.message_bounce + 1
+
+ def _message_reset_bounce(self, email):
+ """ Override of mail.thread generic method. Purpose is to reset the
+ bounce counter of the record. """
+ super(MailBlackListMixin, self)._message_reset_bounce(email)
+ self.write({'message_bounce': 0})
+
+ def mail_action_blacklist_remove(self):
+ # wizard access rights currently not working as expected and allows users without access to
+ # open this wizard, therefore we check to make sure they have access before the wizard opens.
+ can_access = self.env['mail.blacklist'].check_access_rights('write', raise_exception=False)
+ if can_access:
+ return {
+ 'name': 'Are you sure you want to unblacklist this Email Address?',
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'mail.blacklist.remove',
+ 'target': 'new',
+ }
+ else:
+ raise AccessError("You do not have the access right to unblacklist emails. Please contact your administrator.")
diff --git a/addons/mail/models/mail_thread_cc.py b/addons/mail/models/mail_thread_cc.py
new file mode 100644
index 00000000..427eb25a
--- /dev/null
+++ b/addons/mail/models/mail_thread_cc.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import _, api, fields, models, tools
+
+
+class MailCCMixin(models.AbstractModel):
+ _name = 'mail.thread.cc'
+ _inherit = 'mail.thread'
+ _description = 'Email CC management'
+
+ email_cc = fields.Char('Email cc', help='List of cc from incoming emails.')
+
+ def _mail_cc_sanitized_raw_dict(self, cc_string):
+ '''return a dict of sanitize_email:raw_email from a string of cc'''
+ if not cc_string:
+ return {}
+ return {tools.email_normalize(email): tools.formataddr((name, tools.email_normalize(email)))
+ for (name, email) in tools.email_split_tuples(cc_string)}
+
+ @api.model
+ def message_new(self, msg_dict, custom_values=None):
+ if custom_values is None:
+ custom_values = {}
+ cc_values = {
+ 'email_cc': ", ".join(self._mail_cc_sanitized_raw_dict(msg_dict.get('cc')).values()),
+ }
+ cc_values.update(custom_values)
+ return super(MailCCMixin, self).message_new(msg_dict, cc_values)
+
+ def message_update(self, msg_dict, update_vals=None):
+ '''Adds cc email to self.email_cc while trying to keep email as raw as possible but unique'''
+ if update_vals is None:
+ update_vals = {}
+ cc_values = {}
+ new_cc = self._mail_cc_sanitized_raw_dict(msg_dict.get('cc'))
+ if new_cc:
+ old_cc = self._mail_cc_sanitized_raw_dict(self.email_cc)
+ new_cc.update(old_cc)
+ cc_values['email_cc'] = ", ".join(new_cc.values())
+ cc_values.update(update_vals)
+ return super(MailCCMixin, self).message_update(msg_dict, cc_values)
+
+ def _message_get_suggested_recipients(self):
+ recipients = super(MailCCMixin, self)._message_get_suggested_recipients()
+ for record in self:
+ if record.email_cc:
+ for email in tools.email_split_and_format(record.email_cc):
+ record._message_add_suggested_recipient(recipients, email=email, reason=_('CC Email'))
+ return recipients
diff --git a/addons/mail/models/mail_tracking_value.py b/addons/mail/models/mail_tracking_value.py
new file mode 100644
index 00000000..472555a5
--- /dev/null
+++ b/addons/mail/models/mail_tracking_value.py
@@ -0,0 +1,118 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import datetime
+
+from odoo import api, fields, models
+
+
+class MailTracking(models.Model):
+ _name = 'mail.tracking.value'
+ _description = 'Mail Tracking Value'
+ _rec_name = 'field'
+ _order = 'tracking_sequence asc'
+
+ field = fields.Many2one('ir.model.fields', required=True, readonly=1, ondelete='cascade')
+ field_desc = fields.Char('Field Description', required=True, readonly=1)
+ field_type = fields.Char('Field Type')
+ field_groups = fields.Char(compute='_compute_field_groups')
+
+ old_value_integer = fields.Integer('Old Value Integer', readonly=1)
+ old_value_float = fields.Float('Old Value Float', readonly=1)
+ old_value_monetary = fields.Float('Old Value Monetary', readonly=1)
+ old_value_char = fields.Char('Old Value Char', readonly=1)
+ old_value_text = fields.Text('Old Value Text', readonly=1)
+ old_value_datetime = fields.Datetime('Old Value DateTime', readonly=1)
+
+ new_value_integer = fields.Integer('New Value Integer', readonly=1)
+ new_value_float = fields.Float('New Value Float', readonly=1)
+ new_value_monetary = fields.Float('New Value Monetary', readonly=1)
+ new_value_char = fields.Char('New Value Char', readonly=1)
+ new_value_text = fields.Text('New Value Text', readonly=1)
+ new_value_datetime = fields.Datetime('New Value Datetime', readonly=1)
+
+ mail_message_id = fields.Many2one('mail.message', 'Message ID', required=True, index=True, ondelete='cascade')
+
+ tracking_sequence = fields.Integer('Tracking field sequence', readonly=1, default=100)
+
+ def _compute_field_groups(self):
+ for tracking in self:
+ model = self.env[tracking.mail_message_id.model]
+ field = model._fields.get(tracking.field.name)
+ tracking.field_groups = field.groups if field else 'base.group_system'
+
+ @api.model
+ def create_tracking_values(self, initial_value, new_value, col_name, col_info, tracking_sequence, model_name):
+ tracked = True
+
+ field = self.env['ir.model.fields']._get(model_name, col_name)
+ if not field:
+ return
+
+ values = {'field': field.id, 'field_desc': col_info['string'], 'field_type': col_info['type'], 'tracking_sequence': tracking_sequence}
+
+ if col_info['type'] in ['integer', 'float', 'char', 'text', 'datetime', 'monetary']:
+ values.update({
+ 'old_value_%s' % col_info['type']: initial_value,
+ 'new_value_%s' % col_info['type']: new_value
+ })
+ elif col_info['type'] == 'date':
+ values.update({
+ 'old_value_datetime': initial_value and fields.Datetime.to_string(datetime.combine(fields.Date.from_string(initial_value), datetime.min.time())) or False,
+ 'new_value_datetime': new_value and fields.Datetime.to_string(datetime.combine(fields.Date.from_string(new_value), datetime.min.time())) or False,
+ })
+ elif col_info['type'] == 'boolean':
+ values.update({
+ 'old_value_integer': initial_value,
+ 'new_value_integer': new_value
+ })
+ elif col_info['type'] == 'selection':
+ values.update({
+ 'old_value_char': initial_value and dict(col_info['selection'])[initial_value] or '',
+ 'new_value_char': new_value and dict(col_info['selection'])[new_value] or ''
+ })
+ elif col_info['type'] == 'many2one':
+ values.update({
+ 'old_value_integer': initial_value and initial_value.id or 0,
+ 'new_value_integer': new_value and new_value.id or 0,
+ 'old_value_char': initial_value and initial_value.sudo().name_get()[0][1] or '',
+ 'new_value_char': new_value and new_value.sudo().name_get()[0][1] or ''
+ })
+ else:
+ tracked = False
+
+ if tracked:
+ return values
+ return {}
+
+ def get_display_value(self, type):
+ assert type in ('new', 'old')
+ result = []
+ for record in self:
+ if record.field_type in ['integer', 'float', 'char', 'text', 'monetary']:
+ result.append(getattr(record, '%s_value_%s' % (type, record.field_type)))
+ elif record.field_type == 'datetime':
+ if record['%s_value_datetime' % type]:
+ new_datetime = getattr(record, '%s_value_datetime' % type)
+ result.append('%sZ' % new_datetime)
+ else:
+ result.append(record['%s_value_datetime' % type])
+ elif record.field_type == 'date':
+ if record['%s_value_datetime' % type]:
+ new_date = record['%s_value_datetime' % type]
+ result.append(fields.Date.to_string(new_date))
+ else:
+ result.append(record['%s_value_datetime' % type])
+ elif record.field_type == 'boolean':
+ result.append(bool(record['%s_value_integer' % type]))
+ else:
+ result.append(record['%s_value_char' % type])
+ return result
+
+ def get_old_display_value(self):
+ # grep : # old_value_integer | old_value_datetime | old_value_char
+ return self.get_display_value('old')
+
+ def get_new_display_value(self):
+ # grep : # new_value_integer | new_value_datetime | new_value_char
+ return self.get_display_value('new')
diff --git a/addons/mail/models/models.py b/addons/mail/models/models.py
new file mode 100644
index 00000000..1008fe0c
--- /dev/null
+++ b/addons/mail/models/models.py
@@ -0,0 +1,210 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from lxml.builder import E
+
+from odoo import api, models, tools, _
+
+
+class BaseModel(models.AbstractModel):
+ _inherit = 'base'
+
+ # ------------------------------------------------------------
+ # GENERIC MAIL FEATURES
+ # ------------------------------------------------------------
+
+ def _mail_track(self, tracked_fields, initial):
+ """ For a given record, fields to check (tuple column name, column info)
+ and initial values, return a valid command to create tracking values.
+
+ :param tracked_fields: fields_get of updated fields on which tracking
+ is checked and performed;
+ :param initial: dict of initial values for each updated fields;
+
+ :return: a tuple (changes, tracking_value_ids) where
+ changes: set of updated column names;
+ tracking_value_ids: a list of ORM (0, 0, values) commands to create
+ ``mail.tracking.value`` records;
+
+ Override this method on a specific model to implement model-specific
+ behavior. Also consider inheriting from ``mail.thread``. """
+ self.ensure_one()
+ changes = set() # contains onchange tracked fields that changed
+ tracking_value_ids = []
+
+ # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
+ for col_name, col_info in tracked_fields.items():
+ if col_name not in initial:
+ continue
+ initial_value = initial[col_name]
+ new_value = self[col_name]
+
+ if new_value != initial_value and (new_value or initial_value): # because browse null != False
+ tracking_sequence = getattr(self._fields[col_name], 'tracking',
+ getattr(self._fields[col_name], 'track_sequence', 100)) # backward compatibility with old parameter name
+ if tracking_sequence is True:
+ tracking_sequence = 100
+ tracking = self.env['mail.tracking.value'].create_tracking_values(initial_value, new_value, col_name, col_info, tracking_sequence, self._name)
+ if tracking:
+ tracking_value_ids.append([0, 0, tracking])
+ changes.add(col_name)
+
+ return changes, tracking_value_ids
+
+ def _message_get_default_recipients(self):
+ """ Generic implementation for finding default recipient to mail on
+ a recordset. This method is a generic implementation available for
+ all models as we could send an email through mail templates on models
+ not inheriting from mail.thread.
+
+ Override this method on a specific model to implement model-specific
+ behavior. Also consider inheriting from ``mail.thread``. """
+ res = {}
+ for record in self:
+ recipient_ids, email_to, email_cc = [], False, False
+ if 'partner_id' in record and record.partner_id:
+ recipient_ids.append(record.partner_id.id)
+ elif 'email_normalized' in record and record.email_normalized:
+ email_to = record.email_normalized
+ elif 'email_from' in record and record.email_from:
+ email_to = record.email_from
+ elif 'partner_email' in record and record.partner_email:
+ email_to = record.partner_email
+ elif 'email' in record and record.email:
+ email_to = record.email
+ res[record.id] = {'partner_ids': recipient_ids, 'email_to': email_to, 'email_cc': email_cc}
+ return res
+
+ def _notify_get_reply_to(self, default=None, records=None, company=None, doc_names=None):
+ """ Returns the preferred reply-to email address when replying to a thread
+ on documents. This method is a generic implementation available for
+ all models as we could send an email through mail templates on models
+ not inheriting from mail.thread.
+
+ Reply-to is formatted like "MyCompany MyDocument <reply.to@domain>".
+ Heuristic it the following:
+ * search for specific aliases as they always have priority; it is limited
+ to aliases linked to documents (like project alias for task for example);
+ * use catchall address;
+ * use default;
+
+ This method can be used as a generic tools if self is a void recordset.
+
+ Override this method on a specific model to implement model-specific
+ behavior. Also consider inheriting from ``mail.thread``.
+ An example would be tasks taking their reply-to alias from their project.
+
+ :param default: default email if no alias or catchall is found;
+ :param records: DEPRECATED, self should be a valid record set or an
+ empty recordset if a generic reply-to is required;
+ :param company: used to compute company name part of the from name; provide
+ it if already known, otherwise fall back on user company;
+ :param doc_names: dict(res_id, doc_name) used to compute doc name part of
+ the from name; provide it if already known to avoid queries, otherwise
+ name_get on document will be performed;
+ :return result: dictionary. Keys are record IDs and value is formatted
+ like an email "Company_name Document_name <reply_to@email>"/
+ """
+ if records:
+ raise ValueError('Use of records is deprecated as this method is available on BaseModel.')
+
+ _records = self
+ model = _records._name if _records and _records._name != 'mail.thread' else False
+ res_ids = _records.ids if _records and model else []
+ _res_ids = res_ids or [False] # always have a default value located in False
+
+ alias_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain")
+ result = dict.fromkeys(_res_ids, False)
+ result_email = dict()
+ doc_names = doc_names if doc_names else dict()
+
+ if alias_domain:
+ if model and res_ids:
+ if not doc_names:
+ doc_names = dict((rec.id, rec.display_name) for rec in _records)
+
+ mail_aliases = self.env['mail.alias'].sudo().search([
+ ('alias_parent_model_id.model', '=', model),
+ ('alias_parent_thread_id', 'in', res_ids),
+ ('alias_name', '!=', False)])
+ # take only first found alias for each thread_id, to match order (1 found -> limit=1 for each res_id)
+ for alias in mail_aliases:
+ result_email.setdefault(alias.alias_parent_thread_id, '%s@%s' % (alias.alias_name, alias_domain))
+
+ # left ids: use catchall
+ left_ids = set(_res_ids) - set(result_email)
+ if left_ids:
+ catchall = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.alias")
+ if catchall:
+ result_email.update(dict((rid, '%s@%s' % (catchall, alias_domain)) for rid in left_ids))
+
+ # compute name of reply-to - TDE tocheck: quotes and stuff like that
+ company_name = company.name if company else self.env.company.name
+ for res_id in result_email:
+ name = '%s%s%s' % (company_name, ' ' if doc_names.get(res_id) else '', doc_names.get(res_id, ''))
+ result[res_id] = tools.formataddr((name, result_email[res_id]))
+
+ left_ids = set(_res_ids) - set(result_email)
+ if left_ids:
+ result.update(dict((res_id, default) for res_id in left_ids))
+
+ return result
+
+ # ------------------------------------------------------------
+ # ALIAS MANAGEMENT
+ # ------------------------------------------------------------
+
+ def _alias_check_contact(self, message, message_dict, alias):
+ """ Deprecated, remove in v14+ """
+ error_msg = self._alias_get_error_message(message, message_dict, alias)
+ return error_msg if error_msg else True
+
+ def _alias_get_error_message(self, message, message_dict, alias):
+ """ Generic method that takes a record not necessarily inheriting from
+ mail.alias.mixin. """
+ author = self.env['res.partner'].browse(message_dict.get('author_id', False))
+ if alias.alias_contact == 'followers':
+ if not self.ids:
+ return _('incorrectly configured alias (unknown reference record)')
+ if not hasattr(self, "message_partner_ids") or not hasattr(self, "message_channel_ids"):
+ return _('incorrectly configured alias')
+ accepted_partner_ids = self.message_partner_ids | self.message_channel_ids.mapped('channel_partner_ids')
+ if not author or author not in accepted_partner_ids:
+ return _('restricted to followers')
+ elif alias.alias_contact == 'partners' and not author:
+ return _('restricted to known authors')
+ return False
+
+ # ------------------------------------------------------------
+ # ACTIVITY
+ # ------------------------------------------------------------
+
+ @api.model
+ def _get_default_activity_view(self):
+ """ Generates an empty activity view.
+
+ :returns: a activity view as an lxml document
+ :rtype: etree._Element
+ """
+ field = E.field(name=self._rec_name_fallback())
+ activity_box = E.div(field, {'t-name': "activity-box"})
+ templates = E.templates(activity_box)
+ return E.activity(templates, string=self._description)
+
+ # ------------------------------------------------------------
+ # GATEWAY: NOTIFICATION
+ # ------------------------------------------------------------
+
+ def _notify_email_headers(self):
+ """
+ Generate the email headers based on record
+ """
+ if not self:
+ return {}
+ self.ensure_one()
+ return repr(self._notify_email_header_dict())
+
+ def _notify_email_header_dict(self):
+ return {
+ 'X-Odoo-Objects': "%s-%s" % (self._name, self.id),
+ }
diff --git a/addons/mail/models/res_company.py b/addons/mail/models/res_company.py
new file mode 100644
index 00000000..3572c903
--- /dev/null
+++ b/addons/mail/models/res_company.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models, fields, tools
+
+
+class Company(models.Model):
+ _name = 'res.company'
+ _inherit = 'res.company'
+
+ catchall_email = fields.Char(string="Catchall Email", compute="_compute_catchall")
+ catchall_formatted = fields.Char(string="Catchall", compute="_compute_catchall")
+ email_formatted = fields.Char(string="Formatted Email", compute="_compute_email_formatted")
+
+ @api.depends('name')
+ def _compute_catchall(self):
+ ConfigParameter = self.env['ir.config_parameter'].sudo()
+ alias = ConfigParameter.get_param('mail.catchall.alias')
+ domain = ConfigParameter.get_param('mail.catchall.domain')
+ if alias and domain:
+ for company in self:
+ company.catchall_email = '%s@%s' % (alias, domain)
+ company.catchall_formatted = tools.formataddr((company.name, company.catchall_email))
+ else:
+ for company in self:
+ company.catchall_email = ''
+ company.catchall_formatted = ''
+
+ @api.depends('partner_id.email_formatted', 'catchall_formatted')
+ def _compute_email_formatted(self):
+ for company in self:
+ if company.partner_id.email_formatted:
+ company.email_formatted = company.partner_id.email_formatted
+ elif company.catchall_formatted:
+ company.email_formatted = company.catchall_formatted
+ else:
+ company.email_formatted = ''
diff --git a/addons/mail/models/res_config_settings.py b/addons/mail/models/res_config_settings.py
new file mode 100644
index 00000000..6cae4f3b
--- /dev/null
+++ b/addons/mail/models/res_config_settings.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import datetime
+
+from werkzeug import urls
+
+from odoo import api, fields, models, tools
+
+
+class ResConfigSettings(models.TransientModel):
+ """ Inherit the base settings to add a counter of failed email + configure
+ the alias domain. """
+ _inherit = 'res.config.settings'
+
+ fail_counter = fields.Integer('Fail Mail', readonly=True)
+ alias_domain = fields.Char('Alias Domain', help="If you have setup a catch-all email domain redirected to "
+ "the Odoo server, enter the domain name here.", config_parameter='mail.catchall.domain')
+
+ @api.model
+ def get_values(self):
+ res = super(ResConfigSettings, self).get_values()
+
+ previous_date = datetime.datetime.now() - datetime.timedelta(days=30)
+
+ res.update(
+ fail_counter=self.env['mail.mail'].sudo().search_count([
+ ('date', '>=', previous_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)),
+ ('state', '=', 'exception')]),
+ )
+
+ return res
+
+ def set_values(self):
+ super(ResConfigSettings, self).set_values()
+ self.env['ir.config_parameter'].set_param("mail.catchall.domain", self.alias_domain or '')
diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py
new file mode 100644
index 00000000..08b94805
--- /dev/null
+++ b/addons/mail/models/res_partner.py
@@ -0,0 +1,184 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+
+from odoo import _, api, fields, models, tools
+from odoo.addons.bus.models.bus_presence import AWAY_TIMER
+from odoo.addons.bus.models.bus_presence import DISCONNECTION_TIMER
+from odoo.exceptions import AccessError
+from odoo.osv import expression
+
+_logger = logging.getLogger(__name__)
+
+
+class Partner(models.Model):
+ """ Update partner to add a field about notification preferences. Add a generic opt-out field that can be used
+ to restrict usage of automatic email templates. """
+ _name = "res.partner"
+ _inherit = ['res.partner', 'mail.activity.mixin', 'mail.thread.blacklist']
+ _mail_flat_thread = False
+
+ email = fields.Char(tracking=1)
+ phone = fields.Char(tracking=2)
+
+ channel_ids = fields.Many2many('mail.channel', 'mail_channel_partner', 'partner_id', 'channel_id', string='Channels', copy=False)
+ # override the field to track the visibility of user
+ user_id = fields.Many2one(tracking=True)
+
+ def _compute_im_status(self):
+ super()._compute_im_status()
+ odoobot_id = self.env['ir.model.data'].xmlid_to_res_id('base.partner_root')
+ odoobot = self.env['res.partner'].browse(odoobot_id)
+ if odoobot in self:
+ odoobot.im_status = 'bot'
+
+ def _message_get_suggested_recipients(self):
+ recipients = super(Partner, self)._message_get_suggested_recipients()
+ for partner in self:
+ partner._message_add_suggested_recipient(recipients, partner=partner, reason=_('Partner Profile'))
+ return recipients
+
+ def _message_get_default_recipients(self):
+ return {r.id: {
+ 'partner_ids': [r.id],
+ 'email_to': False,
+ 'email_cc': False}
+ for r in self}
+
+ @api.model
+ @api.returns('self', lambda value: value.id)
+ def find_or_create(self, email, assert_valid_email=False):
+ """ Override to use the email_normalized field. """
+ if not email:
+ raise ValueError(_('An email is required for find_or_create to work'))
+
+ parsed_name, parsed_email = self._parse_partner_name(email)
+ if parsed_email:
+ email_normalized = tools.email_normalize(parsed_email)
+ if email_normalized:
+ partners = self.search([('email_normalized', '=', email_normalized)], limit=1)
+ if partners:
+ return partners
+
+ return super(Partner, self).find_or_create(email, assert_valid_email=assert_valid_email)
+
+ def mail_partner_format(self):
+ self.ensure_one()
+ internal_users = self.user_ids - self.user_ids.filtered('share')
+ main_user = internal_users[0] if len(internal_users) else self.user_ids[0] if len(self.user_ids) else self.env['res.users']
+ res = {
+ "id": self.id,
+ "display_name": self.display_name,
+ "name": self.name,
+ "email": self.email,
+ "active": self.active,
+ "im_status": self.im_status,
+ "user_id": main_user.id,
+ }
+ if main_user:
+ res["is_internal_user"] = not main_user.share
+ return res
+
+ @api.model
+ def get_needaction_count(self):
+ """ compute the number of needaction of the current user """
+ if self.env.user.partner_id:
+ self.env['mail.notification'].flush(['is_read', 'res_partner_id'])
+ self.env.cr.execute("""
+ SELECT count(*) as needaction_count
+ FROM mail_message_res_partner_needaction_rel R
+ WHERE R.res_partner_id = %s AND (R.is_read = false OR R.is_read IS NULL)""", (self.env.user.partner_id.id,))
+ return self.env.cr.dictfetchall()[0].get('needaction_count')
+ _logger.error('Call to needaction_count without partner_id')
+ return 0
+
+ @api.model
+ def get_starred_count(self):
+ """ compute the number of starred of the current user """
+ if self.env.user.partner_id:
+ self.env.cr.execute("""
+ SELECT count(*) as starred_count
+ FROM mail_message_res_partner_starred_rel R
+ WHERE R.res_partner_id = %s """, (self.env.user.partner_id.id,))
+ return self.env.cr.dictfetchall()[0].get('starred_count')
+ _logger.error('Call to starred_count without partner_id')
+ return 0
+
+ @api.model
+ def get_static_mention_suggestions(self):
+ """Returns static mention suggestions of partners, loaded once at
+ webclient initialization and stored client side.
+ By default all the internal users are returned.
+
+ The return format is a list of lists. The first level of list is an
+ arbitrary split that allows overrides to return their own list.
+ The second level of list is a list of partner data (as per returned by
+ `mail_partner_format()`).
+ """
+ suggestions = []
+ try:
+ suggestions.append([partner.mail_partner_format() for partner in self.env.ref('base.group_user').users.partner_id])
+ except AccessError:
+ pass
+ return suggestions
+
+ @api.model
+ def get_mention_suggestions(self, search, limit=8, channel_id=None):
+ """ Return 'limit'-first partners' id, name and email such that the name or email matches a
+ 'search' string. Prioritize users, and then extend the research to all partners.
+ If channel_id is given, only members of this channel are returned.
+ """
+ search_dom = expression.OR([[('name', 'ilike', search)], [('email', 'ilike', search)]])
+ search_dom = expression.AND([[('active', '=', True), ('type', '!=', 'private')], search_dom])
+ if channel_id:
+ search_dom = expression.AND([[('channel_ids', 'in', channel_id)], search_dom])
+
+ # Search users
+ domain = expression.AND([[('user_ids.id', '!=', False), ('user_ids.active', '=', True)], search_dom])
+ users = self.search(domain, limit=limit)
+
+ # Search partners if less than 'limit' users found
+ partners = self.env['res.partner']
+ if len(users) < limit:
+ partners = self.search(expression.AND([[('id', 'not in', users.ids)], search_dom]), limit=limit)
+
+ return [
+ [partner.mail_partner_format() for partner in users],
+ [partner.mail_partner_format() for partner in partners],
+ ]
+
+ @api.model
+ def im_search(self, name, limit=20):
+ """ Search partner with a name and return its id, name and im_status.
+ Note : the user must be logged
+ :param name : the partner name to search
+ :param limit : the limit of result to return
+ """
+ # This method is supposed to be used only in the context of channel creation or
+ # extension via an invite. As both of these actions require the 'create' access
+ # right, we check this specific ACL.
+ if self.env['mail.channel'].check_access_rights('create', raise_exception=False):
+ name = '%' + name + '%'
+ excluded_partner_ids = [self.env.user.partner_id.id]
+ self.env.cr.execute("""
+ SELECT
+ U.id as user_id,
+ P.id as id,
+ P.name as name,
+ CASE WHEN B.last_poll IS NULL THEN 'offline'
+ WHEN age(now() AT TIME ZONE 'UTC', B.last_poll) > interval %s THEN 'offline'
+ WHEN age(now() AT TIME ZONE 'UTC', B.last_presence) > interval %s THEN 'away'
+ ELSE 'online'
+ END as im_status
+ FROM res_users U
+ JOIN res_partner P ON P.id = U.partner_id
+ LEFT JOIN bus_presence B ON B.user_id = U.id
+ WHERE P.name ILIKE %s
+ AND P.id NOT IN %s
+ AND U.active = 't'
+ LIMIT %s
+ """, ("%s seconds" % DISCONNECTION_TIMER, "%s seconds" % AWAY_TIMER, name, tuple(excluded_partner_ids), limit))
+ return self.env.cr.dictfetchall()
+ else:
+ return {}
diff --git a/addons/mail/models/res_users.py b/addons/mail/models/res_users.py
new file mode 100644
index 00000000..bd4a3f5b
--- /dev/null
+++ b/addons/mail/models/res_users.py
@@ -0,0 +1,176 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import _, api, exceptions, fields, models, modules
+from odoo.addons.base.models.res_users import is_selection_groups
+
+
+class Users(models.Model):
+ """ Update of res.users class
+ - add a preference about sending emails about notifications
+ - make a new user follow itself
+ - add a welcome message
+ - add suggestion preference
+ - if adding groups to a user, check mail.channels linked to this user
+ group, and the user. This is done by overriding the write method.
+ """
+ _name = 'res.users'
+ _inherit = ['res.users']
+ _description = 'Users'
+
+ notification_type = fields.Selection([
+ ('email', 'Handle by Emails'),
+ ('inbox', 'Handle in Odoo')],
+ 'Notification', required=True, default='email',
+ help="Policy on how to handle Chatter notifications:\n"
+ "- Handle by Emails: notifications are sent to your email address\n"
+ "- Handle in Odoo: notifications appear in your Odoo Inbox")
+ # channel-specific: moderation
+ is_moderator = fields.Boolean(string='Is moderator', compute='_compute_is_moderator')
+ moderation_counter = fields.Integer(string='Moderation count', compute='_compute_moderation_counter')
+ moderation_channel_ids = fields.Many2many(
+ 'mail.channel', 'mail_channel_moderator_rel',
+ string='Moderated channels')
+
+ @api.depends('moderation_channel_ids.moderation', 'moderation_channel_ids.moderator_ids')
+ def _compute_is_moderator(self):
+ moderated = self.env['mail.channel'].search([
+ ('id', 'in', self.mapped('moderation_channel_ids').ids),
+ ('moderation', '=', True),
+ ('moderator_ids', 'in', self.ids)
+ ])
+ user_ids = moderated.mapped('moderator_ids')
+ for user in self:
+ user.is_moderator = user in user_ids
+
+ def _compute_moderation_counter(self):
+ self._cr.execute("""
+SELECT channel_moderator.res_users_id, COUNT(msg.id)
+FROM "mail_channel_moderator_rel" AS channel_moderator
+JOIN "mail_message" AS msg
+ON channel_moderator.mail_channel_id = msg.res_id
+ AND channel_moderator.res_users_id IN %s
+ AND msg.model = 'mail.channel'
+ AND msg.moderation_status = 'pending_moderation'
+GROUP BY channel_moderator.res_users_id""", [tuple(self.ids)])
+ result = dict(self._cr.fetchall())
+ for user in self:
+ user.moderation_counter = result.get(user.id, 0)
+
+ def __init__(self, pool, cr):
+ """ Override of __init__ to add access rights on notification_email_send
+ fields. Access rights are disabled by default, but allowed on some
+ specific fields defined in self.SELF_{READ/WRITE}ABLE_FIELDS.
+ """
+ init_res = super(Users, self).__init__(pool, cr)
+ # duplicate list to avoid modifying the original reference
+ type(self).SELF_WRITEABLE_FIELDS = list(self.SELF_WRITEABLE_FIELDS)
+ type(self).SELF_WRITEABLE_FIELDS.extend(['notification_type'])
+ # duplicate list to avoid modifying the original reference
+ type(self).SELF_READABLE_FIELDS = list(self.SELF_READABLE_FIELDS)
+ type(self).SELF_READABLE_FIELDS.extend(['notification_type'])
+ return init_res
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for values in vals_list:
+ if not values.get('login', False):
+ action = self.env.ref('base.action_res_users')
+ msg = _("You cannot create a new user from here.\n To create new user please go to configuration panel.")
+ raise exceptions.RedirectWarning(msg, action.id, _('Go to the configuration panel'))
+
+ users = super(Users, self).create(vals_list)
+ # Auto-subscribe to channels
+ self.env['mail.channel'].search([('group_ids', 'in', users.groups_id.ids)])._subscribe_users()
+ return users
+
+ def write(self, vals):
+ write_res = super(Users, self).write(vals)
+ if 'active' in vals and not vals['active']:
+ self._unsubscribe_from_channels()
+ sel_groups = [vals[k] for k in vals if is_selection_groups(k) and vals[k]]
+ if vals.get('groups_id'):
+ # form: {'group_ids': [(3, 10), (3, 3), (4, 10), (4, 3)]} or {'group_ids': [(6, 0, [ids]}
+ user_group_ids = [command[1] for command in vals['groups_id'] if command[0] == 4]
+ user_group_ids += [id for command in vals['groups_id'] if command[0] == 6 for id in command[2]]
+ self.env['mail.channel'].search([('group_ids', 'in', user_group_ids)])._subscribe_users()
+ elif sel_groups:
+ self.env['mail.channel'].search([('group_ids', 'in', sel_groups)])._subscribe_users()
+ return write_res
+
+ def unlink(self):
+ self._unsubscribe_from_channels()
+ return super().unlink()
+
+ def _unsubscribe_from_channels(self):
+ """ This method un-subscribes users from private mail channels. Main purpose of this
+ method is to prevent sending internal communication to archived / deleted users.
+ We do not un-subscribes users from public channels because in most common cases,
+ public channels are mailing list (e-mail based) and so users should always receive
+ updates from public channels until they manually un-subscribe themselves.
+ """
+ self.mapped('partner_id.channel_ids').filtered(lambda c: c.public != 'public' and c.channel_type == 'channel').write({
+ 'channel_partner_ids': [(3, pid) for pid in self.mapped('partner_id').ids]
+ })
+
+ @api.model
+ def systray_get_activities(self):
+ query = """SELECT m.id, count(*), act.res_model as model,
+ CASE
+ WHEN %(today)s::date - act.date_deadline::date = 0 Then 'today'
+ WHEN %(today)s::date - act.date_deadline::date > 0 Then 'overdue'
+ WHEN %(today)s::date - act.date_deadline::date < 0 Then 'planned'
+ END AS states
+ FROM mail_activity AS act
+ JOIN ir_model AS m ON act.res_model_id = m.id
+ WHERE user_id = %(user_id)s
+ GROUP BY m.id, states, act.res_model;
+ """
+ self.env.cr.execute(query, {
+ 'today': fields.Date.context_today(self),
+ 'user_id': self.env.uid,
+ })
+ activity_data = self.env.cr.dictfetchall()
+ model_ids = [a['id'] for a in activity_data]
+ model_names = {n[0]: n[1] for n in self.env['ir.model'].browse(model_ids).name_get()}
+
+ user_activities = {}
+ for activity in activity_data:
+ if not user_activities.get(activity['model']):
+ module = self.env[activity['model']]._original_module
+ icon = module and modules.module.get_module_icon(module)
+ user_activities[activity['model']] = {
+ 'name': model_names[activity['id']],
+ 'model': activity['model'],
+ 'type': 'activity',
+ 'icon': icon,
+ 'total_count': 0, 'today_count': 0, 'overdue_count': 0, 'planned_count': 0,
+ }
+ user_activities[activity['model']]['%s_count' % activity['states']] += activity['count']
+ if activity['states'] in ('today', 'overdue'):
+ user_activities[activity['model']]['total_count'] += activity['count']
+
+ user_activities[activity['model']]['actions'] = [{
+ 'icon': 'fa-clock-o',
+ 'name': 'Summary',
+ }]
+ return list(user_activities.values())
+
+
+class res_groups_mail_channel(models.Model):
+ """ Update of res.groups class
+ - if adding users from a group, check mail.channels linked to this user
+ group and subscribe them. This is done by overriding the write method.
+ """
+ _name = 'res.groups'
+ _inherit = 'res.groups'
+ _description = 'Access Groups'
+
+ def write(self, vals, context=None):
+ write_res = super(res_groups_mail_channel, self).write(vals)
+ if vals.get('users'):
+ # form: {'group_ids': [(3, 10), (3, 3), (4, 10), (4, 3)]} or {'group_ids': [(6, 0, [ids]}
+ user_ids = [command[1] for command in vals['users'] if command[0] == 4]
+ user_ids += [id for command in vals['users'] if command[0] == 6 for id in command[2]]
+ self.env['mail.channel'].search([('group_ids', 'in', self._ids)])._subscribe_users()
+ return write_res
diff --git a/addons/mail/models/update.py b/addons/mail/models/update.py
new file mode 100644
index 00000000..edf5b4f2
--- /dev/null
+++ b/addons/mail/models/update.py
@@ -0,0 +1,125 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import datetime
+import logging
+
+import requests
+import werkzeug.urls
+
+from ast import literal_eval
+
+from odoo import api, release, SUPERUSER_ID
+from odoo.exceptions import UserError
+from odoo.models import AbstractModel
+from odoo.tools.translate import _
+from odoo.tools import config, misc, ustr
+
+_logger = logging.getLogger(__name__)
+
+
+class PublisherWarrantyContract(AbstractModel):
+ _name = "publisher_warranty.contract"
+ _description = 'Publisher Warranty Contract'
+
+ @api.model
+ def _get_message(self):
+ Users = self.env['res.users']
+ IrParamSudo = self.env['ir.config_parameter'].sudo()
+
+ dbuuid = IrParamSudo.get_param('database.uuid')
+ db_create_date = IrParamSudo.get_param('database.create_date')
+ limit_date = datetime.datetime.now()
+ limit_date = limit_date - datetime.timedelta(15)
+ limit_date_str = limit_date.strftime(misc.DEFAULT_SERVER_DATETIME_FORMAT)
+ nbr_users = Users.search_count([('active', '=', True)])
+ nbr_active_users = Users.search_count([("login_date", ">=", limit_date_str), ('active', '=', True)])
+ nbr_share_users = 0
+ nbr_active_share_users = 0
+ if "share" in Users._fields:
+ nbr_share_users = Users.search_count([("share", "=", True), ('active', '=', True)])
+ nbr_active_share_users = Users.search_count([("share", "=", True), ("login_date", ">=", limit_date_str), ('active', '=', True)])
+ user = self.env.user
+ domain = [('application', '=', True), ('state', 'in', ['installed', 'to upgrade', 'to remove'])]
+ apps = self.env['ir.module.module'].sudo().search_read(domain, ['name'])
+
+ enterprise_code = IrParamSudo.get_param('database.enterprise_code')
+
+ web_base_url = IrParamSudo.get_param('web.base.url')
+ msg = {
+ "dbuuid": dbuuid,
+ "nbr_users": nbr_users,
+ "nbr_active_users": nbr_active_users,
+ "nbr_share_users": nbr_share_users,
+ "nbr_active_share_users": nbr_active_share_users,
+ "dbname": self._cr.dbname,
+ "db_create_date": db_create_date,
+ "version": release.version,
+ "language": user.lang,
+ "web_base_url": web_base_url,
+ "apps": [app['name'] for app in apps],
+ "enterprise_code": enterprise_code,
+ }
+ if user.partner_id.company_id:
+ company_id = user.partner_id.company_id
+ msg.update(company_id.read(["name", "email", "phone"])[0])
+ return msg
+
+ @api.model
+ def _get_sys_logs(self):
+ """
+ Utility method to send a publisher warranty get logs messages.
+ """
+ msg = self._get_message()
+ arguments = {'arg0': ustr(msg), "action": "update"}
+
+ url = config.get("publisher_warranty_url")
+
+ r = requests.post(url, data=arguments, timeout=30)
+ r.raise_for_status()
+ return literal_eval(r.text)
+
+ def update_notification(self, cron_mode=True):
+ """
+ Send a message to Odoo's publisher warranty server to check the
+ validity of the contracts, get notifications, etc...
+
+ @param cron_mode: If true, catch all exceptions (appropriate for usage in a cron).
+ @type cron_mode: boolean
+ """
+ try:
+ try:
+ result = self._get_sys_logs()
+ except Exception:
+ if cron_mode: # we don't want to see any stack trace in cron
+ return False
+ _logger.debug("Exception while sending a get logs messages", exc_info=1)
+ raise UserError(_("Error during communication with the publisher warranty server."))
+ # old behavior based on res.log; now on mail.message, that is not necessarily installed
+ user = self.env['res.users'].sudo().browse(SUPERUSER_ID)
+ poster = self.sudo().env.ref('mail.channel_all_employees')
+ if not (poster and poster.exists()):
+ if not user.exists():
+ return True
+ poster = user
+ for message in result["messages"]:
+ try:
+ poster.message_post(body=message, subtype_xmlid='mail.mt_comment', partner_ids=[user.partner_id.id])
+ except Exception:
+ pass
+ if result.get('enterprise_info'):
+ # Update expiration date
+ set_param = self.env['ir.config_parameter'].sudo().set_param
+ set_param('database.expiration_date', result['enterprise_info'].get('expiration_date'))
+ set_param('database.expiration_reason', result['enterprise_info'].get('expiration_reason', 'trial'))
+ set_param('database.enterprise_code', result['enterprise_info'].get('enterprise_code'))
+ set_param('database.already_linked_subscription_url', result['enterprise_info'].get('database_already_linked_subscription_url'))
+ set_param('database.already_linked_email', result['enterprise_info'].get('database_already_linked_email'))
+ set_param('database.already_linked_send_mail_url', result['enterprise_info'].get('database_already_linked_send_mail_url'))
+
+ except Exception:
+ if cron_mode:
+ return False # we don't want to see any stack trace in cron
+ else:
+ raise
+ return True