[IMP] mail: avoid email loops when Odoo reply automatically
Purpose ======= In this commit, we will fix the loops that occur when you send an email to an alias (to create a ticket e.g.). In that case Odoo can reply "Your ticket has been created" and then the auto-replier of the user can reply to this email and the loop occurs. Specifications ============== To solve this issues, we add 2 system parameters - <mail.gateway.loop.minutes>, 120 minutes by default - <mail.gateway.loop.threshold>, 20 by default When an email is sent to an alias, we look on the last records created <mail.gateway.loop.minutes> minutes ago. If we overcome the limit <mail.gateway.loop.threshold> the email is ignored. Alias creation detection ======================== To detect the number of records created by a specific email address we use the `_primary_email` attribute set on the model. Task-2294034 closes odoo/odoo#78597 Related: odoo/enterprise#21772 Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
@@ -123,6 +123,7 @@ class Applicant(models.Model):
|
||||
_order = "priority desc, id desc"
|
||||
_inherit = ['mail.thread.cc', 'mail.activity.mixin', 'utm.mixin']
|
||||
_mailing_enabled = True
|
||||
_primary_email = 'email_from'
|
||||
|
||||
name = fields.Char("Subject / Application", required=True, help="Email subject for applications sent via email", index='trigram')
|
||||
active = fields.Boolean("Active", default=True, help="If the active field is set to false, it will allow you to hide the case without removing it.")
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
'data/mail_message_subtype_data.xml',
|
||||
'data/mail_templates_chatter.xml',
|
||||
'data/mail_templates_email_layouts.xml',
|
||||
'data/mail_templates_mailgateway.xml',
|
||||
'data/mail_channel_data.xml',
|
||||
'data/mail_activity_data.xml',
|
||||
'data/ir_cron_data.xml',
|
||||
|
||||
@@ -59,25 +59,5 @@
|
||||
</t>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Mail gateway templates -->
|
||||
<template id="mail_bounce_catchall">
|
||||
<div>
|
||||
<p>Hello <t t-esc="message['email_from']"/>,</p>
|
||||
<p>The email sent to <t t-esc="message['to']"/> cannot be processed. This address
|
||||
is used to collect replies and should not be used to directly contact <t t-esc="res_company.name"/>.</p>
|
||||
<p>Please contact us instead using <a t-att-href="'mailto:%s' % res_company.email"><t t-esc="res_company.email"/></a></p>
|
||||
<p>Regards,</p>
|
||||
<p>The <t t-esc="res_company.name"/> team.</p>
|
||||
</div>
|
||||
<blockquote><t t-esc="message['body']"/></blockquote>
|
||||
</template>
|
||||
|
||||
<!-- Mail bounce alias mail template -->
|
||||
<template id="mail_bounce_alias_security">
|
||||
<div><t t-out="body"/></div>
|
||||
<blockquote><t t-out="message['body']"/></blockquote>
|
||||
</template>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<template id="message_notification_limit_email">
|
||||
<p>Dear Sender,</p>
|
||||
<p>
|
||||
The message below could not be accepted by the address <t t-esc="email"/> because you have
|
||||
contacted it too many times in the last few minutes.
|
||||
<br/>
|
||||
Please try again later.
|
||||
</p>
|
||||
<p>Kind Regards</p>
|
||||
</template>
|
||||
|
||||
<template id="mail_bounce_catchall">
|
||||
<div>
|
||||
<p>Hello <t t-esc="message['email_from']"/>,</p>
|
||||
<p>The email sent to <t t-esc="message['to']"/> cannot be processed. This address
|
||||
is used to collect replies and should not be used to directly contact <t t-esc="res_company.name"/>.</p>
|
||||
<p>Please contact us instead using <a t-att-href="'mailto:%s' % res_company.email"><t t-esc="res_company.email"/></a></p>
|
||||
<p>Regards,</p>
|
||||
<p>The <t t-esc="res_company.name"/> team.</p>
|
||||
</div>
|
||||
<blockquote><t t-esc="message['body']"/></blockquote>
|
||||
</template>
|
||||
|
||||
<!-- Mail bounce alias mail template -->
|
||||
<template id="mail_bounce_alias_security">
|
||||
<div><t t-out="body"/></div>
|
||||
<blockquote><t t-out="message['body']"/></blockquote>
|
||||
</template>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -74,6 +74,7 @@ class MailThread(models.AbstractModel):
|
||||
_description = 'Email Thread'
|
||||
_mail_flat_thread = True # flatten the discussion history
|
||||
_mail_post_access = 'write' # access required on the document to post on it
|
||||
_primary_email = 'email' # Must be set for the models that can be created by alias
|
||||
_Attachment = namedtuple('Attachment', ('fname', 'content', 'info'))
|
||||
|
||||
message_is_follower = fields.Boolean(
|
||||
@@ -808,6 +809,116 @@ class MailThread(models.AbstractModel):
|
||||
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 _check_primary_email(self):
|
||||
"""Check if the "_primary_email" model attribute is correctly set.
|
||||
|
||||
If it is correctly set, return the _primary_email field name
|
||||
Otherwise return None
|
||||
"""
|
||||
primary_email = getattr(self, '_primary_email', None)
|
||||
if primary_email and primary_email in self._fields:
|
||||
return primary_email
|
||||
|
||||
@api.model
|
||||
def _routing_detect_loop_from_records_domain(self, email_from_normalized):
|
||||
"""Return the domain to be used to detect duplicated records created by alias.
|
||||
|
||||
:param email_from_normalized: FROM of the incoming email, normalized
|
||||
"""
|
||||
primary_email = self._check_primary_email()
|
||||
if primary_email:
|
||||
return [(primary_email, 'ilike', email_from_normalized)]
|
||||
|
||||
_logger.info('Primary email missing on %s', self._name)
|
||||
|
||||
@api.model
|
||||
def _routing_detect_loop_from_records(self, message, message_dict, routes):
|
||||
"""This method returns True if the incoming email should be ignored.
|
||||
|
||||
The goal of this method is to prevent loops which can occur if an auto-replier
|
||||
send emails to Odoo.
|
||||
"""
|
||||
email_from = message_dict.get('email_from')
|
||||
if not email_from:
|
||||
return False
|
||||
|
||||
email_from_normalized = tools.email_normalize(email_from)
|
||||
|
||||
# Detect the email address sent to many emails
|
||||
get_param = self.env['ir.config_parameter'].sudo().get_param
|
||||
# Period in minutes in which we will look for <mail.mail>
|
||||
INCOMING_LIMIT_PERIOD = int(get_param('mail.gateway.loop.minutes', 120))
|
||||
INCOMING_LIMIT_ALIAS = int(get_param('mail.gateway.loop.threshold', 20))
|
||||
|
||||
create_date_limit = self.env.cr.now() - datetime.timedelta(minutes=INCOMING_LIMIT_PERIOD)
|
||||
|
||||
# Search only once per model
|
||||
models = {
|
||||
self.env[model]
|
||||
for model, thread_id, *__ in routes or []
|
||||
if not thread_id # Reply to an existing thread
|
||||
}
|
||||
|
||||
for model in models:
|
||||
if not hasattr(model, '_routing_detect_loop_from_records_domain'):
|
||||
continue
|
||||
|
||||
domain = model._routing_detect_loop_from_records_domain(email_from_normalized)
|
||||
|
||||
if not domain:
|
||||
_logger.info('Domain missing for %s, could not find duplicated.', self._name)
|
||||
continue
|
||||
|
||||
mail_incoming_messages_count = model.sudo().search_count(
|
||||
expression.AND([
|
||||
[('create_date', '>', create_date_limit)],
|
||||
domain,
|
||||
]),
|
||||
)
|
||||
|
||||
if mail_incoming_messages_count >= INCOMING_LIMIT_ALIAS:
|
||||
_logger.info('Email address %s created too many <%s>.', email_from, model)
|
||||
|
||||
body = self.env['ir.qweb']._render(
|
||||
'mail.message_notification_limit_email',
|
||||
{'email': message_dict.get('to')},
|
||||
minimal_qcontext=True,
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
|
||||
# Add a reference with a tag, to be able to ignore response to this email
|
||||
references = (
|
||||
message_dict.get('message_id', '') + ' '
|
||||
+ tools.generate_tracking_message_id('loop-detection-bounce-email')
|
||||
)
|
||||
self._routing_create_bounce_email(email_from, body, message, references=references)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@api.model
|
||||
def _routing_detect_loop_from_headers(self, message, msg_dict):
|
||||
"""Detect common headers set by the auto-repliers / mailing lists."""
|
||||
|
||||
# Detect if the email has been sent by an auto-replier or by a mailing list
|
||||
for header in ('X-Autoreply', 'X-Autorespond', 'Feedback-ID', 'List-Id', 'List-Unsubscribe'):
|
||||
if header in message:
|
||||
_logger.info('Email sent by an auto-replier (%s), ignoring it.', header)
|
||||
return True
|
||||
|
||||
precedence = 'Precedence' in message and tools.decode_message_header(message, 'Precedence')
|
||||
if precedence and precedence.lower() in ('bulk', 'auto_reply', 'list'):
|
||||
_logger.info('Email sent by an auto-replier (Precedence), ignoring it.')
|
||||
return True
|
||||
|
||||
if '-loop-detection-bounce-email@' in msg_dict.get('references', '') \
|
||||
or '-loop-detection-bounce-email@' in msg_dict.get('in_reply_to', ''):
|
||||
_logger.info('Email is a reply to the bounce notification, ignoring it.')
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@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,
|
||||
@@ -1091,8 +1202,14 @@ class MailThread(models.AbstractModel):
|
||||
msg_dict.get('email_from'), msg_dict.get('to'), msg_dict.get('message_id'))
|
||||
return False
|
||||
|
||||
if self._routing_detect_loop_from_headers(message, msg_dict):
|
||||
return
|
||||
|
||||
# find possible routes for the message
|
||||
routes = self.message_route(message, msg_dict, model, thread_id, custom_values)
|
||||
if self._routing_detect_loop_from_records(message, msg_dict, routes):
|
||||
return
|
||||
|
||||
thread_id = self._message_route_process(message, msg_dict, routes)
|
||||
return thread_id
|
||||
|
||||
@@ -1123,6 +1240,11 @@ class MailThread(models.AbstractModel):
|
||||
name_field = self._rec_name or 'name'
|
||||
if name_field in fields and not data.get('name'):
|
||||
data[name_field] = msg_dict.get('subject', '')
|
||||
|
||||
primary_email = self._check_primary_email()
|
||||
if primary_email and msg_dict.get('email_from'):
|
||||
data[primary_email] = msg_dict['email_from']
|
||||
|
||||
return self.create(data)
|
||||
|
||||
def message_update(self, msg_dict, update_vals=None):
|
||||
|
||||
@@ -124,3 +124,11 @@ class MailBlackListMixin(models.AbstractModel):
|
||||
}
|
||||
else:
|
||||
raise AccessError(_("You do not have the access right to unblacklist emails. Please contact your administrator."))
|
||||
|
||||
@api.model
|
||||
def _routing_detect_loop_from_records_domain(self, email_from_normalized):
|
||||
"""Return the domain to be used to detect duplicated records created by alias.
|
||||
|
||||
:param email_from_normalized: FROM of the incoming email, normalized
|
||||
"""
|
||||
return [('email_normalized', '=', email_from_normalized)]
|
||||
|
||||
@@ -114,11 +114,13 @@ class MockEmail(common.BaseCase, MockSmtplibCase):
|
||||
def format(self, template, to='groups@example.com, other@gmail.com', subject='Frogs',
|
||||
email_from='Sylvie Lelitre <test.sylvie.lelitre@agrolait.com>', return_path='', cc='',
|
||||
extra='', msg_id='<1198923581.41972151344608186760.JavaMail@agrolait.com>',
|
||||
**kwargs):
|
||||
references='', **kwargs):
|
||||
if not return_path:
|
||||
return_path = '<whatever-2a840@postmaster.twitter.com>'
|
||||
return template.format(
|
||||
subject=subject, to=to, cc=cc,
|
||||
email_from=email_from, return_path=return_path,
|
||||
extra=extra, msg_id=msg_id,
|
||||
extra=extra, msg_id=msg_id, references=references,
|
||||
**kwargs)
|
||||
|
||||
def format_and_process(self, template, email_from, to, subject='Frogs', cc='',
|
||||
|
||||
@@ -21,6 +21,7 @@ class MailGroupMessage(models.Model):
|
||||
_description = 'Mailing List Message'
|
||||
_rec_name = 'subject'
|
||||
_order = 'create_date DESC'
|
||||
_primary_email = 'email_from'
|
||||
|
||||
# <mail.message> fields, can not be done with inherits because it will impact
|
||||
# the performance of the <mail.message> model (different cache, so the ORM will need
|
||||
|
||||
@@ -941,6 +941,7 @@ class Task(models.Model):
|
||||
_inherit = ['portal.mixin', 'mail.thread.cc', 'mail.activity.mixin', 'rating.mixin']
|
||||
_mail_post_access = 'read'
|
||||
_order = "priority desc, sequence, id desc"
|
||||
_primary_email = 'email_from'
|
||||
_check_company_auto = True
|
||||
|
||||
@api.model
|
||||
@@ -2155,7 +2156,6 @@ class Task(models.Model):
|
||||
custom_values = {}
|
||||
defaults = {
|
||||
'name': msg.get('subject') or _("No Subject"),
|
||||
'email_from': msg.get('from'),
|
||||
'planned_hours': 0.0,
|
||||
'partner_id': msg.get('author_id'),
|
||||
'description': msg.get('body'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
MAIL_TEMPLATE = """Return-Path: <whatever-2a840@postmaster.twitter.com>
|
||||
MAIL_TEMPLATE = """Return-Path: {return_path}
|
||||
To: {to}
|
||||
cc: {cc}
|
||||
Received: by mail1.openerp.com (Postfix, from userid 10002)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
import socket
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from unittest.mock import DEFAULT
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -12,6 +14,7 @@ from odoo.addons.test_mail.data import test_mail_data
|
||||
from odoo.addons.test_mail.data.test_mail_data import MAIL_TEMPLATE
|
||||
from odoo.addons.test_mail.models.test_mail_models import MailTestGateway
|
||||
from odoo.addons.test_mail.tests.common import TestMailCommon
|
||||
from odoo.sql_db import Cursor
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import users
|
||||
from odoo.tools import email_split_and_format, formataddr, mute_logger
|
||||
@@ -1403,6 +1406,84 @@ class TestMailgateway(TestMailCommon):
|
||||
self.assertEqual(record.name, 'Spammy')
|
||||
self.assertEqual(record._name, 'mail.test.gateway')
|
||||
|
||||
# --------------------------------------------------
|
||||
# Emails loop detection
|
||||
# --------------------------------------------------
|
||||
|
||||
@mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.addons.mail.models.mail_mail')
|
||||
@patch.object(Cursor, 'now', lambda *args, **kwargs: datetime(2022, 1, 1, 10, 0, 0))
|
||||
def test_routing_loop_alias(self):
|
||||
"""Test the limit on the number of record we can create by alias."""
|
||||
self.env['ir.config_parameter'].sudo().set_param('mail.gateway.loop.minutes', 30)
|
||||
self.env['ir.config_parameter'].sudo().set_param('mail.gateway.loop.threshold', 5)
|
||||
|
||||
alias = self.env['mail.alias'].create({
|
||||
'alias_name': 'test',
|
||||
'alias_user_id': False,
|
||||
'alias_model_id': self.env['ir.model']._get('mail.test.container').id,
|
||||
'alias_contact': 'everyone',
|
||||
})
|
||||
|
||||
# Send an email 2 hours ago, should not have an impact on more recent emails
|
||||
with patch.object(Cursor, 'now', lambda *args, **kwargs: datetime(2022, 1, 1, 8, 0, 0)):
|
||||
self.format_and_process(
|
||||
MAIL_TEMPLATE,
|
||||
self.email_from,
|
||||
f'{self.alias.alias_name}@{self.alias_domain}',
|
||||
subject='Test alias loop old',
|
||||
target_model=alias.alias_model_id.model,
|
||||
)
|
||||
|
||||
for i in range(5):
|
||||
self.format_and_process(
|
||||
MAIL_TEMPLATE,
|
||||
self.email_from,
|
||||
f'{self.alias.alias_name}@{self.alias_domain}',
|
||||
subject=f'Test alias loop {i}',
|
||||
target_model=alias.alias_model_id.model,
|
||||
)
|
||||
|
||||
records = self.env['mail.test.gateway'].search([('name', 'ilike', 'Test alias loop %')])
|
||||
self.assertEqual(len(records), 6, 'Should have created 6 <mail.test.gateway>')
|
||||
self.assertEqual(set(records.mapped('email_from')), set([self.email_from]),
|
||||
msg='Should have automatically filled the email field')
|
||||
|
||||
self.assertEqual(set(records.mapped('email_from')), {self.email_from})
|
||||
|
||||
for email_from in (self.email_from, self.email_from.upper()):
|
||||
with self.mock_mail_gateway():
|
||||
self.format_and_process(
|
||||
MAIL_TEMPLATE,
|
||||
email_from,
|
||||
f'{self.alias.alias_name}@{self.alias_domain}',
|
||||
subject='Test alias loop X',
|
||||
target_model=alias.alias_model_id.model,
|
||||
return_path=email_from,
|
||||
)
|
||||
|
||||
new_record = self.env['mail.test.gateway'].search([('name', '=', 'Test alias loop X')])
|
||||
self.assertFalse(
|
||||
new_record,
|
||||
msg='The loop should have been detected and the record should not have been created')
|
||||
|
||||
self.assertSentEmail('"MAILER-DAEMON" <bounce.test@test.com>', [email_from])
|
||||
self.assertIn('-loop-detection-bounce-email@', self._mails[0]['references'],
|
||||
msg='The "bounce email" tag must be in the reference')
|
||||
|
||||
# The reply to the bounce email must be ignored
|
||||
with self.mock_mail_gateway():
|
||||
self.format_and_process(
|
||||
MAIL_TEMPLATE,
|
||||
self.email_from,
|
||||
f'{self.alias.alias_name}@{self.alias_domain}',
|
||||
subject='Test alias loop X',
|
||||
target_model=alias.alias_model_id.model,
|
||||
return_path=self.email_from,
|
||||
extra='References: <test-1337-loop-detection-bounce-email@odoo.com>',
|
||||
)
|
||||
|
||||
self.assertNotSentEmail()
|
||||
|
||||
|
||||
class TestMailThreadCC(TestMailCommon):
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ class MailingSimple(models.Model):
|
||||
_description = 'Simple Mailing'
|
||||
_name = 'mailing.test.simple'
|
||||
_inherit = ['mail.thread']
|
||||
_primary_email = 'email_from'
|
||||
|
||||
name = fields.Char()
|
||||
email_from = fields.Char()
|
||||
|
||||
Reference in New Issue
Block a user