%(dyn_help)s
" % { + '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%(dyn_help)s
" % { + '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 "%(dyn_help)s
" % { + '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 = bool(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") + 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 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 -> + 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 -> + 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--
%s
+ The Problem
+By default, Odoo provides two options for Notification Management:
+
+Handle by Emails: notifications are sent to the users' email. This is the default option for new user creation
+Handle in Odoo: notifications appear in the users' Odoo Inbox but no email sent to the users
+This module does the following
+Handle by both now offers both inbox and email notifications
+
+