[MERGE] mail: add various mailgateway improvements

This merge holds several long awaited improvements for the mail gateway.
It includes

 * store email status of notifications emails sent to customers
 * display this information on Chatter
 * improve bounce email management: track bounced notifications emails, use
   message_bounce fields directly in mail and not in mass_mailing
 * send emails to assigned user on records
 * remove required alias on users while keeping it possible to define alias
   on users and use them
 * handle emails forwarded to aliases as new threads instead of replies
 * do not store needaction for forum and blog posts
 * automatically unsubscribe partners that bounce on channels
 * some light code cleaning
This commit is contained in:
Thibault Delavallée
2016-09-01 13:33:46 +02:00
46 changed files with 770 additions and 496 deletions
@@ -9,8 +9,6 @@
<field name="active" eval="False"/>
<!-- Avoid auto-including this user in any default group, just like a typical portal member -->
<field name="groups_id" eval="[(6, 0, [ref('base.group_portal')])]"/>
<!-- allow signuped users to have a alias -->
<field name="alias_name">_usertemplate</field>
</record>
<record id="default_template_user_config" model="ir.config_parameter">
-1
View File
@@ -18,7 +18,6 @@ class TestCrm(TestMail):
cls.user_salesman_all = Users.create({
'name': 'Riton La Chignole',
'login': 'riton',
'alias_name': 'riton',
'email': 'riton.salesman_all@example.com',
'groups_id': [(6, 0, [user_group_employee.id, user_group_salesman_all.id])]
})
-2
View File
@@ -27,7 +27,6 @@ class TestEventCommon(common.TransactionCase):
self.user_eventuser = self.Users.with_context({'no_reset_password': True}).create({
'name': 'Armande EventUser',
'login': 'Armande',
'alias_name': 'armande',
'email': 'armande.eventuser@example.com',
'tz': 'Europe/Brussels',
'groups_id': [(6, 0, [self.group_employee_id, self.group_event_user_id])]
@@ -35,7 +34,6 @@ class TestEventCommon(common.TransactionCase):
self.user_eventmanager = self.Users.with_context({'no_reset_password': True}).create({
'name': 'Bastien EventManager',
'login': 'bastien',
'alias_name': 'bastien',
'email': 'bastien.eventmanager@example.com',
'tz': 'Europe/Brussels',
'groups_id': [(6, 0, [
+17 -28
View File
@@ -3,45 +3,34 @@
import logging
from odoo import api, models
from odoo.addons.mail.models.mail_thread import decode_header
from odoo.tools import email_split
from odoo import api, models, _
from odoo.tools import decode_message_header, email_split
_logger = logging.getLogger(__name__)
class MailThread(models.AbstractModel):
_inherit = "mail.thread"
@api.model
def message_route_verify(self, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, allow_private=False):
res = super(MailThread, self).message_route_verify(message, message_dict, route, update_author, assert_model, create_fallback, allow_private)
def message_route_verify(self, message, message_dict, route,
update_author=True, assert_model=True,
create_fallback=True, allow_private=False,
drop_alias=False):
res = super(MailThread, self).message_route_verify(
message, message_dict, route,
update_author=update_author,
assert_model=assert_model,
create_fallback=create_fallback,
allow_private=allow_private,
drop_alias=drop_alias)
if res:
alias = route[4]
email_from = decode_header(message, 'From')
email_from = decode_message_header(message, 'From')
message_id = message.get('Message-Id')
# Identically equal to the definition in mail module because sub methods are local
# variables and cannot be called with super
def _create_bounce_email(body_html):
bounce_to = decode_header(message, 'Return-Path') or email_from
bounce_mail_values = {
'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'] = 'MAILER-DAEMON <%s>' % bounce_from
self.env['mail.mail'].create(bounce_mail_values).send()
def _warn(message):
_logger.info('Routing mail with Message-Id %s: route %s: %s',
message_id, route, message)
# Alias: check alias_contact settings for employees
if alias and alias.alias_contact == 'employees':
email_address = email_split(email_from)[0]
employee = self.env['hr.employee'].search([('work_email', 'ilike', email_address)], limit=1)
@@ -49,7 +38,7 @@ class MailThread(models.AbstractModel):
employee = self.env['hr.employee'].search([('user_id.email', 'ilike', email_address)], limit=1)
if not employee:
mail_template = self.env.ref('hr.mail_template_data_unknown_employee_email_address')
_warn('alias %s does not accept unknown employees, skipping' % alias.alias_name)
_create_bounce_email(mail_template.body_html)
self._routing_warn(_('alias %s does not accept unknown employees') % alias.alias_name, _('skipping'), message_id, route, False)
self._routing_create_bounce_email(email_from, mail_template.body_html, message)
return False
return res
-3
View File
@@ -18,21 +18,18 @@ class TestHrHolidaysBase(common.TransactionCase):
self.user_hruser_id = Users.create({
'name': 'Armande HrUser',
'login': 'Armande',
'alias_name': 'armande',
'email': 'armande.hruser@example.com',
'groups_id': [(6, 0, [group_employee_id, self.ref('base.group_hr_user')])]
}).id
self.user_hrmanager_id = Users.create({
'name': 'Bastien HrManager',
'login': 'bastien',
'alias_name': 'bastien',
'email': 'bastien.hrmanager@example.com',
'groups_id': [(6, 0, [group_employee_id, self.ref('base.group_hr_manager')])]
}).id
self.user_employee_id = Users.create({
'name': 'David Employee',
'login': 'david',
'alias_name': 'david',
'email': 'david.employee@example.com',
'groups_id': [(6, 0, [group_employee_id])]
}).id
+1
View File
@@ -3,3 +3,4 @@
import models
import wizard
import controllers
import tests.test_mail_model
+7 -1
View File
@@ -39,6 +39,12 @@
<field name="value">catchall</field>
</record>
<!-- Bounce Email Alias -->
<record id="icp_mail_bounce_alias" model="ir.config_parameter">
<field name="key">mail.bounce.alias</field>
<field name="value">bounce</field>
</record>
<!-- Discussion subtype for messaging / Chatter -->
<record id="mt_comment" model="mail.message.subtype">
<field name="name">Discussions</field>
@@ -70,7 +76,7 @@
</div>
</div>
% endif
% if not ctx['is_discussion'] or not len(ctx['actions']) == 0 or ctx.get('button_access') or ctx.get('is_accessible'):
% if not ctx['is_discussion'] or not len(ctx['actions']) == 0 or ctx.get('button_access'):
<div summary="o_mail_notification">
<table cellspacing="0" cellpadding="0" border="0" style="width: 100%; margin-top: 5px;">
<tbody><tr>
+1
View File
@@ -4,6 +4,7 @@ import mail_message_subtype
import mail_tracking_value
import mail_alias
import mail_followers
import mail_notification
import mail_message
import mail_mail
import mail_thread
+16 -4
View File
@@ -37,6 +37,8 @@ class Channel(models.Model):
_inherit = ['mail.thread']
_inherits = {'mail.alias': 'alias_id'}
MAX_BOUNCE_LIMIT = 10
def _get_default_image(self):
image_path = modules.get_module_resource('mail', 'static/src/img', 'groupdefault.png')
return tools.image_resize_image_big(open(image_path, 'rb').read().encode('base64'))
@@ -156,14 +158,17 @@ class Channel(models.Model):
@api.multi
def action_unfollow(self):
partner_id = self.env.user.partner_id.id
return self._action_unfollow(self.env.user.partner_id)
@api.multi
def _action_unfollow(self, partner):
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)]})
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', partner_id), channel_info)
result = self.write({'channel_partner_ids': [(3, partner.id)]})
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, message_type="notification", subtype="mail.mt_comment", author_id=partner_id)
self.sudo().message_post(body=notification, message_type="notification", subtype="mail.mt_comment", author_id=partner.id)
return result
@api.multi
@@ -201,6 +206,13 @@ class Channel(models.Model):
res['headers'] = repr(headers)
return res
@api.multi
def message_receive_bounce(self, email, partner, mail_id=None):
""" Override bounce management to unsubscribe bouncing addresses """
if partner.message_bounce >= self.MAX_BOUNCE_LIMIT:
self._action_unfollow(partner)
return super(Channel, self).message_receive_bounce(email, partner, mail_id=mail_id)
@api.multi
def message_get_recipient_values(self, notif_message=None, recipient_ids=None):
# real mailing list: multiple recipients (hidden by X-Forge-To)
+15 -2
View File
@@ -129,6 +129,19 @@ class MailMail(models.Model):
:param browse_record mail: the mail that was just sent
:return: True
"""
notif_emails = self.filtered(lambda email: email.notification)
if notif_emails:
notifications = self.env['mail.notification'].search([
('mail_message_id', 'in', notif_emails.mapped('mail_message_id').ids),
('is_email', '=', True)])
if mail_sent:
notifications.write({
'email_status': 'sent',
})
else:
notifications.write({
'email_status': 'exception',
})
if mail_sent:
self.sudo().filtered(lambda self: self.auto_delete).unlink()
return True
@@ -222,9 +235,9 @@ class MailMail(models.Model):
catchall_domain = self.env['ir.config_parameter'].get_param("mail.catchall.domain")
if bounce_alias and catchall_domain:
if mail.model and mail.res_id:
headers['Return-Path'] = '%s-%d-%s-%d@%s' % (bounce_alias, mail.id, mail.model, mail.res_id, catchall_domain)
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)
headers['Return-Path'] = '%s+%d@%s' % (bounce_alias, mail.id, catchall_domain)
if mail.headers:
try:
headers.update(safe_eval(mail.headers))
+57 -32
View File
@@ -3,7 +3,6 @@
import logging
from email.header import decode_header
from email.utils import formataddr
from odoo import _, api, fields, models, SUPERUSER_ID, tools
@@ -14,16 +13,6 @@ from odoo.osv import expression
_logger = logging.getLogger(__name__)
def decode(text):
"""Returns unicode() string conversion of the the given encoded smtp header text"""
# TDE proposal: move to tools ?
if text:
text = decode_header(text.replace('\r', ''))
# The joining space will not be needed as of Python 3.3
# See https://hg.python.org/cpython/rev/8c03fe231877
return ' '.join([tools.ustr(x[0], x[1]) for x in text])
class Message(models.Model):
""" Messages model: system notification (replacing res.log notifications),
comments (OpenChatter discussion) and incoming emails. """
@@ -37,11 +26,9 @@ class Message(models.Model):
@api.model
def _get_default_from(self):
if self.env.user.alias_name and self.env.user.alias_domain:
return formataddr((self.env.user.name, '%s@%s' % (self.env.user.alias_name, self.env.user.alias_domain)))
elif self.env.user.email:
if self.env.user.email:
return formataddr((self.env.user.name, self.env.user.email))
raise UserError(_("Unable to send email, please configure the sender's email address or alias."))
raise UserError(_("Unable to send email, please configure the sender's email address."))
@api.model
def _get_default_author(self):
@@ -93,6 +80,10 @@ class Message(models.Model):
help='Need Action')
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)
# user interface
starred_partner_ids = fields.Many2many(
'res.partner', 'mail_message_res_partner_starred_rel', string='Favorited By')
@@ -116,20 +107,18 @@ class Message(models.Model):
@api.multi
def _get_needaction(self):
""" Need action on a mail.message = notified on my channel """
my_messages = self.sudo().filtered(lambda msg: self.env.user.partner_id in msg.needaction_partner_ids)
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.multi
def _is_accessible(self):
self.ensure_one()
return False
@api.model
def _search_needaction(self, operator, operand):
if operator == '=' and operand:
return [('needaction_partner_ids', 'in', self.env.user.partner_id.id)]
return [('needaction_partner_ids', 'not in', self.env.user.partner_id.id)]
return ['&', ('notification_ids.res_partner_id', '=', self.env.user.partner_id.id), ('notification_ids.is_read', '=', False)]
return ['&', ('notification_ids.res_partner_id', '=', self.env.user.partner_id.id), ('notification_ids.is_read', '=', True)]
@api.depends('starred_partner_ids')
def _get_starred(self):
@@ -158,7 +147,8 @@ class Message(models.Model):
""" Remove all needactions of the current partner. If channel_ids is
given, restrict to messages written in one of those channels. """
partner_id = self.env.user.partner_id.id
if domain is None:
delete_mode = not self.env.user.share # delete employee notifs, keep customer ones
if domain is None and delete_mode:
query = "DELETE FROM mail_message_res_partner_needaction_rel WHERE res_partner_id IN %s"
args = [(partner_id,)]
if channel_ids:
@@ -181,7 +171,14 @@ class Message(models.Model):
if channel_ids:
msg_domain += [('channel_ids', 'in', channel_ids)]
unread_messages = self.search(expression.AND([msg_domain, domain]))
unread_messages.sudo().write({'needaction_partner_ids': [(3, partner_id)]})
notifications = self.env['mail.notification'].sudo().search([
('mail_message_id', 'in', unread_messages.ids),
('res_partner_id', '=', self.env.user.partner_id.id),
('is_read', '=', False)])
if delete_mode:
notifications.unlink()
else:
notifications.write({'is_read': True})
ids = unread_messages.mapped('id')
notification = {'type': 'mark_as_read', 'message_ids': ids, 'channel_ids': channel_ids}
@@ -204,14 +201,20 @@ class Message(models.Model):
def set_message_done(self):
""" Remove the needaction from messages for the current partner. """
partner_id = self.env.user.partner_id
messages = self.filtered(lambda msg: partner_id in msg.needaction_partner_ids)
if not len(messages):
delete_mode = not self.env.user.share # delete employee notifs, keep customer ones
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
messages.sudo().write({'needaction_partner_ids': [(3, partner_id.id)]})
# 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:
@@ -226,6 +229,11 @@ class Message(models.Model):
current_group = [record.id]
current_channel_ids = record.channel_ids
if delete_mode:
notifications.unlink()
else:
notifications.write({'is_read': True})
for (msg_ids, channel_ids) in groups:
notification = {'type': 'mark_as_read', 'message_ids': msg_ids, 'channel_ids': [c.id for c in channel_ids]}
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', partner_id.id), notification)
@@ -281,6 +289,8 @@ class Message(models.Model):
partners |= message.partner_ids
elif not message.subtype_id and message.partner_ids: # take specified people of message without a subtype (log)
partners |= message.partner_ids
if message.needaction_partner_ids: # notified
partners |= message.needaction_partner_ids
if message.attachment_ids:
attachments |= message.attachment_ids
if message.tracking_value_ids:
@@ -322,6 +332,11 @@ class Message(models.Model):
else:
partner_ids = [partner_tree[partner.id] for partner in message.partner_ids
if partner.id in partner_tree]
customer_email_data = []
for notification in message.notification_ids.filtered(lambda notif: notif.res_partner_id.partner_share):
customer_email_data.append((partner_tree[notification.res_partner_id.id][0], partner_tree[notification.res_partner_id.id][1], notification.email_status))
attachment_ids = []
for attachment in message.attachment_ids:
if attachment.id in attachments_tree:
@@ -334,6 +349,10 @@ class Message(models.Model):
message_dict.update({
'author_id': author,
'partner_ids': partner_ids,
'customer_email_status': (all(d[2] == 'sent' for d in customer_email_data) and 'sent') or
(any(d[2] == 'exception' for d in customer_email_data) and 'exception') or
(any(d[2] == 'bounce' for d in customer_email_data) and 'bounce') or 'ready',
'customer_email_data': customer_email_data,
'attachment_ids': attachment_ids,
'tracking_value_ids': tracking_value_ids,
})
@@ -725,7 +744,7 @@ class Message(models.Model):
#------------------------------------------------------
@api.multi
def _notify(self, force_send=False, user_signature=True):
def _notify(self, force_send=False, send_after_commit=True, user_signature=True):
""" Add the related record followers to the destination partner_ids if is not a private message.
Call mail_notification.notify to manage the email sending
"""
@@ -758,11 +777,17 @@ class Message(models.Model):
if not self._context.get('mail_notify_author', False) and self_sudo.author_id:
partners = partners - self_sudo.author_id
# update message
self.write({'channel_ids': [(6, 0, channels.ids)], 'needaction_partner_ids': [(6, 0, partners.ids)]})
# update message, with maybe custom values
message_values = {
'channel_ids': [(6, 0, channels.ids)],
'needaction_partner_ids': [(6, 0, partners.ids)]
}
if self.model and self.res_id and hasattr(self.env[self.model], 'message_get_message_notify_values'):
message_values.update(self.env[self.model].browse(self.res_id).message_get_message_notify_values(self, message_values))
self.write(message_values)
# notify partners and channels
partners._notify(self, force_send=force_send, user_signature=user_signature)
partners._notify(self, force_send=force_send, send_after_commit=send_after_commit, user_signature=user_signature)
channels._notify(self)
# Discard cache, because child / parent allow reading and therefore
+30
View File
@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class Notification(models.Model):
_name = 'mail.notification'
_table = 'mail_message_res_partner_needaction_rel'
_rec_name = 'res_partner_id'
_log_access = False
_description = 'Message Notifications'
mail_message_id = fields.Many2one(
'mail.message', 'Message', index=True, ondelete='cascade', required=True)
res_partner_id = fields.Many2one(
'res.partner', 'Needaction Recipient', index=True, ondelete='cascade', required=True)
is_read = fields.Boolean('Is Read', index=True)
is_email = fields.Boolean('Sent by Email', index=True)
email_status = fields.Selection([
('ready', 'Ready to Send'),
('sent', 'Sent'),
('bounce', 'Bounced'),
('exception', 'Exception')], 'Email Status',
default='ready', index=True)
@api.model_cr
def init(self):
self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('mail_notification_res_partner_id_is_read_email_status_mail_message_id',))
if not self._cr.fetchone():
self._cr.execute('CREATE INDEX mail_notification_res_partner_id_is_read_email_status_mail_message_id ON mail_message_res_partner_needaction_rel (res_partner_id, is_read, email_status, mail_message_id)')
+274 -224
View File
@@ -21,20 +21,12 @@ from lxml import etree
from werkzeug import url_encode
from odoo import _, api, exceptions, fields, models, tools
from odoo.addons.mail.models.mail_message import decode
from odoo.tools.safe_eval import safe_eval
_logger = logging.getLogger(__name__)
mail_header_msgid_re = re.compile('<[^<>]+>')
def decode_header(message, header, separator=' '):
return separator.join(map(decode, filter(None, message.get_all(header, []))))
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
@@ -199,7 +191,7 @@ class MailThread(models.AbstractModel):
# 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
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""",
(self.env.user.partner_id.id, self._name, tuple(self.ids),))
for result in self._cr.fetchall():
@@ -795,11 +787,35 @@ class MailThread(models.AbstractModel):
""" Find partners related to some header fields of the message.
:param string message: an email.message instance """
s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
s = ', '.join([tools.decode_smtp_header(message.get(h)) for h in header_fields if message.get(h)])
return filter(lambda x: x, self._find_partner_from_emails(tools.email_split(s)))
def _routing_warn(self, error_message, warn_suffix, message_id, route, raise_exception):
""" Tools method used in message_route_verify: whether to log a warning or raise an error """
full_message = _('Routing mail with Message-Id %s: route %s: %s') % (message_id, route, error_message)
if raise_exception:
raise ValueError(full_message)
else:
_logger.info(full_message + warn_suffix and '; %s' % warn_suffix or '')
def _routing_create_bounce_email(self, email_from, body_html, message):
bounce_to = tools.decode_message_header(message, 'Return-Path') or email_from
bounce_mail_values = {
'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'] = 'MAILER-DAEMON <%s>' % bounce_from
self.env['mail.mail'].create(bounce_mail_values).send()
@api.model
def message_route_verify(self, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, allow_private=False):
def message_route_verify(self, message, message_dict, route,
update_author=True, assert_model=True,
create_fallback=True, allow_private=False,
drop_alias=False):
""" 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
@@ -813,99 +829,82 @@ class MailThread(models.AbstractModel):
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 update_author: update message_dict['author_id']. TDE TODO: move me
:param assert_model: if an error occurs, tell whether to raise an error
or just log a warning and try other processing or
invalidate route
:param create_fallback: if the route aims at updating a record and that
record does not exists or does not support update
either fallback on creating a new record in the
same model or raise / warn
:param allow_private: allow void model / thread_id routes, aka private
discussions
"""
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.get('Message-Id')
email_from = decode_header(message, 'From')
email_from = tools.decode_message_header(message, 'From')
author_id = message_dict.get('author_id')
model, thread_id, alias = route[0], route[1], route[4]
record_set = None
def _create_bounce_email():
bounce_to = decode_header(message, 'Return-Path') or email_from
bounce_mail_values = {
'body_html': '<div><p>Hello,</p>'
'<p>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.</p></div>'
'<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
'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'] = 'MAILER-DAEMON <%s>' % bounce_from
self.env['mail.mail'].create(bounce_mail_values).send()
def _warn(message):
_logger.info('Routing mail with Message-Id %s: route %s: %s',
message_id, route, message)
_generic_bounce_body_html = """<div>
<p>Hello,</p>
<p>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.</p>
</div><blockquote>%s</blockquote>""" % (message.get('to'), message_dict.get('body')),
# Wrong model
if model and model not in self.env:
if assert_model:
assert model in self.env, 'Routing: unknown target model %s' % model
_warn('unknown target model %s' % model)
self._routing_warn(_('unknown target model %s') % model, '', message_id, route, assert_model)
return ()
# Private message: should not contain any thread_id
if not model and thread_id:
if assert_model:
if thread_id:
raise ValueError('Routing: posting a message without model should be with a null res_id (private message), received %s.' % thread_id)
_warn('posting a message without model should be with a null res_id (private message), received %s resetting thread_id' % thread_id)
thread_id = 0
# Private message: should have a parent_id (only answers)
if not model and not message_dict.get('parent_id'):
if assert_model:
if not message_dict.get('parent_id'):
raise ValueError('Routing: posting a message without model should be with a parent_id (private mesage).')
_warn('posting a message without model should be with a parent_id (private mesage), skipping')
return False
# Private message
if not model:
# should not contain any thread_id
if thread_id:
self._routing_warn(_('posting a message without model should be with a null res_id (private message), received %s') % thread_id, _('resetting thread_id'), message_id, route, assert_model)
thread_id = 0
# should have a parent_id (only answers)
if not message_dict.get('parent_id'):
self._routing_warn(_('posting a message without model should be with a parent_id (private message)'), _('skipping'), message_id, route, assert_model)
return False
if model and thread_id:
record_set = self.env[model].browse(thread_id)
elif model:
record_set = self.env[model]
# Existing Document: check if exists; if not, fallback on create if allowed
if thread_id and not record_set.exists():
if create_fallback:
_warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
# 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() and create_fallback:
self._routing_warn(_('reply to missing document (%s,%s), fall back on new document creation') % (model, thread_id), '', message_id, route, False)
thread_id = None
elif not hasattr(record_set, 'message_update') and create_fallback:
self._routing_warn(_('model %s does not accept document update, fall back on document creation') % model, '', message_id, route, False)
thread_id = None
elif assert_model:
# TDE FIXME: change assert to some real error
assert record_set.exists(), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
else:
_warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
return False
# Existing Document: check model accepts the mailgateway
if thread_id and model and not hasattr(record_set, 'message_update'):
if create_fallback:
_warn('model %s does not accept document update, fall back on document creation' % model)
thread_id = None
elif assert_model:
assert hasattr(record_set, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
else:
_warn('model %s does not accept document update, skipping' % model)
if not record_set.exists():
self._routing_warn(_('reply to missing document (%s,%s)') % (model, thread_id), _('skipping'), message_id, route, assert_model)
return False
elif not hasattr(record_set, 'message_update'):
self._routing_warn(_('model %s does not accept document update') % model, _('skipping'), message_id, route, assert_model)
return False
# New Document: check model accepts the mailgateway
if not thread_id and model and not hasattr(record_set, 'message_new'):
if assert_model:
if not hasattr(record_set, 'message_new'):
raise ValueError(
'Model %s does not accept document creation, crashing' % model
)
_warn('model %s does not accept document creation, skipping' % model)
self._routing_warn(_('model %s does not accept document creation') % model, _('skipping'), message_id, route, assert_model)
return False
# Update message author if asked
# We do it now because we need it for aliases (contact settings)
# Update message author if asked. We do it now because we need it for aliases (contact settings)
if not author_id and update_author:
author_ids = self.env['mail.thread']._find_partner_from_emails([email_from], res_model=model, res_id=thread_id)
if author_ids:
@@ -923,143 +922,185 @@ class MailThread(models.AbstractModel):
set(partner.id for channel in obj.message_channel_ids for partner in channel.channel_partner_ids)
)
if not author_id or author_id not in accepted_partner_ids:
_warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
_create_bounce_email()
self._routing_warn(_('alias %s restricted to internal followers') % alias.alias_name, _('skipping'), message_id, route, False)
self._routing_create_bounce_email(email_from, _generic_bounce_body_html, message)
return False
elif alias and alias.alias_contact == 'partners' and not author_id:
_warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
_create_bounce_email()
self._routing_warn(_('alias %s does not accept unknown author') % alias.alias_name, _('skipping'), message_id, route, False)
self._routing_create_bounce_email(email_from, _generic_bounce_body_html, message)
return False
if not model and not thread_id and not alias and not allow_private:
return ()
return False
return (model, thread_id, route[2], route[3], None if self._context.get('drop_alias', False) else route[4])
return (model, thread_id, route[2], route[3], None if drop_alias else route[4])
@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,
""" 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:
1. If the message replies to an existing thread_id, and
properly contains the thread model in the 'In-Reply-To'
header, use this model/thread_id pair, and ignore
custom_value (not needed as no creation will take place)
2. Look for a mail.alias entry matching the message
recipient, and use the corresponding model, thread_id,
custom_values and user_id.
3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
provided.
4. If all the above fails, raise an exception.
:param string message: an email.message instance
:param dict message_dict: dictionary holding 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 [model, thread_id, custom_values, user_id, alias]
* 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
* if the message replies to an existing thread by having In-Reply-To or
References matching odoo model/thread_id Message-Id and if this thread
has messages without message_id, take this model/thread_id pair and
ignore custom_value as no creation will take place (6.1 compatibility)
* 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, Message):
raise TypeError('message must be an email.message.Message at this point')
MailMessage = self.env['mail.message']
Alias = self.env['mail.alias']
Alias, dest_aliases = self.env['mail.alias'], self.env['mail.alias']
bounce_alias = self.env['ir.config_parameter'].get_param("mail.bounce.alias")
fallback_model = model
# Get email.message.Message variables for future processing
# get email.message.Message variables for future processing
local_hostname = socket.gethostname()
message_id = message.get('Message-Id')
email_from = decode_header(message, 'From')
email_to = decode_header(message, 'To')
references = decode_header(message, 'References')
in_reply_to = decode_header(message, 'In-Reply-To').strip()
# compute references to find if message is a reply to an existing thread
references = tools.decode_message_header(message, 'References')
in_reply_to = tools.decode_message_header(message, 'In-Reply-To').strip()
thread_references = references or in_reply_to
reply_match, reply_model, reply_thread_id, reply_hostname, reply_private = tools.email_references(thread_references)
# author and recipients
email_from = tools.decode_message_header(message, 'From')
email_from_localpart = (tools.email_split(email_from) or [''])[0].split('@', 1)[0].lower()
email_to = tools.decode_message_header(message, 'To')
email_to_localpart = (tools.email_split(email_to) or [''])[0].split('@', 1)[0].lower()
# 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 = ','.join([
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')])
rcpt_tos_localparts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
# 0. Verify whether this is a bounced email and use it to collect bounce data and update notifications for customers
if bounce_alias and bounce_alias in email_to_localpart:
# 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_re = re.compile("%s\+(\d+)-?([\w.]+)?-?(\d+)?" % re.escape(bounce_alias), re.UNICODE)
bounce_match = bounce_re.search(email_to)
if bounce_match:
bounced_mail_id, bounced_model, bounced_thread_id = bounce_match.group(1), bounce_match.group(2), int(bounce_match.group(3))
email_part = next((part for part in message.walk() if part.get_content_type() == 'message/rfc822'), None)
dsn_part = next((part for part in message.walk() if part.get_content_type() == 'message/delivery-status'), None)
partner, partner_address = self.env['res.partner'], False
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')
partner_address = final_recipient_data.split(';', 1)[1].strip()
if partner_address:
partner = partner.sudo().search([('email', 'like', partner_address)])
partner.message_receive_bounce(partner_address, partner, mail_id=bounced_mail_id)
mail_message = self.env['mail.message']
if email_part:
email = email_part.get_payload()[0]
bounced_message_id = tools.mail_header_msgid_re.findall(tools.decode_message_header(email, 'Message-Id'))
mail_message = MailMessage.sudo().search([('message_id', 'in', bounced_message_id)])
if partner and mail_message:
notifications = self.env['mail.notification'].sudo().search([
('mail_message_id', '=', mail_message.id),
('res_partner_id', '=', partner.id)])
notifications.write({
'email_status': 'bounce'
})
if bounced_model in self.env and hasattr(self.env[bounced_model], 'message_receive_bounce') and bounced_thread_id:
self.env[bounced_model].browse(bounced_thread_id).message_receive_bounce(partner_address, partner, mail_id=bounced_mail_id)
_logger.info('Routing mail from %s to %s with Message-Id %s: bounced mail from mail %s, model: %s, thread_id: %s: dest %s (partner %s)',
email_from, email_to, message_id, bounced_mail_id, bounced_model, bounced_thread_id, partner_address, partner.id)
return []
# 0. First check if this is a bounce message or not.
# 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"
localpart = (tools.email_split(email_from) or [''])[0].split('@', 1)[0].lower()
if message.get_content_type() == 'multipart/report' or localpart == 'mailer-daemon':
_logger.info("Not routing bounce email from %s to %s with Message-Id %s",
email_from, email_to, message_id)
if message.get_content_type() == 'multipart/report' or email_from_localpart == 'mailer-daemon':
_logger.info('Routing mail with Message-Id %s: not routing bounce email from %s to %s',
message_id, email_from, email_to)
return []
# 1. message is a reply to an existing message (exact match of message_id)
ref_match = thread_references and tools.reference_re.search(thread_references)
msg_references = mail_header_msgid_re.findall(thread_references)
mail_messages = MailMessage.sudo().search([('message_id', 'in', msg_references)], limit=1)
if ref_match and mail_messages:
model, thread_id = mail_messages.model, mail_messages.res_id
alias = Alias.search([('alias_name', '=', (tools.email_split(email_to) or [''])[0].split('@', 1)[0].lower())])
alias = alias[0] if alias else None
route = self.with_context(drop_alias=True).message_route_verify(
message, message_dict,
(model, thread_id, custom_values, self._uid, alias),
update_author=True, assert_model=False, create_fallback=True)
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, model, thread_id, custom_values, self._uid)
return [route]
elif route is False:
return []
# 1. message is a reply to an existing message (exact match of message_id or compat-mode)
if reply_model and reply_thread_id:
other_alias = Alias.search([
'&',
('alias_name', '!=', False),
('alias_name', '=', email_to_localpart)
])
if other_alias and other_alias.alias_model_id.model != reply_model:
reply_match = False
# 2. message is a reply to an existign thread (6.1 compatibility)
if ref_match:
reply_thread_id = int(ref_match.group(1))
reply_model = ref_match.group(2) or fallback_model
reply_hostname = ref_match.group(3)
local_hostname = socket.gethostname()
if reply_match:
compat_mode = False
msg_references = tools.mail_header_msgid_re.findall(thread_references)
mail_messages = MailMessage.sudo().search([('message_id', 'in', msg_references)], limit=1)
# message is a reply to an existing thread (6.1 compatibility)
# do not match forwarded emails from another OpenERP system (thread_id collision!)
if local_hostname == reply_hostname:
thread_id, model = reply_thread_id, reply_model
if thread_id and model in self.env:
record = self.env[model].browse(thread_id)
compat_mail_msg_ids = MailMessage.search([
('message_id', '=', False),
('model', '=', model),
('res_id', '=', thread_id)])
if compat_mail_msg_ids and record.exists() and hasattr(record, 'message_update'):
route = self.message_route_verify(
message, message_dict,
(model, thread_id, custom_values, self._uid, None),
update_author=True, assert_model=True, create_fallback=True)
if route:
# parent is invalid for a compat-reply
message_dict.pop('parent_id', None)
_logger.info(
'Routing mail from %s to %s with Message-Id %s: direct thread reply (compat-mode) to model: %s, thread_id: %s, custom_values: %s, uid: %s',
email_from, email_to, message_id, model, thread_id, custom_values, self._uid)
return [route]
elif route is False:
return []
if not mail_messages and local_hostname == reply_hostname and reply_thread_id and reply_model in self.env:
mail_messages = MailMessage.search([
('message_id', '=', False),
('model', '=', reply_model),
('res_id', '=', reply_thread_id)])
compat_mode = True
# 3. Reply to a private message
if in_reply_to:
mail_message_ids = MailMessage.search([
('message_id', '=', in_reply_to),
'!', ('message_id', 'ilike', 'reply_to')
], limit=1)
if mail_message_ids:
if mail_messages:
model, thread_id = mail_messages.model, mail_messages.res_id
if not reply_private: # TDE note: not sure why private mode as no alias search, copying existing behavior
dest_aliases = Alias.search([('alias_name', 'in', rcpt_tos_localparts)], limit=1)
# TDE Note: compat mode = without context key, why ? because
route = self.message_route_verify(
message, message_dict,
(mail_message_ids.model, mail_message_ids.res_id, custom_values, self._uid, None),
update_author=True, assert_model=True, create_fallback=True, allow_private=True)
(model, thread_id, custom_values, self._uid, dest_aliases),
update_author=True, assert_model=reply_private, create_fallback=True,
allow_private=reply_private, drop_alias=True)
if route:
# TDE Note: compat mode: parent is invalid for a compat-reply
# message_dict.pop('parent_id', None)
# TDE note: add compat mode for compat mode in debug
_logger.info(
'Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
email_from, email_to, message_id, mail_message_ids.id, custom_values, self._uid)
'Routing mail from %s to %s with Message-Id %s%s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s',
email_from, email_to, message_id, compat_mode and ' (compat mode)' or '', model, thread_id, custom_values, self._uid)
return [route]
elif route is False:
return []
@@ -1068,20 +1109,11 @@ class MailThread(models.AbstractModel):
message_dict.pop('parent_id', None)
# 4. Look for a matching mail.alias entry
# 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 = \
','.join([decode_header(message, 'Delivered-To'),
decode_header(message, 'To'),
decode_header(message, 'Cc'),
decode_header(message, 'Resent-To'),
decode_header(message, 'Resent-Cc')])
local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
if local_parts:
aliases = Alias.search([('alias_name', 'in', local_parts)])
if aliases:
if rcpt_tos_localparts:
dest_aliases = Alias.search([('alias_name', 'in', rcpt_tos_localparts)])
if dest_aliases:
routes = []
for alias in aliases:
for alias in dest_aliases:
user_id = alias.alias_user_id.id
if not user_id:
# TDE note: this could cause crashes, because no clue that the user
@@ -1103,24 +1135,16 @@ class MailThread(models.AbstractModel):
return routes
# 5. Fallback to the provided parameters, if they work
if not thread_id:
# Legacy: fallback to matching [ID] in the Subject
match = tools.res_re.search(decode_header(message, 'Subject'))
thread_id = match and match.group(1)
# Convert into int (bug spotted in 7.0 because of str)
try:
thread_id = int(thread_id)
except:
thread_id = False
route = self.message_route_verify(
message, message_dict,
(fallback_model, thread_id, custom_values, self._uid, None),
update_author=True, assert_model=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, self._uid)
return [route]
if fallback_model:
route = self.message_route_verify(
message, message_dict,
(fallback_model, thread_id, custom_values, self._uid, None),
update_author=True, assert_model=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, self._uid)
return [route]
# ValueError if no routes found and if no bounce occured
raise ValueError(
@@ -1285,6 +1309,22 @@ class MailThread(models.AbstractModel):
self.write(update_vals)
return True
@api.multi
def message_receive_bounce(self, email, partner, mail_id=None):
"""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 check is an integer ``message_bounce`` column exists.
If it is the case, its content is incremented.
:param mail_id: ID of the sent email that bounced. It may not exist anymore
but it could be usefull if the information was kept. This is
used notably in mass mailing.
:param RecordSet partner: partner matching the bounced email address, if any
:param string email: email that caused the bounce """
if 'message_bounce' in self._fields:
for record in self:
record.message_bounce = record.message_bounce + 1
def _message_extract_payload_postprocess(self, message, body, attachments):
""" Perform some cleaning / postprocess in the body and attachments
extracted from the email. Note that this processing is specific to the
@@ -1355,7 +1395,7 @@ class MailThread(models.AbstractModel):
# RFC2231
filename = email.utils.collapse_rfc2231_value(filename).strip()
else:
filename = decode(filename)
filename = tools.decode_smtp_header(filename)
encoding = part.get_content_charset() # None if attachment
# 0) Inline Attachments -> attachments, with a third part in the tuple to match cid / attachment
@@ -1432,19 +1472,19 @@ class MailThread(models.AbstractModel):
msg_dict['message_id'] = message_id
if message.get('Subject'):
msg_dict['subject'] = decode(message.get('Subject'))
msg_dict['subject'] = tools.decode_smtp_header(message.get('Subject'))
# Envelope fields not stored in mail.message but made available for message_new()
msg_dict['from'] = decode(message.get('from'))
msg_dict['to'] = decode(message.get('to'))
msg_dict['cc'] = decode(message.get('cc'))
msg_dict['email_from'] = decode(message.get('from'))
msg_dict['from'] = tools.decode_smtp_header(message.get('from'))
msg_dict['to'] = tools.decode_smtp_header(message.get('to'))
msg_dict['cc'] = tools.decode_smtp_header(message.get('cc'))
msg_dict['email_from'] = tools.decode_smtp_header(message.get('from'))
partner_ids = self._message_find_partners(message, ['To', 'Cc'])
msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
if message.get('Date'):
try:
date_hdr = decode(message.get('Date'))
date_hdr = tools.decode_smtp_header(message.get('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
@@ -1461,13 +1501,13 @@ class MailThread(models.AbstractModel):
msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
if message.get('In-Reply-To'):
parent_ids = self.env['mail.message'].search([('message_id', '=', decode(message['In-Reply-To'].strip()))], limit=1)
parent_ids = self.env['mail.message'].search([('message_id', '=', tools.decode_smtp_header(message['In-Reply-To'].strip()))], limit=1)
if parent_ids:
msg_dict['parent_id'] = parent_ids.id
msg_dict['internal'] = parent_ids.subtype_id and parent_ids.subtype_id.internal or False
if message.get('References') and 'parent_id' not in msg_dict:
msg_list = mail_header_msgid_re.findall(decode(message['References']))
msg_list = tools.mail_header_msgid_re.findall(tools.decode_smtp_header(message['References']))
parent_ids = self.env['mail.message'].search([('message_id', 'in', [x.strip() for x in msg_list])], limit=1)
if parent_ids:
msg_dict['parent_id'] = parent_ids.id
@@ -1518,7 +1558,7 @@ class MailThread(models.AbstractModel):
return result
@api.multi
def _find_partner_from_emails(self, emails, res_model=None, res_id=None, check_followers=True, force_create=False):
def _find_partner_from_emails(self, emails, res_model=None, res_id=None, check_followers=True, force_create=False, exclude_aliases=True):
""" Utility method to find partners from email addresses. The rules are :
1 - check in document (model | self, id) followers
2 - try to find a matching partner that is also an user
@@ -1530,6 +1570,9 @@ class MailThread(models.AbstractModel):
is used.
:param boolean check_followers: check in document followers
:param boolean force_create: create a new partner if not found
:param boolean exclude_aliases: do not try to find a partner that could match an alias. Normally aliases
should not be used as partner emails but it could be the case due to some
strange manipulation
"""
if res_model is None:
res_model = self._name
@@ -1551,12 +1594,14 @@ class MailThread(models.AbstractModel):
if not email_address:
partner_ids.append(partner_id)
continue
if exclude_aliases and self.env['mail.alias'].search([('alias_name', 'ilike', email_address)], limit=1):
partner_ids.append(partner_id)
continue
email_address = email_address[0]
# first try: check in document's followers
for follower in followers:
if follower.email == email_address:
partner_id = follower.id
break
partner_id = next((partner.id for partner in followers if partner.email == email_address), False)
# second try: check in partners that are also users
# Escape special SQL characters in email_address to avoid invalid matches
email_address = (email_address.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_'))
@@ -1567,8 +1612,7 @@ class MailThread(models.AbstractModel):
if not partners:
# if no match with addr-spec, attempt substring match within name-addr pair
partners = Users.search([('email', 'ilike', email_brackets)], limit=1).mapped('partner_id')
if partners:
partner_id = partners[0].id
partner_id = partners.id
# third try: check in partners
if not partner_id:
# exact, case-insensitive match
@@ -1576,8 +1620,7 @@ class MailThread(models.AbstractModel):
if not partners:
# if no match with addr-spec, attempt substring match within name-addr pair
partners = Partner.search([('email', 'ilike', email_brackets)], limit=1)
if partners:
partner_id = partners[0].id
partner_id = partners.id
if not partner_id and force_create:
partner_id = self.env['res.partner'].name_create(contact)[0]
partner_ids.append(partner_id)
@@ -1973,10 +2016,17 @@ class MailThread(models.AbstractModel):
"""
if not partner_ids:
return
for record_id in self.ids:
for record in self:
record.message_post_with_view(
'mail.message_user_assigned',
composition_mode='mass_mail',
partner_ids=[(4, pid) for pid in partner_ids],
auto_delete=True,
auto_delete_message=True,
subtype_id=self.env.ref('mail.mt_note').id)
messages = self.env['mail.message'].sudo().search([
('model', '=', self._name),
('res_id', '=', record_id),
('res_id', '=', record.id),
('subtype_id', '!=', False),
('subtype_id.internal', '=', False)], limit=1)
if messages:
+45 -10
View File
@@ -4,7 +4,7 @@
import logging
import threading
from odoo import _, api, fields, models
from odoo import _, api, fields, models, registry, SUPERUSER_ID
from odoo.osv import expression
_logger = logging.getLogger(__name__)
@@ -86,7 +86,6 @@ class Partner(models.Model):
'tracking': tracking,
'is_discussion': is_discussion,
'subtype': message.subtype_id,
'is_accessible': message._is_accessible()
}
@api.model
@@ -133,8 +132,19 @@ class Partner(models.Model):
emails |= self.env['mail.mail'].create(create_values)
return emails, recipients_nbr
@api.model
def _notify_udpate_notifications(self, emails):
for email in emails:
notifications = self.env['mail.notification'].sudo().search([
('mail_message_id', '=', email.mail_message_id.id),
('res_partner_id', 'in', email.recipient_ids.ids)])
notifications.write({
'is_email': True,
'email_status': 'ready',
})
@api.multi
def _notify(self, message, force_send=False, user_signature=True):
def _notify(self, message, force_send=False, send_after_commit=True, user_signature=True):
# TDE TODO: model-dependant ? (like customer -> always email ?)
message_sudo = message.sudo()
email_channels = message.channel_ids.filtered(lambda channel: channel.email_send)
@@ -143,14 +153,19 @@ class Partner(models.Model):
('id', 'in', self.ids),
('channel_ids', 'in', email_channels.ids),
('email', '!=', message_sudo.author_id and message_sudo.author_id.email or message.email_from),
('notify_email', '!=', 'none')])._notify_by_email(message, force_send=force_send, user_signature=user_signature)
('notify_email', '!=', 'none')])._notify_by_email(message, force_send=force_send, send_after_commit=send_after_commit, user_signature=user_signature)
self._notify_by_chat(message)
return True
@api.multi
def _notify_by_email(self, message, force_send=False, user_signature=True):
def _notify_by_email(self, message, force_send=False, send_after_commit=True, user_signature=True):
""" Method to send email linked to notified messages. The recipients are
the recordset on which this method is called. """
the recordset on which this method is called.
:param boolean force_send: send notification emails now instead of letting the scheduler handle the email queue
:param boolean send_after_commit: send notification emails after the transaction end instead of durign the
transaction; this option is used only if force_send is True
:param user_signature: add current user signature to notification emails """
if not self.ids:
return True
@@ -166,7 +181,6 @@ class Partner(models.Model):
base_template_ctx['signature'] = False
base_mail_values = self._notify_prepare_email_values(message)
# classify recipients: actions / no action
if message.model and message.res_id and hasattr(self.env[message.model], '_message_notification_recipients'):
recipients = self.env[message.model].browse(message.res_id)._message_notification_recipients(message, self)
@@ -185,6 +199,9 @@ class Partner(models.Model):
fol_values = template_fol.generate_email(message.id, fields=['body_html', 'subject'])
# send email
new_emails, new_recipients_nbr = self._notify_send(fol_values['body'], fol_values['subject'], recipient_template_values['followers'], **base_mail_values)
# update notifications
self._notify_udpate_notifications(new_emails)
emails |= new_emails
recipients_nbr += new_recipients_nbr
if recipient_template_values['not_followers']:
@@ -196,6 +213,9 @@ class Partner(models.Model):
not_values = template_not.generate_email(message.id, fields=['body_html', 'subject'])
# send email
new_emails, new_recipients_nbr = self._notify_send(not_values['body'], not_values['subject'], recipient_template_values['not_followers'], **base_mail_values)
# update notifications
self._notify_udpate_notifications(new_emails)
emails |= new_emails
recipients_nbr += new_recipients_nbr
@@ -204,9 +224,24 @@ class Partner(models.Model):
# 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 recipients_nbr < recipients_max and \
(not self.pool._init or getattr(threading.currentThread(), 'testing', False)):
emails.send()
(not self.pool._init or test_mode):
email_ids = emails.ids
dbname = self.env.cr.dbname
def send_notifications():
db_registry = registry(dbname)
with db_registry.cursor() as cr:
env = api.Environment(cr, SUPERUSER_ID, {})
env['mail.mail'].browse(email_ids).send()
# 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:
self._cr.after('commit', send_notifications)
else:
emails.send()
return True
@@ -226,7 +261,7 @@ class Partner(models.Model):
self.env.cr.execute("""
SELECT count(*) as needaction_count
FROM mail_message_res_partner_needaction_rel R
WHERE R.res_partner_id = %s """, (self.env.user.partner_id.id,))
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
+7 -18
View File
@@ -14,11 +14,15 @@ class Users(models.Model):
group, and the user. This is done by overriding the write method.
"""
_name = 'res.users'
_inherit = ['mail.alias.mixin', 'res.users']
_inherit = ['res.users']
alias_id = fields.Many2one('mail.alias', 'Alias', ondelete="restrict", required=True,
alias_id = fields.Many2one('mail.alias', 'Alias', ondelete="set null", required=False,
help="Email address internally associated with this user. Incoming "\
"emails will appear in the user's notifications.", copy=False, auto_join=True)
alias_contact = fields.Selection([
('everyone', 'Everyone'),
('partners', 'Authenticated Partners'),
('followers', 'Followers only')], string='Alias Contact Security', related='alias_id.alias_contact')
def __init__(self, pool, cr):
""" Override of __init__ to add access rights on notification_email_send
@@ -31,17 +35,9 @@ class Users(models.Model):
type(self).SELF_WRITEABLE_FIELDS.extend(['notify_email'])
# duplicate list to avoid modifying the original reference
type(self).SELF_READABLE_FIELDS = list(self.SELF_READABLE_FIELDS)
type(self).SELF_READABLE_FIELDS.extend(['notify_email', 'alias_domain', 'alias_name'])
type(self).SELF_READABLE_FIELDS.extend(['notify_email'])
return init_res
def get_alias_model_name(self, vals):
return self._name
def get_alias_values(self):
values = super(Users, self).get_alias_values()
values['alias_force_thread_id'] = self.id
return values
@api.model
def create(self, values):
if not values.get('login', False):
@@ -65,13 +61,6 @@ class Users(models.Model):
self.env['mail.channel'].search([('group_ids', 'in', user_group_ids)])._subscribe_users()
return write_res
@api.multi
def copy_data(self, default=None):
data = super(Users, self).copy_data(default)[0]
if data and data.get('alias_name'):
data['alias_name'] = data['login']
return [data]
def _create_welcome_message(self):
self.ensure_one()
if not self.has_group('base.group_user'):
+4
View File
@@ -10,6 +10,9 @@ access_mail_followers_all,mail.followers.all,model_mail_followers,,1,0,0,0
access_mail_followers_portal,mail.followers.portal,model_mail_followers,base.group_portal,1,1,1,0
access_mail_followers_user,mail.followers.user,model_mail_followers,base.group_user,1,1,1,0
access_mail_followers_system,mail.followers.system,model_mail_followers,base.group_system,1,1,1,1
access_mail_notification_portal,mail.notification.portal,model_mail_notification,base.group_portal,1,0,0,0
access_mail_notification_user,mail.notification.user,model_mail_notification,base.group_user,1,1,1,0
access_mail_notification_system, mail.notification.system,model_mail_notification,base.group_system,1,1,1,1
access_mail_channel_all,mail.group.all,model_mail_channel,,1,0,0,0
access_mail_channel_user,mail.group.user,model_mail_channel,base.group_user,1,1,1,1
access_mail_channel_partner_public,mail.channel.partner.public,model_mail_channel_partner,base.group_public,1,0,0,0
@@ -30,3 +33,4 @@ access_mail_template,mail.template,model_mail_template,base.group_user,1,1,1,0
access_mail_template_system,mail.template_system,model_mail_template,base.group_system,1,1,1,1
access_mail_shortcode,mail.shortcode,model_mail_shortcode,base.group_user,1,1,1,1
access_mail_shortcode_portal,mail.shortcode.portal,model_mail_shortcode,base.group_portal,1,0,0,0
access_mail_test_all,mail.test.all,model_mail_test,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
10 access_mail_followers_portal mail.followers.portal model_mail_followers base.group_portal 1 1 1 0
11 access_mail_followers_user mail.followers.user model_mail_followers base.group_user 1 1 1 0
12 access_mail_followers_system mail.followers.system model_mail_followers base.group_system 1 1 1 1
13 access_mail_notification_portal mail.notification.portal model_mail_notification base.group_portal 1 0 0 0
14 access_mail_notification_user mail.notification.user model_mail_notification base.group_user 1 1 1 0
15 access_mail_notification_system mail.notification.system model_mail_notification base.group_system 1 1 1 1
16 access_mail_channel_all mail.group.all model_mail_channel 1 0 0 0
17 access_mail_channel_user mail.group.user model_mail_channel base.group_user 1 1 1 1
18 access_mail_channel_partner_public mail.channel.partner.public model_mail_channel_partner base.group_public 1 0 0 0
33 access_mail_template_system mail.template_system model_mail_template base.group_system 1 1 1 1
34 access_mail_shortcode mail.shortcode model_mail_shortcode base.group_user 1 1 1 1
35 access_mail_shortcode_portal mail.shortcode.portal model_mail_shortcode base.group_portal 1 0 0 0
36 access_mail_test_all mail.test.all model_mail_test base.group_user 1 1 1 1
+10
View File
@@ -23,6 +23,16 @@
<field name="perm_read" eval="False"/>
</record>
<record id="ir_rule_mail_notifications_group_user" model="ir.rule">
<field name="name">mail.notifications: group_user: write its own entries</field>
<field name="model_id" ref="model_mail_notification"/>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="domain_force">[('res_partner_id', '=', user.partner_id.id)]</field>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
<field name="perm_read" eval="False"/>
</record>
<record id="mail_message_subtype_rule_public" model="ir.rule">
<field name="name">mail.message.subtype: portal/public: read public subtypes</field>
<field name="model_id" ref="model_mail_message_subtype"/>
@@ -132,6 +132,8 @@ function make_message (data) {
attachment_ids: data.attachment_ids || [],
subject: data.subject,
email_from: data.email_from,
customer_email_status: data.customer_email_status,
customer_email_data: data.customer_email_data,
record_name: data.record_name,
tracking_value_ids: data.tracking_value_ids,
channel_ids: data.channel_ids,
+1
View File
@@ -32,6 +32,7 @@ return Widget.extend({
autofocus: true,
display_stars: true,
display_reply_icon: false,
display_email_icon: false,
placeholder: _t("Say something"),
input_less: false,
});
@@ -491,6 +491,7 @@ var ChatAction = Widget.extend(ControlPanelMixin, {
display_empty_channel: !messages.length && !this.domain.length,
display_no_match: !messages.length && this.domain.length,
display_subject: this.channel.mass_mailing || this.channel.id === "channel_inbox",
display_email_icon: false,
display_reply_icon: true,
};
},
+1
View File
@@ -72,6 +72,7 @@ var Thread = Widget.extend({
display_document_link: true,
display_avatar: true,
squash_close_messages: true,
display_email_icon: true,
display_reply_icon: false,
});
this.expanded_msg_ids = [];
+43 -3
View File
@@ -70,7 +70,8 @@
}
}
i.o_thread_message_star, i.o_thread_message_needaction, i.o_thread_message_reply {
i.o_thread_message_star, i.o_thread_message_needaction, i.o_thread_message_reply, i.o_thread_message_email {
cursor: pointer;
opacity: 0;
&.fa-star {
@@ -84,7 +85,7 @@
opacity: @mail-thread-side-date-opacity;
}
i.o_thread_message_star, i.o_thread_message_needaction, i.o_thread_message_reply {
i.o_thread_message_star, i.o_thread_message_needaction, i.o_thread_message_reply, i.o_thread_message_email {
opacity: @mail-thread-icon-opacity;
&:hover {
opacity: 1;
@@ -146,9 +147,48 @@
}
}
.o_thread_message_star, .o_thread_message_needaction, .o_thread_message_reply {
.o_thread_message_star, .o_thread_message_needaction, .o_thread_message_reply, .o_thread_message_email {
padding: 4px;
}
i.o_thread_message_email {
&.o_thread_message_email_ready {
color: grey;
}
&.o_thread_message_email_exception {
color: red;
opacity: 1;
}
&.o_thread_message_email_bounce {
color: red;
opacity: 1;
}
}
.o_thread_tooltip_container {
display: inline;
position: relative;
.o_thread_tooltip_content {
top: 10px;
background: @gray-lighter;
padding: 4px;
border: solid @gray-lighter 1px;
border-radius: 5px;
color: gray;
width: 300px;
opacity: 0;
position: absolute;
transition: opacity 0.5s;
i.fa-exclamation {
color: red;
}
}
.o_thread_tooltip:hover + .o_thread_tooltip_content {
opacity: 1;
}
}
}
}
.o_thread_title {
+15
View File
@@ -94,6 +94,21 @@
<t t-if="message.origin_id &amp;&amp; (message.origin_id !== options.channel_id)">
(from <a t-att-data-oe-id="message.origin_id" href="#">#<t t-esc="message.origin_name"/></a>)
</t>
<span t-if="options.display_email_icon &amp;&amp; message.customer_email_data.length" class="o_thread_tooltip_container">
<i t-att-class="'o_thread_tooltip o_thread_message_email o_thread_message_email_' + message.customer_email_status + ' fa fa-envelope-o'"/>
<span class="o_thread_tooltip_content">
<t t-foreach="message.customer_email_data" t-as="customer">
<span>
<t t-if="customer[2] == 'sent'"><i class='fa fa-check'/></t>
<t t-if="customer[2] == 'bounce'"><i class='fa fa-exclamation'/></t>
<t t-if="customer[2] == 'exception'"><i class='fa fa-exclamation'/></t>
<t t-if="customer[2] == 'ready'"><i class='fa fa-send-o'/></t>
<t t-esc="customer[1]"/>
</span>
<br />
</t>
</span>
</span>
<span>
<i t-if="options.display_stars &amp;&amp; !message.is_system_notification"
t-att-class="'fa fa-lg o_thread_message_star ' + (message.is_starred ? 'fa-star' : 'fa-star-o')"
-3
View File
@@ -56,7 +56,6 @@ class TestMail(common.SavepointCase):
cls.user_employee = Users.create({
'name': 'Ernest Employee',
'login': 'ernest',
'alias_name': 'ernest',
'email': 'e.e@example.com',
'signature': '--\nErnest',
'notify_email': 'always',
@@ -64,7 +63,6 @@ class TestMail(common.SavepointCase):
cls.user_public = Users.create({
'name': 'Bert Tartignole',
'login': 'bert',
'alias_name': 'bert',
'email': 'b.t@example.com',
'signature': 'SignBert',
'notify_email': 'always',
@@ -72,7 +70,6 @@ class TestMail(common.SavepointCase):
cls.user_portal = Users.create({
'name': 'Chell Gladys',
'login': 'chell',
'alias_name': 'chell',
'email': 'chell@gladys.portal',
'signature': 'SignChell',
'notify_email': 'always',
+4 -18
View File
@@ -9,22 +9,8 @@ class TestMailFeatures(TestMail):
# TDE TODO: tests on the redirection controller
def test_alias_setup(self):
Users = self.env['res.users'].with_context({'no_reset_password': True})
user_valentin = Users.create({
'name': 'Valentin Cognito', 'email': 'valentin.cognito@gmail.com',
'login': 'valentin.cognito', 'alias_name': 'valentin.cognito'})
self.assertEqual(user_valentin.alias_name, user_valentin.login, "Login should be used as alias")
user_pagan = Users.create({
'name': 'Pagan Le Marchant', 'email': 'plmarchant@gmail.com',
'login': 'plmarchant@gmail.com', 'alias_name': 'plmarchant@gmail.com'})
self.assertEqual(user_pagan.alias_name, 'plmarchant', "If login is an email, the alias should keep only the local part")
user_barty = Users.create({
'name': 'Bartholomew Ironside', 'email': 'barty@gmail.com',
'login': 'b4r+_#_R3wl$$', 'alias_name': 'b4r+_#_R3wl$$'})
self.assertEqual(user_barty.alias_name, 'b4r+_-_r3wl-', 'Disallowed chars should be replaced by hyphens')
alias = self.env['mail.alias'].with_context(alias_model_name='mail.channel').create({'alias_name': 'b4r+_#_R3wl$$'})
self.assertEqual(alias.alias_name, 'b4r+_-_r3wl-', 'Disallowed chars should be replaced by hyphens')
@mute_logger('openerp.addons.mail.models.mail_mail')
def test_needaction(self):
@@ -138,8 +124,8 @@ class TestMessagePost(TestMail):
# notification emails: followers + recipients - notify_email=none (partner_2) - author (user_employee)
self.assertEqual(set(m['email_from'] for m in self._mails),
set(['%s <%s@%s>' % (self.user_employee.name, self.user_employee.alias_name, _domain)]),
'message_post: notification email wrong email_from: should use alias of sender')
set(['%s <%s>' % (self.user_employee.name, self.user_employee.email)]),
'message_post: notification email wrong email_from: should use sender email')
self.assertEqual(set(m['email_to'][0] for m in self._mails),
set(['%s <%s>' % (self.partner_1.name, self.partner_1.email),
'%s <%s>' % (self.env.user.name, self.env.user.email)]))
+76 -26
View File
@@ -298,6 +298,14 @@ class TestMailgateway(TestMail):
'alias_model_id': self.mail_channel_model.id,
'alias_contact': 'everyone'})
# test@.. will cause the creation of new mail.test
self.mail_test_model = self.env['ir.model'].search([('model', '=', 'mail.test')], limit=1)
self.alias_2 = self.env['mail.alias'].create({
'alias_name': 'test',
'alias_user_id': False,
'alias_model_id': self.mail_test_model.id,
'alias_contact': 'everyone'})
# Set a first message on public group to test update and hierarchy
self.fake_email = self.env['mail.message'].create({
'model': 'mail.channel',
@@ -512,7 +520,7 @@ class TestMailgateway(TestMail):
@mute_logger('openerp.addons.mail.models.mail_thread', 'openerp.models')
def test_message_process_in_reply_to(self):
""" Incoming email using in-rely-to should go into the right destination event with a wrong destination """
""" Incoming email using in-rely-to should go into the right destination even with a wrong destination """
self.format_and_process(
MAIL_TEMPLATE, email_from='valid.other@gmail.com',
msg_id='<1198923581.41972151344608186800.JavaMail.diff1@agrolait.com>',
@@ -524,7 +532,7 @@ class TestMailgateway(TestMail):
@mute_logger('openerp.addons.mail.models.mail_thread', 'openerp.models')
def test_message_process_references(self):
""" Incoming email using in-rely-to should go into the right destination event with a wrong destination """
""" Incoming email using references should go into the right destination even with a wrong destination """
self.format_and_process(
MAIL_TEMPLATE, to='erroneous@example.com',
extra='References: <2233@a.com>\r\n\t<3edss_dsa@b.com> %s' % self.fake_email.message_id,
@@ -533,6 +541,34 @@ class TestMailgateway(TestMail):
self.assertEqual(len(self.group_public.message_ids), 2, 'message_process: group should contain one new message')
self.assertEqual(len(self.fake_email.child_ids), 1, 'message_process: new message should be children of the existing one')
@mute_logger('openerp.addons.mail.models.mail_thread', 'openerp.models')
def test_message_process_references_forward(self):
""" Incoming email using references but with alias forward should not go into references destination """
res_test = self.format_and_process(
MAIL_TEMPLATE, to='test@example.com',
subject='My Dear Forward',
extra='References: <2233@a.com>\r\n\t<3edss_dsa@b.com> %s' % self.fake_email.message_id,
msg_id='<1198923581.41972151344608186800.JavaMail.4@agrolait.com>',
target_model='mail.test')
self.assertEqual(len(self.group_public.message_ids), 1, 'message_process: group should not contain new message')
self.assertEqual(len(self.fake_email.child_ids), 0, 'message_process: original email should not contain childs')
self.assertEqual(res_test.name, 'My Dear Forward')
self.assertEqual(len(res_test.message_ids), 1)
@mute_logger('openerp.addons.mail.models.mail_thread', 'openerp.models')
def test_message_process_references_forward_cc(self):
""" Incoming email using references but with alias forward should not go into references destination """
self.format_and_process(
MAIL_TEMPLATE, to='erroneous@example.com', cc='test@example.com',
subject='My Dear Forward',
extra='References: <2233@a.com>\r\n\t<3edss_dsa@b.com> %s' % self.fake_email.message_id,
msg_id='<1198923581.41972151344608186800.JavaMail.4@agrolait.com>',
target_model='mail.test')
self.assertEqual(len(self.group_public.message_ids), 2, 'message_process: group should contain one new message')
self.assertEqual(len(self.fake_email.child_ids), 1, 'message_process: new message should be children of the existing one')
@mute_logger('openerp.addons.mail.models.mail_thread', 'openerp.models')
def test_message_process_model_res_id(self):
""" Incoming email with ref holding model / res_id but that does not match any message in the thread: must raise since OpenERP saas-3 """
@@ -560,15 +596,8 @@ class TestMailgateway(TestMail):
to='noone@example.com>', subject='spam',
extra='In-Reply-To: <12321321-openerp-%d-mail.channel@neighbor.com>' % frog_groups.id)
# 4. Subject contains [<ID>] + model passed to message+process -> only attached to group, but not to mail (not in msg1.child_ids)
self.format_and_process(
MAIL_TEMPLATE,
to='erroneous@example.com', email_from='other6@gmail.com',
extra='', subject='Re: [%s] 1' % self.group_public.id, model='mail.channel',
msg_id='<1198923581.41972151344608186803.JavaMail.5@agrolait.com>')
# Test created messages
self.assertEqual(len(self.group_public.message_ids), 3, 'message_process: group should contain 6 messages')
self.assertEqual(len(self.group_public.message_ids), 2, 'message_process: group should contain 6 messages')
self.assertEqual(len(self.group_public.message_ids[0].child_ids), 0, 'message_process: msg1 should not have children')
@mute_logger('openerp.addons.mail.models.mail_thread', 'openerp.models')
@@ -700,21 +729,42 @@ class TestMailgateway(TestMail):
self.assertEqual(msg.needaction_partner_ids, self.user_employee.partner_id | self.env.user.partner_id,
'message_post: private discussion: incorrect notified recipients when replying')
# Do bert forward it to an alias
msg = self.env['mail.message'].browse(msg1.id)
@mute_logger('openerp.addons.mail.models.mail_thread', 'openerp.models', 'openerp.addons.mail.models.mail_mail')
def test_forward_parent_id(self):
msg = self.group_pigs.sudo(self.user_employee).message_post(no_auto_thread=True, subtype='mail.mt_comment')
self.assertNotIn(msg.model, msg.message_id)
self.assertNotIn('-%d-' % msg.res_id, msg.message_id)
self.assertIn('reply_to', msg.message_id)
# forward it to a new thread AND an existing thread
for i, to in enumerate(['groups', 'public']):
fw_msg_id = '<THIS.IS.A.FW.MESSAGE.%d@bert.fr>' % (i,)
fw_message = MAIL_TEMPLATE.format(to='%s@example.com' % (to,),
cc='',
subject='FW: Re: 1',
email_from='b.t@example.com',
extra='References: %s' % msg.message_id,
msg_id=fw_msg_id)
self.env['mail.thread'].message_process(None, fw_message)
fw_msg_id = '<THIS.IS.A.FW.MESSAGE.1@bert.fr>'
fw_message = MAIL_TEMPLATE.format(to='groups@example.com',
cc='',
subject='FW: Re: 1',
email_from='b.t@example.com',
extra='In-Reply-To: %s' % msg.message_id,
msg_id=fw_msg_id)
self.env['mail.thread'].message_process(None, fw_message)
msg_fw = self.env['mail.message'].search([('message_id', '=', fw_msg_id)])
self.assertEqual(len(msg_fw), 1)
channel = self.env['mail.channel'].search([('name', "=", msg_fw.subject)])
self.assertEqual(len(channel), 1)
self.assertEqual(msg_fw.model, 'mail.channel')
self.assertFalse(msg_fw.parent_id)
self.assertTrue(msg_fw.res_id == channel.id)
msg_fw = self.env['mail.message'].search([('message_id', '=', fw_msg_id)])
self.assertEqual(len(msg_fw), 1)
self.assertEqual(msg_fw.model, 'mail.channel')
self.assertFalse(msg_fw.parent_id)
fw_msg_id = '<THIS.IS.A.FW.MESSAGE.2@bert.fr>'
fw_message = MAIL_TEMPLATE.format(to='public@example.com',
cc='',
subject='FW: Re: 2',
email_from='b.t@example.com',
extra='In-Reply-To: %s' % msg.message_id,
msg_id=fw_msg_id)
self.env['mail.thread'].message_process(None, fw_message)
msg_fw = self.env['mail.message'].search([('message_id', '=', fw_msg_id)])
self.assertEqual(len(msg_fw), 1)
channel = self.env['mail.channel'].search([('name', "=", msg_fw.subject)])
self.assertEqual(len(channel), 0)
self.assertEqual(msg_fw.model, 'mail.channel')
self.assertFalse(msg_fw.parent_id)
self.assertTrue(msg_fw.res_id == self.group_public.id)
+5 -5
View File
@@ -49,8 +49,8 @@ class TestMailMessage(TestMail):
msg = self.env['mail.message'].sudo(self.user_employee).create({})
self.assertIn('-private', msg.message_id, 'mail_message: message_id for a void message should be a "private" one')
self.assertEqual(msg.reply_to, '%s <%s@%s>' % (self.user_employee.name, self.user_employee.alias_name, alias_domain))
self.assertEqual(msg.email_from, '%s <%s@%s>' % (self.user_employee.name, self.user_employee.alias_name, alias_domain))
self.assertEqual(msg.reply_to, '%s <%s>' % (self.user_employee.name, self.user_employee.email))
self.assertEqual(msg.email_from, '%s <%s>' % (self.user_employee.name, self.user_employee.email))
def test_mail_message_values_alias_catchall(self):
alias_domain = 'example.com'
@@ -61,7 +61,7 @@ class TestMailMessage(TestMail):
msg = self.env['mail.message'].sudo(self.user_employee).create({})
self.assertIn('-private', msg.message_id, 'mail_message: message_id for a void message should be a "private" one')
self.assertEqual(msg.reply_to, '%s <%s@%s>' % (self.env.user.company_id.name, alias_catchall, alias_domain))
self.assertEqual(msg.email_from, '%s <%s@%s>' % (self.user_employee.name, self.user_employee.alias_name, alias_domain))
self.assertEqual(msg.email_from, '%s <%s>' % (self.user_employee.name, self.user_employee.email))
def test_mail_message_values_document_no_alias(self):
self.env['ir.config_parameter'].search([('key', '=', 'mail.catchall.domain')]).unlink()
@@ -85,7 +85,7 @@ class TestMailMessage(TestMail):
})
self.assertIn('-openerp-%d-mail.channel' % self.group_pigs.id, msg.message_id, 'mail_message: message_id for a void message should be a "private" one')
self.assertEqual(msg.reply_to, '%s %s <%s@%s>' % (self.env.user.company_id.name, self.group_pigs.name, self.group_pigs.alias_name, alias_domain))
self.assertEqual(msg.email_from, '%s <%s@%s>' % (self.user_employee.name, self.user_employee.alias_name, alias_domain))
self.assertEqual(msg.email_from, '%s <%s>' % (self.user_employee.name, self.user_employee.email))
def test_mail_message_values_document_alias_catchall(self):
alias_domain = 'example.com'
@@ -99,7 +99,7 @@ class TestMailMessage(TestMail):
})
self.assertIn('-openerp-%d-mail.channel' % self.group_pigs.id, msg.message_id, 'mail_message: message_id for a void message should be a "private" one')
self.assertEqual(msg.reply_to, '%s %s <%s@%s>' % (self.env.user.company_id.name, self.group_pigs.name, self.group_pigs.alias_name, alias_domain))
self.assertEqual(msg.email_from, '%s <%s@%s>' % (self.user_employee.name, self.user_employee.alias_name, alias_domain))
self.assertEqual(msg.email_from, '%s <%s>' % (self.user_employee.name, self.user_employee.email))
def test_mail_message_values_no_auto_thread(self):
msg = self.env['mail.message'].sudo(self.user_employee).create({
+17
View File
@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class MailTest(models.Model):
""" A mail.channel is a discussion group that may behave like a listener
on documents. """
_description = 'Test Mail Model'
_name = 'mail.test'
_mail_post_access = 'read'
_inherit = ['mail.thread']
name = fields.Char()
alias_id = fields.Many2one(
'mail.alias', 'Alias')
+18 -4
View File
@@ -59,10 +59,24 @@
</page>
<page string="Recipients">
<group>
<field name="partner_ids" widget="many2many_tags"/>
<field name="channel_ids" widget="many2many_tags"/>
<field name="needaction_partner_ids" widget="many2many_tags"/>
<field name="starred_partner_ids" widget="many2many_tags"/>
<group>
<field name="partner_ids" widget="many2many_tags"/>
<field name="channel_ids" widget="many2many_tags"/>
</group>
<group>
<field name="needaction_partner_ids" widget="many2many_tags"/>
<field name="starred_partner_ids" widget="many2many_tags"/>
</group>
</group>
<group>
<field name="notification_ids">
<tree>
<field name="res_partner_id"/>
<field name="is_read"/>
<field name="is_email"/>
<field name="email_status"/>
</tree>
</field>
</group>
</page>
<page string="Tracking">
+12
View File
@@ -38,5 +38,17 @@
</p>
</template>
<template id="message_user_assigned">
<p>Dear <t t-esc="object.user_id.name"/>,</p>
<p>You have been assigned to the <t t-esc="object._description.lower()"/> <t t-esc="object.name_get()[0][1]"/>.</p>
<p>
<a t-att-href="'/mail/view?model=%s&amp;res_id=%s' % (object._name, object.id)"
style="background-color: #9E588B; margin-top: 10px; padding: 10px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 16px;">
View <t t-esc="object._description.lower()"/>
</a>
</p>
<p style="color:#9E588B;">Powered by <a href="https://www.odoo.com">Odoo</a>.</p>
</template>
</data>
</odoo>
+2 -11
View File
@@ -27,17 +27,8 @@
<field name="notify_email" widget="radio"/>
</field>
<field name="signature" position="before">
<label for="alias_id" string="Messaging Alias" class="oe_read_only"
attrs="{'invisible': [('alias_domain', '=', False)]}"/>
<field name="alias_id" class="oe_read_only" required="0" nolabel="1"
attrs="{'invisible': [('alias_domain', '=', False)]}"/>
<label for="alias_name" string="Messaging Alias" class="oe_edit_only"
attrs="{'invisible': [('alias_domain', '=', False)]}"/>
<div class="oe_edit_only" attrs="{'invisible': [('alias_domain', '=', False)]}">
<field name="alias_name" class="oe_inline"/>@<field name="alias_domain" class="oe_inline" readonly="1"/>
</div>
<field name="alias_contact" string="Alias Accepts Emails From"
attrs="{'invisible': [('alias_domain', '=', False)]}"/>
<field name="alias_id"/>
<field name="alias_contact" attrs="{'invisible': [('alias_id', '=', False)]}"/>
</field>
</data>
</field>
+1 -1
View File
@@ -60,6 +60,6 @@ class Invite(models.TransientModel):
'res_id': wizard.res_id,
'no_auto_thread': True,
})
new_partners.with_context(auto_delete=True)._notify(message, force_send=True, user_signature=True)
new_partners.with_context(auto_delete=True)._notify(message, force_send=True, send_after_commit=False, user_signature=True)
message.unlink()
return {'type': 'ir.actions.act_window_close'}
-1
View File
@@ -22,7 +22,6 @@ professional emails and reuse templates in a few clicks.
],
'data': [
'security/mass_mailing_security.xml',
'data/mail_data.xml',
'data/mass_mailing_data.xml',
'wizard/mail_compose_message_views.xml',
'wizard/test_mailing_views.xml',
-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Bounce Email Alias -->
<record id="icp_mail_bounce_alias" model="ir.config_parameter">
<field name="key">mail.bounce.alias</field>
<field name="value">bounce</field>
</record>
</data>
</odoo>
+16 -50
View File
@@ -4,65 +4,31 @@
import logging
import re
from odoo import api, models
from odoo.addons.mail.models.mail_message import decode
from odoo.addons.mail.models.mail_thread import decode_header
from odoo import api, models, tools
from odoo.tools import decode_smtp_header, decode_message_header
_logger = logging.getLogger(__name__)
class MailThread(models.AbstractModel):
""" Update MailThread to add the feature of bounced emails and replied emails
in message_process. """
""" Update MailThread to add the support of bounce management in mass mailing statistics. """
_inherit = 'mail.thread'
def message_route_check_bounce(self, message):
""" Override to verify that the email_to is the bounce alias. If it is the
case, log the bounce, set the parent and related document as bounced and
return False to end the routing process. """
bounce_alias = self.env['ir.config_parameter'].get_param("mail.bounce.alias")
message_id = message.get('Message-Id')
email_from = decode_header(message, 'From')
email_to = decode_header(message, 'To')
# 0. Verify whether this is a bounced email (wrong destination,...) -> use it to collect data, such as dead leads
if bounce_alias and bounce_alias in email_to:
# 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_re = re.compile("%s-(\d+)-?([\w.]+)?-?(\d+)?" % re.escape(bounce_alias), re.UNICODE)
bounce_match = bounce_re.search(email_to)
if bounce_match:
bounced_model, bounced_thread_id = None, False
bounced_mail_id = bounce_match.group(1)
statistics = self.env['mail.mail.statistics'].set_bounced(mail_mail_ids=[bounced_mail_id])
for stat in statistics:
bounced_model = stat.model
bounced_thread_id = stat.res_id
_logger.info('Routing mail from %s to %s with Message-Id %s: bounced mail from mail %s, model: %s, thread_id: %s',
email_from, email_to, message_id, bounced_mail_id, bounced_model, bounced_thread_id)
if bounced_model and bounced_model in self.env and hasattr(self.env[bounced_model], 'message_receive_bounce') and bounced_thread_id:
self.env[bounced_model].browse(bounced_thread_id).message_receive_bounce(mail_id=bounced_mail_id)
return False
return True
@api.model
def message_route(self, message, message_dict, model=None, thread_id=None, custom_values=None):
if not self.message_route_check_bounce(message):
return []
return super(MailThread, self).message_route(message, message_dict, model, thread_id, custom_values)
""" Override to udpate mass mailing statistics based on bounce emails """
bounce_alias = self.env['ir.config_parameter'].get_param("mail.bounce.alias")
email_to = decode_message_header(message, 'To')
email_to_localpart = (tools.email_split(email_to) or [''])[0].split('@', 1)[0].lower()
def message_receive_bounce(self, mail_id=None):
"""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 check is an integer ``message_bounce`` column exists.
If it is the case, its content is incremented. """
if 'message_bounce' in self._fields:
for mail_thread in self:
mail_thread.message_bounce = mail_thread.message_bounce + 1
if bounce_alias and bounce_alias in email_to_localpart:
bounce_re = re.compile("%s\+(\d+)-?([\w.]+)?-?(\d+)?" % re.escape(bounce_alias), re.UNICODE)
bounce_match = bounce_re.search(email_to)
if bounce_match:
bounced_mail_id = bounce_match.group(1)
self.env['mail.mail.statistics'].set_bounced(mail_mail_ids=[bounced_mail_id])
return super(MailThread, self).message_route(message, message_dict, model, thread_id, custom_values)
@api.model
def message_route_process(self, message, message_dict, routes):
@@ -70,6 +36,6 @@ class MailThread(models.AbstractModel):
by using the References header of the incoming message and looking for
matching message_id in mail.mail.statistics. """
if message.get('References'):
message_ids = [x.strip() for x in decode(message['References']).split()]
message_ids = [x.strip() for x in decode_smtp_header(message['References']).split()]
self.env['mail.mail.statistics'].set_replied(mail_message_ids=message_ids)
return super(MailThread, self).message_route_process(message, message_dict, routes)
@@ -98,14 +98,6 @@ class MassMailingContact(models.Model):
def message_get_default_recipients(self):
return dict((record.id, {'partner_ids': [], 'email_to': record.email, 'email_cc': False}) for record in self)
def message_receive_bounce(self, mail_id=None):
"""Called by ``message_process`` when a bounce email (such as Undelivered
Mail Returned to Sender) is received for an existing thread. As contacts
do not inherit form mail.thread, we have to define this method to be able
to track bounces (see mail.thread for more details). """
for contact in self:
contact.message_bounce = contact.message_bounce + 1
class MassMailingStage(models.Model):
@@ -13,7 +13,6 @@ class TestPortalProjectBase(TestProjectBase):
self.user_noone = self.env['res.users'].with_context({'no_reset_password': True, 'mail_create_nosubscribe': True}).create({
'name': 'Noemie NoOne',
'login': 'noemie',
'alias_name': 'noemie',
'email': 'n.n@example.com',
'signature': '--\nNoemie',
'notify_email': 'always',
@@ -18,14 +18,12 @@ class TestProjectBase(TestMail):
cls.user_projectuser = Users.create({
'name': 'Armande ProjectUser',
'login': 'Armande',
'alias_name': 'armande',
'email': 'armande.projectuser@example.com',
'groups_id': [(6, 0, [user_group_employee.id, user_group_project_user.id])]
})
cls.user_projectmanager = Users.create({
'name': 'Bastien ProjectManager',
'login': 'bastien',
'alias_name': 'bastien',
'email': 'bastien.projectmanager@example.com',
'groups_id': [(6, 0, [user_group_employee.id, user_group_project_manager.id])]})
-2
View File
@@ -13,7 +13,6 @@ class TestSale(AccountingTestCase):
self.manager = self.env['res.users'].create({
'name': 'Andrew Manager',
'login': 'manager',
'alias_name': 'andrew',
'email': 'a.m@example.com',
'signature': '--\nAndreww',
'notify_email': 'always',
@@ -22,7 +21,6 @@ class TestSale(AccountingTestCase):
self.user = self.env['res.users'].create({
'name': 'Mark User',
'login': 'user',
'alias_name': 'mark',
'email': 'm.u@example.com',
'signature': '--\nMark',
'notify_email': 'always',
+3 -3
View File
@@ -20,15 +20,15 @@ class TestSurvey(TransactionCase):
User = self.env['res.users'].with_context({'no_reset_password': True})
(group_survey_user, group_employee) = (self.ref('base.group_survey_user'), self.ref('base.group_user'))
self.survey_manager = User.create({
'name': 'Gustave Doré', 'login': 'Gustav', 'alias_name': 'gustav', 'email': 'gustav.dore@example.com',
'name': 'Gustave Doré', 'login': 'Gustav','email': 'gustav.dore@example.com',
'groups_id': [(6, 0, [self.ref('base.group_survey_manager'), group_survey_user, group_employee])]})
self.survey_user = User.create({
'name': 'Lukas Peeters', 'login': 'Lukas', 'alias_name': 'lukas', 'email': 'lukas.petters@example.com',
'name': 'Lukas Peeters', 'login': 'Lukas', 'email': 'lukas.petters@example.com',
'groups_id': [(6, 0, [group_survey_user, group_employee])]})
self.user_public = User.create({
'name': 'Wout Janssens', 'login': 'Wout', 'alias_name': 'wout', 'email': 'wout.janssens@example.com',
'name': 'Wout Janssens', 'login': 'Wout', 'email': 'wout.janssens@example.com',
'groups_id': [(6, 0, [self.ref('base.group_public')])]})
self.survey1 = self.env['survey.survey'].sudo(self.survey_manager).create({'title': "S0", 'page_ids': [(0, 0, {'title': "P0"})]})
@@ -217,3 +217,14 @@ class BlogPost(models.Model):
for category, data in res.iteritems():
res[category]['button_access'] = {'url': access_action, 'title': _('View Blog Post')}
return res
@api.multi
def message_get_message_notify_values(self, message, message_values):
""" Override to avoid keeping all notified recipients of a comment.
We avoid tracking needaction on post comments. Only emails should be
sufficient. """
if message.message_type == 'comment':
return {
'needaction_partner_ids': [],
}
return {}
-3
View File
@@ -17,7 +17,6 @@ class TestWebsiteBlogCommon(common.TransactionCase):
self.user_employee = Users.with_context({'no_reset_password': True}).create({
'name': 'Armande Employee',
'login': 'armande',
'alias_name': 'armande',
'email': 'armande.employee@example.com',
'notify_email': 'none',
'groups_id': [(6, 0, [group_employee_id])]
@@ -25,7 +24,6 @@ class TestWebsiteBlogCommon(common.TransactionCase):
self.user_blogmanager = Users.with_context({'no_reset_password': True}).create({
'name': 'Bastien BlogManager',
'login': 'bastien',
'alias_name': 'bastien',
'email': 'bastien.blogmanager@example.com',
'notify_email': 'none',
'groups_id': [(6, 0, [group_blog_manager_id, group_employee_id])]
@@ -33,7 +31,6 @@ class TestWebsiteBlogCommon(common.TransactionCase):
self.user_public = Users.with_context({'no_reset_password': True}).create({
'name': 'Cedric Public',
'login': 'cedric',
'alias_name': 'cedric',
'email': 'cedric.public@example.com',
'notify_email': 'none',
'groups_id': [(6, 0, [group_public_id])]
+11 -9
View File
@@ -810,6 +810,17 @@ class Post(models.Model):
kwargs['record_name'] = kwargs.get('record_name') or self.parent_id and self.parent_id.name
return super(Post, self).message_post(message_type=message_type, subtype=subtype, **kwargs)
@api.multi
def message_get_message_notify_values(self, message, message_values):
""" Override to avoid keeping all notified recipients of a comment.
We avoid tracking needaction on post comments. Only emails should be
sufficient. """
if message.message_type == 'comment':
return {
'needaction_partner_ids': [],
}
return {}
class PostReason(models.Model):
_name = "forum.post.reason"
@@ -911,12 +922,3 @@ class Tags(models.Model):
if self.env.user.karma < forum.karma_tag_create:
raise KarmaError(_('Not enough karma to create a new Tag'))
return super(Tags, self.with_context(mail_create_nolog=True, mail_create_nosubscribe=True)).create(vals)
class Message(models.Model):
_name = 'mail.message'
_inherit = ['mail.message']
@api.multi
def _is_accessible(self):
res = super(Message, self)._is_accessible()
return self.model == 'forum.post' or res
-3
View File
@@ -35,7 +35,6 @@ class TestForumCommon(common.SavepointCase):
cls.user_employee = TestUsersEnv.create({
'name': 'Armande Employee',
'login': 'Armande',
'alias_name': 'armande',
'email': 'armande.employee@example.com',
'karma': 0,
'groups_id': [(6, 0, [group_employee_id])]
@@ -43,7 +42,6 @@ class TestForumCommon(common.SavepointCase):
cls.user_portal = TestUsersEnv.create({
'name': 'Beatrice Portal',
'login': 'Beatrice',
'alias_name': 'beatrice',
'email': 'beatrice.employee@example.com',
'karma': 0,
'groups_id': [(6, 0, [group_portal_id])]
@@ -51,7 +49,6 @@ class TestForumCommon(common.SavepointCase):
cls.user_public = TestUsersEnv.create({
'name': 'Cedric Public',
'login': 'Cedric',
'alias_name': 'cedric',
'email': 'cedric.employee@example.com',
'karma': 0,
'groups_id': [(6, 0, [group_public_id])]
+9
View File
@@ -200,6 +200,10 @@ class Partner(models.Model, FormatAddress):
company_id = fields.Many2one('res.company', 'Company', index=True, default=_default_company)
color = fields.Integer(string='Color Index', default=0)
user_ids = fields.One2many('res.users', 'partner_id', string='Users', auto_join=True)
partner_share = fields.Boolean(
'Share Partner', compute='_compute_partner_share', store=True,
help="Either customer (no user), either shared user. Indicated the current partner is a customer without "
"access or with a limited access created for sharing data.")
contact_address = fields.Char(compute='_compute_contact_address', string='Complete Address')
# technical field used for managing commercial fields
@@ -239,6 +243,11 @@ class Partner(models.Model, FormatAddress):
for partner in self:
partner.tz_offset = datetime.datetime.now(pytz.timezone(partner.tz or 'GMT')).strftime('%z')
@api.depends('user_ids.share')
def _compute_partner_share(self):
for partner in self:
partner.partner_share = not partner.user_ids or any(user.share for user in partner.user_ids)
@api.depends(lambda self: self._display_address_depends())
def _compute_contact_address(self):
for partner in self:
+38 -4
View File
@@ -1,21 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from lxml import etree
import cgi
import logging
import lxml.html
import lxml.html.clean as clean
import random
import re
import socket
import threading
import time
from email.header import decode_header
from email.utils import getaddresses, formataddr
from lxml import etree
import openerp
from openerp.loglevels import ustr
from openerp.tools.translate import _
_logger = logging.getLogger(__name__)
@@ -403,13 +403,16 @@ email_re = re.compile(r"""([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63})""",
# matches a string containing only one email
single_email_re = re.compile(r"""^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$""", re.VERBOSE)
res_re = re.compile(r"\[([0-9]+)\]", re.UNICODE)
# update command in emails body
command_re = re.compile("^Set-([a-z]+) *: *(.+)$", re.I + re.UNICODE)
# Updated in 7.0 to match the model name as well
# Typical form of references is <timestamp-openerp-record_id-model_name@domain>
# group(1) = the record ID ; group(2) = the model (if any) ; group(3) = the domain
reference_re = re.compile("<.*-open(?:object|erp)-(\\d+)(?:-([\w.]+))?[^>]*@([^>]*)>", re.UNICODE)
discussion_re = re.compile("<.*-open(?:object|erp)-private[^>]*@([^>]*)>", re.UNICODE)
mail_header_msgid_re = re.compile('<[^<>]+>')
def generate_tracking_message_id(res_id):
@@ -487,3 +490,34 @@ def email_split_and_format(text):
# is strictly required in RFC2822's `addr-spec`.
if addr[1]
if '@' in addr[1]]
def email_references(references):
ref_match, model, thread_id, hostname, is_private = False, False, False, False, False
if references:
ref_match = reference_re.search(references)
if ref_match:
model = ref_match.group(2)
thread_id = int(ref_match.group(1))
hostname = ref_match.group(3)
else:
ref_match = discussion_re.search(references)
if ref_match:
is_private = True
return (ref_match, model, thread_id, hostname, is_private)
# was mail_message.decode()
def decode_smtp_header(smtp_header):
"""Returns unicode() string conversion of the given encoded smtp header
text. email.header decode_header method return a decoded string and its
charset for each decoded par of the header. This method unicodes the
decoded header and join them in a complete string. """
if smtp_header:
text = decode_header(smtp_header.replace('\r', ''))
# The joining space will not be needed as of Python 3.3
# See https://hg.python.org/cpython/rev/8c03fe231877
return ' '.join([ustr(x[0], x[1]) for x in text])
return u''
# was mail_thread.decode_header()
def decode_message_header(message, header, separator=' '):
return separator.join(map(decode_smtp_header, filter(None, message.get_all(header, []))))