diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 1a567a275ed..90786a57b0e 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -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 ->
                 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)
diff --git a/addons/mail/tests/test_mail_gateway.py b/addons/mail/tests/test_mail_gateway.py
index d948c50d90a..d24f69d77f8 100644
--- a/addons/mail/tests/test_mail_gateway.py
+++ b/addons/mail/tests/test_mail_gateway.py
@@ -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 ; 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 ; 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: 
+Subject: {subject}
+From: =?UTF-8?Q?Thibault_Delavall=C3=A9e?= 
+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
+
+
Premi=C3=A8re image, orang=C3=A9e.

3D"Inline

Seconde image, rosa=C3=A7=C3= +=A9e.

3D"=

Troisi= +=C3=A8me image, verte!=C2=B5

3D"Inline

J'esp=C3=A8re que tout se passera bien.
--
Thibault Delavall=C3=A9e
+
+ +--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: +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: +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: +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 """