[IMP] mail: gateway: CIDs
Finally add support of CIDs in incoming emails. Inline images are recognized and added as attachments. Image links are updated from src=cid: to src=link using the /web/image controller. It has been decided to keep the image in atttachments instead of putting them in base64, like proposed in various PRs. Indeed we prefer to store this data into the attachments instead of directly putting it in the mail_message table. Moreover this enable the display of attachments in the chatter and record views.
This commit is contained in:
@@ -12,6 +12,8 @@ import re
|
||||
import socket
|
||||
import time
|
||||
import xmlrpclib
|
||||
|
||||
from collections import namedtuple
|
||||
from email.message import Message
|
||||
from email.utils import formataddr
|
||||
from werkzeug import url_encode
|
||||
@@ -81,6 +83,7 @@ class MailThread(models.AbstractModel):
|
||||
_mail_flat_thread = True # flatten the discussino history
|
||||
_mail_post_access = 'write' # access required on the document to post on it
|
||||
_mail_mass_mailing = False # enable mass mailing on this model
|
||||
_Attachment = namedtuple('Attachment', ('fname', 'content', 'info'))
|
||||
|
||||
message_is_follower = fields.Boolean(
|
||||
'Is Follower', compute='_compute_is_follower', search='_search_is_follower')
|
||||
@@ -1303,6 +1306,13 @@ class MailThread(models.AbstractModel):
|
||||
postprocessed = True
|
||||
if node.getparent() is not None:
|
||||
to_remove.append(node)
|
||||
if node.tag == 'img' and node.get('src', '').startswith('cid:'):
|
||||
cid = node.get('src').split(':', 1)[1]
|
||||
related_attachment = [attach for attach in attachments if len(attach) == 2 and attach[2] == cid]
|
||||
if related_attachment:
|
||||
node.set('data-filename', related_attachment[0])
|
||||
postprocessed = True
|
||||
|
||||
for node in to_remove:
|
||||
node.getparent().remove(node)
|
||||
if postprocessed:
|
||||
@@ -1314,7 +1324,7 @@ class MailThread(models.AbstractModel):
|
||||
attachments = []
|
||||
body = u''
|
||||
if save_original:
|
||||
attachments.append(('original_email.eml', message.as_string()))
|
||||
attachments.append(self._Attachment('original_email.eml', message.as_string(), {}))
|
||||
|
||||
# Be careful, content-type may contain tricky content like in the
|
||||
# following example so test the MIME type with startswith()
|
||||
@@ -1344,19 +1354,25 @@ class MailThread(models.AbstractModel):
|
||||
# original get_filename is not able to decode iso-8859-1 (for instance).
|
||||
# therefore, iso encoded attachements are not able to be decoded properly with get_filename
|
||||
# code here partially copy the original get_filename method, but handle more encoding
|
||||
filename=part.get_param('filename', None, 'content-disposition')
|
||||
filename = part.get_param('filename', None, 'content-disposition')
|
||||
if not filename:
|
||||
filename=part.get_param('name', None)
|
||||
filename = part.get_param('name', None)
|
||||
if filename:
|
||||
if isinstance(filename, tuple):
|
||||
# RFC2231
|
||||
filename=email.utils.collapse_rfc2231_value(filename).strip()
|
||||
filename = email.utils.collapse_rfc2231_value(filename).strip()
|
||||
else:
|
||||
filename=decode(filename)
|
||||
filename = decode(filename)
|
||||
encoding = part.get_content_charset() # None if attachment
|
||||
|
||||
# 0) Inline Attachments -> attachments, with a third part in the tuple to match cid / attachment
|
||||
if filename and part.get('content-id'):
|
||||
inner_cid = part.get('content-id').strip('><')
|
||||
attachments.append(self._Attachment(filename, part.get_payload(decode=True), {'cid': inner_cid}))
|
||||
continue
|
||||
# 1) Explicit Attachments -> attachments
|
||||
if filename or part.get('content-disposition', '').strip().startswith('attachment'):
|
||||
attachments.append((filename or 'attachment', part.get_payload(decode=True)))
|
||||
attachments.append(self._Attachment(filename or 'attachment', part.get_payload(decode=True), {}))
|
||||
continue
|
||||
# 2) text/plain -> <pre/>
|
||||
if part.get_content_type() == 'text/plain' and (not alternative or not body):
|
||||
@@ -1374,7 +1390,7 @@ class MailThread(models.AbstractModel):
|
||||
body = tools.append_content_to_html(body, html, plaintext=False)
|
||||
# 4) Anything else -> attachment
|
||||
else:
|
||||
attachments.append((filename or 'attachment', part.get_payload(decode=True)))
|
||||
attachments.append(self._Attachment(filename or 'attachment', part.get_payload(decode=True), {}))
|
||||
|
||||
body, attachments = self._message_extract_payload_postprocess(message, body, attachments)
|
||||
return body, attachments
|
||||
@@ -1601,7 +1617,6 @@ class MailThread(models.AbstractModel):
|
||||
]).write({'author_id': partner_info['partner_id']})
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def _message_preprocess_attachments(self, attachments, attachment_ids, attach_model, attach_res_id):
|
||||
""" Preprocess attachments for mail_thread.message_post() or mail_mail.create().
|
||||
|
||||
@@ -1611,17 +1626,30 @@ class MailThread(models.AbstractModel):
|
||||
:param str attach_model: the model of the attachments parent record
|
||||
:param integer attach_res_id: the id of the attachments parent record
|
||||
"""
|
||||
return self._message_post_process_attachments(attachments, attachment_ids, {'model': attach_model, 'res_id': attach_res_id})
|
||||
|
||||
def _message_post_process_attachments(self, attachments, attachment_ids, message_data):
|
||||
IrAttachment, parameter_attachments = self.env['ir.attachment'], self.env['ir.attachment']
|
||||
m2m_attachment_ids = []
|
||||
cid_mapping = {}
|
||||
if attachment_ids:
|
||||
filtered_attachment_ids = self.env['ir.attachment'].sudo().search([
|
||||
('res_model', '=', 'mail.compose.message'),
|
||||
('create_uid', '=', self._uid),
|
||||
('id', 'in', attachment_ids)])
|
||||
if filtered_attachment_ids:
|
||||
filtered_attachment_ids.write({'res_model': attach_model, 'res_id': attach_res_id})
|
||||
filtered_attachment_ids.write({'res_model': message_data['model'], 'res_id': message_data['res_id']})
|
||||
m2m_attachment_ids += [(4, id) for id in attachment_ids]
|
||||
# Handle attachments parameter, that is a dictionary of attachments
|
||||
for name, content in attachments:
|
||||
for attachment in attachments:
|
||||
if len(attachment) == 2:
|
||||
name, content = attachment
|
||||
elif len(attachment) == 3:
|
||||
name, content, info = attachment
|
||||
if info and info.get('cid'):
|
||||
cid_mapping[info['cid']] = name
|
||||
else:
|
||||
continue
|
||||
if isinstance(content, unicode):
|
||||
content = content.encode('utf-8')
|
||||
data_attach = {
|
||||
@@ -1629,10 +1657,26 @@ class MailThread(models.AbstractModel):
|
||||
'datas': base64.b64encode(str(content)),
|
||||
'datas_fname': name,
|
||||
'description': name,
|
||||
'res_model': attach_model,
|
||||
'res_id': attach_res_id,
|
||||
'res_model': message_data['model'],
|
||||
'res_id': message_data['res_id'],
|
||||
}
|
||||
m2m_attachment_ids.append((0, 0, data_attach))
|
||||
parameter_attachments |= IrAttachment.create(data_attach)
|
||||
m2m_attachment_ids += [(4, attach.id) for attach in parameter_attachments]
|
||||
|
||||
if cid_mapping and message_data.get('body'):
|
||||
root = lxml.html.fromstring(tools.ustr(message_data['body']))
|
||||
postprocessed = False
|
||||
for node in root.iter('img'):
|
||||
if node.get('src', '').startswith('cid:'):
|
||||
fname = cid_mapping.get(node.get('src').split('cid:')[1], node.get('data-filename', ''))
|
||||
attachment = parameter_attachments.filtered(lambda attachment: attachment.name == fname)
|
||||
if attachment:
|
||||
node.set('src', '/web/image/%s' % attachment.id)
|
||||
postprocessed = True
|
||||
if postprocessed:
|
||||
body = lxml.html.tostring(root, pretty_print=False, encoding='UTF-8')
|
||||
message_data['body'] = body
|
||||
|
||||
return m2m_attachment_ids
|
||||
|
||||
@api.multi
|
||||
@@ -1706,11 +1750,6 @@ class MailThread(models.AbstractModel):
|
||||
private_followers -= set([author_id])
|
||||
partner_ids |= private_followers
|
||||
|
||||
# 3. Attachments
|
||||
# - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
|
||||
attachment_ids = self._message_preprocess_attachments(
|
||||
attachments, kwargs.pop('attachment_ids', []), model, self.ids and self.ids[0] or None)
|
||||
|
||||
# 4: mail.message.subtype
|
||||
subtype_id = kwargs.get('subtype_id', False)
|
||||
if not subtype_id:
|
||||
@@ -1754,11 +1793,15 @@ class MailThread(models.AbstractModel):
|
||||
'subject': subject or False,
|
||||
'message_type': message_type,
|
||||
'parent_id': parent_id,
|
||||
'attachment_ids': attachment_ids,
|
||||
'subtype_id': subtype_id,
|
||||
'partner_ids': [(4, pid) for pid in partner_ids],
|
||||
})
|
||||
|
||||
# 3. Attachments
|
||||
# - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
|
||||
attachment_ids = self._message_post_process_attachments(attachments, kwargs.pop('attachment_ids', []), values)
|
||||
values['attachment_ids'] = attachment_ids
|
||||
|
||||
# Avoid warnings about non-existing fields
|
||||
for x in ('from', 'to', 'cc'):
|
||||
values.pop(x, None)
|
||||
|
||||
@@ -176,6 +176,115 @@ Content-Type: text/html;
|
||||
"""
|
||||
|
||||
|
||||
MAIL_MULTIPART_IMAGE = """X-Original-To: raoul@example.com
|
||||
Delivered-To: micheline@example.com
|
||||
Received: by mail1.example.com (Postfix, from userid 99999)
|
||||
id 9DFB7BF509; Thu, 17 Dec 2015 15:22:56 +0100 (CET)
|
||||
X-Spam-Checker-Version: SpamAssassin 3.4.0 (2014-02-07) on mail1.example.com
|
||||
X-Spam-Level: *
|
||||
X-Spam-Status: No, score=1.1 required=5.0 tests=FREEMAIL_FROM,
|
||||
HTML_IMAGE_ONLY_08,HTML_MESSAGE,RCVD_IN_DNSWL_LOW,RCVD_IN_MSPIKE_H3,
|
||||
RCVD_IN_MSPIKE_WL,T_DKIM_INVALID autolearn=no autolearn_force=no version=3.4.0
|
||||
Received: from mail-lf0-f44.example.com (mail-lf0-f44.example.com [209.85.215.44])
|
||||
by mail1.example.com (Postfix) with ESMTPS id 1D80DBF509
|
||||
for <micheline@example.com>; Thu, 17 Dec 2015 15:22:56 +0100 (CET)
|
||||
Authentication-Results: mail1.example.com; dkim=pass
|
||||
reason="2048-bit key; unprotected key"
|
||||
header.d=example.com header.i=@example.com header.b=kUkTIIlt;
|
||||
dkim-adsp=pass; dkim-atps=neutral
|
||||
Received: by mail-lf0-f44.example.com with SMTP id z124so47959461lfa.3
|
||||
for <micheline@example.com>; Thu, 17 Dec 2015 06:22:56 -0800 (PST)
|
||||
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
|
||||
d=example.com; s=20120113;
|
||||
h=mime-version:date:message-id:subject:from:to:content-type;
|
||||
bh=GdrEuMrz6vxo/Z/F+mJVho/1wSe6hbxLx2SsP8tihzw=;
|
||||
b=kUkTIIlt6fe4dftKHPNBkdHU2rO052o684R0e2bqH7roGUQFb78scYE+kqX0wo1zlk
|
||||
zhKPVBR1TqTsYlqcHu+D3aUzai7L/Q5m40sSGn7uYGkZJ6m1TwrWNqVIgTZibarqvy94
|
||||
NWhrjjK9gqd8segQdSjCgTipNSZME4bJCzPyBg/D5mqe07FPBJBGoF9SmIzEBhYeqLj1
|
||||
GrXjb/D8J11aOyzmVvyt+bT+oeLUJI8E7qO5g2eQkMncyu+TyIXaRofOOBA14NhQ+0nS
|
||||
w5O9rzzqkKuJEG4U2TJ2Vi2nl2tHJW2QPfTtFgcCzGxQ0+5n88OVlbGTLnhEIJ/SYpem
|
||||
O5EA==
|
||||
MIME-Version: 1.0
|
||||
X-Received: by 10.25.167.197 with SMTP id q188mr22222517lfe.129.1450362175493;
|
||||
Thu, 17 Dec 2015 06:22:55 -0800 (PST)
|
||||
Received: by 10.25.209.145 with HTTP; Thu, 17 Dec 2015 06:22:55 -0800 (PST)
|
||||
Date: Thu, 17 Dec 2015 15:22:55 +0100
|
||||
Message-ID: <CAP76m_UB=aLqWEFccnq86AhkpwRB3aZoGL9vMffX7co3YEro_A@mail.gmail.com>
|
||||
Subject: {subject}
|
||||
From: =?UTF-8?Q?Thibault_Delavall=C3=A9e?= <raoul@example.com>
|
||||
To: {to}
|
||||
Content-Type: multipart/related; boundary=001a11416b9e9b229a05272b7052
|
||||
|
||||
--001a11416b9e9b229a05272b7052
|
||||
Content-Type: multipart/alternative; boundary=001a11416b9e9b229805272b7051
|
||||
|
||||
--001a11416b9e9b229805272b7051
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
Premi=C3=A8re image, orang=C3=A9e.
|
||||
|
||||
[image: Inline image 1]
|
||||
|
||||
Seconde image, rosa=C3=A7=C3=A9e.
|
||||
|
||||
[image: Inline image 2]
|
||||
|
||||
Troisi=C3=A8me image, verte!=C2=B5
|
||||
|
||||
[image: Inline image 3]
|
||||
|
||||
J'esp=C3=A8re que tout se passera bien.
|
||||
--=20
|
||||
Thibault Delavall=C3=A9e
|
||||
|
||||
--001a11416b9e9b229805272b7051
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>Premi=C3=A8re image, orang=C3=A9e.</div><div><br></di=
|
||||
v><div><img src=3D"cid:ii_151b519fc025fdd3" alt=3D"Inline image 1" width=3D=
|
||||
"2" height=3D"2"><br></div><div><br></div><div>Seconde image, rosa=C3=A7=C3=
|
||||
=A9e.</div><div><br></div><div><img src=3D"cid:ii_151b51a290ed6a91" alt=3D"=
|
||||
Inline image 2" width=3D"2" height=3D"2"></div><div><br></div><div>Troisi=
|
||||
=C3=A8me image, verte!=C2=B5</div><div><br></div><div><img src=3D"cid:ii_15=
|
||||
1b51a37e5eb7a6" alt=3D"Inline image 3" width=3D"10" height=3D"10"><br></div=
|
||||
><div><br></div><div>J'esp=C3=A8re que tout se passera bien.</div>-- <b=
|
||||
r><div class=3D"gmail_signature">Thibault Delavall=C3=A9e</div>
|
||||
</div>
|
||||
|
||||
--001a11416b9e9b229805272b7051--
|
||||
--001a11416b9e9b229a05272b7052
|
||||
Content-Type: image/gif; name="=?UTF-8?B?b3JhbmfDqWUuZ2lm?="
|
||||
Content-Disposition: inline; filename="=?UTF-8?B?b3JhbmfDqWUuZ2lm?="
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <ii_151b519fc025fdd3>
|
||||
X-Attachment-Id: ii_151b519fc025fdd3
|
||||
|
||||
R0lGODdhAgACALMAAAAAAP///wAAAP//AP8AAP+AAAD/AAAAAAAA//8A/wAAAAAAAAAAAAAAAAAA
|
||||
AAAAACwAAAAAAgACAAAEA7DIEgA7
|
||||
--001a11416b9e9b229a05272b7052
|
||||
Content-Type: image/gif; name="=?UTF-8?B?dmVydGUhwrUuZ2lm?="
|
||||
Content-Disposition: inline; filename="=?UTF-8?B?dmVydGUhwrUuZ2lm?="
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <ii_151b51a37e5eb7a6>
|
||||
X-Attachment-Id: ii_151b51a37e5eb7a6
|
||||
|
||||
R0lGODlhCgAKALMAAAAAAIAAAACAAICAAAAAgIAAgACAgMDAwICAgP8AAAD/AP//AAAA//8A/wD/
|
||||
/////ywAAAAACgAKAAAEClDJSau9OOvNe44AOw==
|
||||
--001a11416b9e9b229a05272b7052
|
||||
Content-Type: image/gif; name="=?UTF-8?B?cm9zYcOnw6llLmdpZg==?="
|
||||
Content-Disposition: inline; filename="=?UTF-8?B?cm9zYcOnw6llLmdpZg==?="
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <ii_151b51a290ed6a91>
|
||||
X-Attachment-Id: ii_151b51a290ed6a91
|
||||
|
||||
R0lGODdhAgACALMAAAAAAP///wAAAP//AP8AAP+AAAD/AAAAAAAA//8A/wAAAP+AgAAAAAAAAAAA
|
||||
AAAAACwAAAAAAgACAAAEA3DJFQA7
|
||||
--001a11416b9e9b229a05272b7052--
|
||||
"""
|
||||
|
||||
|
||||
class TestMailgateway(TestMail):
|
||||
|
||||
def setUp(self):
|
||||
@@ -230,6 +339,13 @@ class TestMailgateway(TestMail):
|
||||
res.get('body', ''),
|
||||
'message_parse: second part of the html version should be in body after parsing multipart/mixed')
|
||||
|
||||
@mute_logger('openerp.addons.mail.models.mail_thread')
|
||||
def test_message_process_cid(self):
|
||||
new_groups = self.format_and_process(MAIL_MULTIPART_IMAGE, subject='My Frogs', to='groups@example.com')
|
||||
message = new_groups.message_ids[0]
|
||||
for attachment in message.attachment_ids:
|
||||
self.assertIn('/web/image/%s' % attachment.id, message.body)
|
||||
|
||||
@mute_logger('openerp.addons.mail.models.mail_thread', 'openerp.models')
|
||||
def test_message_process_alias_basic(self):
|
||||
""" Incoming email on an alias creating a new record + message_new + message details """
|
||||
|
||||
Reference in New Issue
Block a user