msg_attachments_to_mail_message
This commit is contained in:
parent
98b5ed31fd
commit
aa4fbe72ec
18 changed files with 726 additions and 145 deletions
1
msg_attachments_to_mail_message/__init__.py
Normal file
1
msg_attachments_to_mail_message/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from . import models
|
||||||
61
msg_attachments_to_mail_message/__manifest__.py
Normal file
61
msg_attachments_to_mail_message/__manifest__.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
'name': 'MSG to Mail Message',
|
||||||
|
'version': '18.0.1.1',
|
||||||
|
'category': 'Mail',
|
||||||
|
'summary': 'Process MSG files as incoming emails',
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
'description': """
|
||||||
|
This module processes Outlook files (.msg) as incoming emails in Odoo when upload as attachments.
|
||||||
|
|
||||||
|
Features
|
||||||
|
========
|
||||||
|
1. Automatic MSG File Detection
|
||||||
|
- Identifies .msg files by extension or MIME type
|
||||||
|
- Automatic processing upon upload
|
||||||
|
|
||||||
|
2. Message Content Extraction
|
||||||
|
- Email subject
|
||||||
|
- Message body
|
||||||
|
- Sender information
|
||||||
|
- Send date
|
||||||
|
- Attachments
|
||||||
|
|
||||||
|
3. Odoo Message Creation
|
||||||
|
- Creates a mail.message in the chatter
|
||||||
|
- Preserves all original email information
|
||||||
|
- Integrates the message into existing discussion thread
|
||||||
|
- Don't add email receipent as follower, just add message and attachments
|
||||||
|
|
||||||
|
4. Attachment Management
|
||||||
|
- Automatically extracts all attachments from MSG file
|
||||||
|
- Creates standard Odoo attachments for each file
|
||||||
|
- Links attachments to the created message
|
||||||
|
- Preserves original filenames
|
||||||
|
|
||||||
|
Usage
|
||||||
|
=====
|
||||||
|
- Simply upload a .msg file to Odoo
|
||||||
|
- The module automatically processes the file
|
||||||
|
- Message appears in the discussion thread
|
||||||
|
- Attachments are accessible as standard attachments
|
||||||
|
|
||||||
|
Technical Notes
|
||||||
|
===============
|
||||||
|
Requires Python library 'extract-msg' for MSG file processing.
|
||||||
|
""",
|
||||||
|
'author': 'Bemade inc.',
|
||||||
|
'website': 'https://www.bemade.org',
|
||||||
|
'depends': [
|
||||||
|
'base',
|
||||||
|
'mail',
|
||||||
|
],
|
||||||
|
'data': [
|
||||||
|
'views/res_config_settings_views.xml',
|
||||||
|
],
|
||||||
|
'external_dependencies': {
|
||||||
|
'python': ['extract-msg'],
|
||||||
|
},
|
||||||
|
'installable': True,
|
||||||
|
'auto_install': False,
|
||||||
|
'application': False,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
from odoo import SUPERUSER_ID, api
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
if not version:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add msg_processed column
|
||||||
|
cr.execute("""
|
||||||
|
ALTER TABLE ir_attachment
|
||||||
|
ADD COLUMN IF NOT EXISTS msg_processed boolean DEFAULT false
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Add mail_message_id column
|
||||||
|
cr.execute("""
|
||||||
|
ALTER TABLE ir_attachment
|
||||||
|
ADD COLUMN IF NOT EXISTS mail_message_id integer
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Add foreign key constraint
|
||||||
|
cr.execute("""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.constraint_column_usage
|
||||||
|
WHERE table_name = 'ir_attachment'
|
||||||
|
AND column_name = 'mail_message_id'
|
||||||
|
AND constraint_name = 'ir_attachment_mail_message_id_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ir_attachment
|
||||||
|
ADD CONSTRAINT ir_attachment_mail_message_id_fkey
|
||||||
|
FOREIGN KEY (mail_message_id)
|
||||||
|
REFERENCES mail_message(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
""")
|
||||||
1
msg_attachments_to_mail_message/migrations/__init__.py
Normal file
1
msg_attachments_to_mail_message/migrations/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
2
msg_attachments_to_mail_message/models/__init__.py
Normal file
2
msg_attachments_to_mail_message/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
from . import ir_attachment
|
||||||
|
from . import res_config_settings
|
||||||
290
msg_attachments_to_mail_message/models/ir_attachment.py
Normal file
290
msg_attachments_to_mail_message/models/ir_attachment.py
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
from odoo import models, api, fields
|
||||||
|
from odoo.tools import html_sanitize
|
||||||
|
import extract_msg
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from odoo.tools.translate import _
|
||||||
|
from email.message import EmailMessage
|
||||||
|
import lxml.html
|
||||||
|
import re
|
||||||
|
from odoo.tools import email_normalize, email_split
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class IrAttachment(models.Model):
|
||||||
|
_inherit = 'ir.attachment'
|
||||||
|
|
||||||
|
msg_processed = fields.Boolean(string='MSG Processed', default=False)
|
||||||
|
mail_message_id = fields.Many2one('mail.message', string='Created Mail Message')
|
||||||
|
|
||||||
|
def _is_msg_file(self):
|
||||||
|
"""Check if the attachment is an MSG file and validate size."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
# Check if it's an MSG file
|
||||||
|
is_msg = (
|
||||||
|
self.mimetype == 'application/vnd.ms-outlook' or
|
||||||
|
(self.name and self.name.lower().endswith('.msg'))
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_msg:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check file size
|
||||||
|
max_size = int(self.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'msg_attachments.max_file_size', '25'))
|
||||||
|
file_size_mb = len(self.datas) * 3 / 4 / 1024 / 1024 # Convert from base64 to MB
|
||||||
|
|
||||||
|
if file_size_mb > max_size:
|
||||||
|
self._notify_error('File Size Error',
|
||||||
|
_('The MSG file is too large. Maximum allowed size is %s MB') % max_size)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _clean_header_value(self, value):
|
||||||
|
"""Clean header value by removing line breaks and extra whitespace."""
|
||||||
|
if not value:
|
||||||
|
return ''
|
||||||
|
# Replace any combination of whitespace (including newlines) with a single space
|
||||||
|
return ' '.join(str(value).split())
|
||||||
|
|
||||||
|
def process_msg_as_email(self):
|
||||||
|
"""Convert MSG file to standard email format and process it using Odoo's mail module."""
|
||||||
|
self.ensure_one()
|
||||||
|
_logger.info('Starting MSG processing for file: %s (model: %s, res_id: %s)',
|
||||||
|
self.name, self.res_model, self.res_id)
|
||||||
|
|
||||||
|
if not self._is_msg_file():
|
||||||
|
_logger.warning('File %s is not a valid MSG file', self.name)
|
||||||
|
self._notify_error('Invalid File',
|
||||||
|
_('The selected file is not a valid MSG file'))
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.datas:
|
||||||
|
try:
|
||||||
|
# Try to reload data from filestore
|
||||||
|
self.datas = self.datas
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error('Failed to read file %s from filestore: %s', self.name, str(e))
|
||||||
|
self._notify_error('File Access Error',
|
||||||
|
_('The MSG file could not be read from the filestore. The file may have been moved or deleted.'))
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Read the MSG file
|
||||||
|
_logger.info('Decoding MSG file data')
|
||||||
|
msg_data = base64.b64decode(self.datas)
|
||||||
|
msg_file = extract_msg.Message(msg_data)
|
||||||
|
_logger.info('MSG file info - Subject: %s, From: %s, To: %s',
|
||||||
|
msg_file.subject, msg_file.sender, msg_file.to)
|
||||||
|
|
||||||
|
# Convert MSG to email format
|
||||||
|
email_msg = EmailMessage()
|
||||||
|
email_msg['Subject'] = self._clean_header_value(msg_file.subject) or _("No Subject")
|
||||||
|
email_msg['From'] = self._clean_header_value(msg_file.sender)
|
||||||
|
email_msg['To'] = self._clean_header_value(msg_file.to)
|
||||||
|
email_msg['Cc'] = self._clean_header_value(msg_file.cc)
|
||||||
|
email_msg['Date'] = msg_file.date.strftime('%a, %d %b %Y %H:%M:%S %z') if msg_file.date else ''
|
||||||
|
email_msg['References'] = self._clean_header_value(msg_file.header.get('References', ''))
|
||||||
|
email_msg['In-Reply-To'] = self._clean_header_value(msg_file.header.get('In-Reply-To', ''))
|
||||||
|
email_msg['Message-ID'] = self._clean_header_value(msg_file.header.get('Message-Id', ''))
|
||||||
|
|
||||||
|
# Add X-Headers to force message association
|
||||||
|
if self.res_model and self.res_id:
|
||||||
|
email_msg['X-Odoo-Objects'] = f'{self.res_model}-{self.res_id}'
|
||||||
|
|
||||||
|
# Set the body
|
||||||
|
if msg_file.body:
|
||||||
|
_logger.info('Processing message body')
|
||||||
|
# Convert [cid:xxx] to proper HTML img tags
|
||||||
|
body = msg_file.body
|
||||||
|
cid_pattern = r'\[cid:([^\]]+)\]'
|
||||||
|
body = re.sub(cid_pattern, r'<img src="cid:\1">', body)
|
||||||
|
# Nettoyer le HTML si nécessaire
|
||||||
|
if '<html' in body.lower():
|
||||||
|
body = html_sanitize(body)
|
||||||
|
email_msg.set_content(body, subtype='html')
|
||||||
|
_logger.debug('Message body length: %d characters', len(body))
|
||||||
|
else:
|
||||||
|
_logger.warning('No message body found')
|
||||||
|
email_msg.set_content('', subtype='html')
|
||||||
|
|
||||||
|
# Add attachments
|
||||||
|
attachment_mapping = {}
|
||||||
|
attachment_ids = []
|
||||||
|
_logger.info('Processing %d attachments', len(msg_file.attachments))
|
||||||
|
for idx, attachment in enumerate(msg_file.attachments, 1):
|
||||||
|
if not hasattr(attachment, 'data') or not attachment.data:
|
||||||
|
_logger.warning('Attachment %d has no data, skipping', idx)
|
||||||
|
continue
|
||||||
|
|
||||||
|
maintype, subtype = (attachment.mimetype or 'application/octet-stream').split('/', 1)
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
filename = None
|
||||||
|
if hasattr(attachment, 'filename'):
|
||||||
|
filename = attachment.filename
|
||||||
|
elif hasattr(attachment, 'longFilename'):
|
||||||
|
filename = attachment.longFilename
|
||||||
|
elif hasattr(attachment, 'shortFilename'):
|
||||||
|
filename = attachment.shortFilename
|
||||||
|
|
||||||
|
_logger.info('Processing attachment %d: %s (%s/%s)',
|
||||||
|
idx, filename or 'unknown.bin', maintype, subtype)
|
||||||
|
|
||||||
|
if hasattr(attachment, 'content_id') and attachment.content_id:
|
||||||
|
# Ensure Content-ID is properly formatted with <>
|
||||||
|
cid = attachment.content_id.strip('<>')
|
||||||
|
headers['Content-ID'] = f'<{cid}>'
|
||||||
|
|
||||||
|
# Create Odoo attachment for all attachments, not just those with CID
|
||||||
|
try:
|
||||||
|
odoo_attachment = self.env['ir.attachment'].create({
|
||||||
|
'name': filename or 'unknown.bin',
|
||||||
|
'datas': base64.b64encode(attachment.data),
|
||||||
|
'mimetype': attachment.mimetype or 'application/octet-stream',
|
||||||
|
'res_model': self.res_model,
|
||||||
|
'res_id': self.res_id,
|
||||||
|
})
|
||||||
|
_logger.info('Created Odoo attachment: %s (ID: %s)',
|
||||||
|
odoo_attachment.name, odoo_attachment.id)
|
||||||
|
|
||||||
|
attachment_ids.append(odoo_attachment.id)
|
||||||
|
if headers.get('Content-ID'):
|
||||||
|
attachment_mapping[cid] = odoo_attachment.id
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error('Failed to create attachment %s: %s', filename, str(e))
|
||||||
|
|
||||||
|
email_msg.add_attachment(
|
||||||
|
attachment.data,
|
||||||
|
maintype=maintype,
|
||||||
|
subtype=subtype,
|
||||||
|
filename=filename or 'unknown.bin',
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process using Odoo's standard mail handling
|
||||||
|
thread_model = self.env[self.res_model] if self.res_model else self.env['mail.thread']
|
||||||
|
_logger.info('Using thread model: %s', thread_model._name)
|
||||||
|
|
||||||
|
# Forcer le contexte pour la création du message
|
||||||
|
context = {
|
||||||
|
'default_model': self.res_model,
|
||||||
|
'default_res_id': self.res_id,
|
||||||
|
'mail_create_nosubscribe': True,
|
||||||
|
'mail_create_nolog': True,
|
||||||
|
'mail_notify_force_send': False,
|
||||||
|
'mail_create_force_model': self.res_model,
|
||||||
|
'mail_thread_quote': False,
|
||||||
|
}
|
||||||
|
_logger.info('Setting context for message creation: %s', context)
|
||||||
|
thread_model = thread_model.with_context(**context)
|
||||||
|
|
||||||
|
# Créer directement le message si nous avons un modèle et un ID
|
||||||
|
if self.res_model and self.res_id:
|
||||||
|
_logger.info('Creating message directly for model %s with ID %s',
|
||||||
|
self.res_model, self.res_id)
|
||||||
|
# Trouver l'auteur du message
|
||||||
|
author_id = False
|
||||||
|
if msg_file.sender:
|
||||||
|
email_normalized = email_normalize(msg_file.sender)
|
||||||
|
_logger.info('Looking for partner with email: %s', email_normalized)
|
||||||
|
partner = self.env['res.partner'].sudo().search([
|
||||||
|
('email_normalized', '=', email_normalized)
|
||||||
|
], limit=1)
|
||||||
|
if partner:
|
||||||
|
author_id = partner.id
|
||||||
|
_logger.info('Found partner: %s (ID: %s)', partner.name, partner.id)
|
||||||
|
else:
|
||||||
|
_logger.info('No partner found for email %s', email_normalized)
|
||||||
|
|
||||||
|
# Créer le message directement
|
||||||
|
try:
|
||||||
|
vals = {
|
||||||
|
'subject': msg_file.subject or _("No Subject"),
|
||||||
|
'body': body if msg_file.body else '',
|
||||||
|
'email_from': msg_file.sender,
|
||||||
|
'author_id': author_id,
|
||||||
|
'message_type': 'email',
|
||||||
|
'model': self.res_model,
|
||||||
|
'res_id': self.res_id,
|
||||||
|
'attachment_ids': [(6, 0, attachment_ids)],
|
||||||
|
}
|
||||||
|
_logger.info('Creating mail.message with values: %s', vals)
|
||||||
|
message = self.env['mail.message'].create(vals)
|
||||||
|
_logger.info('Created message successfully: ID %s', message.id)
|
||||||
|
|
||||||
|
# Marquer le fichier MSG comme traité
|
||||||
|
self.write({
|
||||||
|
'description': _('Processed as email on %s') % fields.Datetime.now(),
|
||||||
|
'msg_processed': True,
|
||||||
|
'mail_message_id': message.id,
|
||||||
|
})
|
||||||
|
_logger.info('MSG file marked as processed')
|
||||||
|
|
||||||
|
return message.id
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error('Failed to create message: %s', str(e), exc_info=True)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
_logger.info('No model/ID available, falling back to message_process')
|
||||||
|
# Si nous n'avons pas de modèle/ID, utiliser message_process
|
||||||
|
thread_id = thread_model.message_process(
|
||||||
|
model=self.res_model,
|
||||||
|
message=email_msg.as_bytes(),
|
||||||
|
custom_values={
|
||||||
|
'res_id': self.res_id,
|
||||||
|
'model': self.res_model,
|
||||||
|
'msg_attachment_id': self.id,
|
||||||
|
},
|
||||||
|
thread_id=self.res_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if thread_id:
|
||||||
|
_logger.info('Message processed successfully, thread_id: %s', thread_id)
|
||||||
|
# Trouver le message créé
|
||||||
|
message = self.env['mail.message'].search([
|
||||||
|
('model', '=', self.res_model),
|
||||||
|
('res_id', '=', thread_id)
|
||||||
|
], order='id desc', limit=1)
|
||||||
|
|
||||||
|
if message:
|
||||||
|
_logger.info('Found created message: ID %s', message.id)
|
||||||
|
else:
|
||||||
|
_logger.warning('No message found after processing')
|
||||||
|
|
||||||
|
# Marquer le fichier MSG comme traité
|
||||||
|
self.write({
|
||||||
|
'description': _('Processed as email on %s') % fields.Datetime.now(),
|
||||||
|
'msg_processed': True,
|
||||||
|
'mail_message_id': message.id if message else False,
|
||||||
|
})
|
||||||
|
_logger.info('MSG file marked as processed')
|
||||||
|
else:
|
||||||
|
_logger.error('message_process failed to create thread_id')
|
||||||
|
|
||||||
|
return thread_id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error('Error processing MSG file %s: %s', self.name, str(e), exc_info=True)
|
||||||
|
self._notify_error('Processing Error',
|
||||||
|
_('Could not process the MSG file: %s') % str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _notify_error(self, title, message):
|
||||||
|
"""Show error notification to the user."""
|
||||||
|
self.env['bus.bus']._sendone(self.env.user.partner_id, 'notification', {
|
||||||
|
'type': 'danger',
|
||||||
|
'title': title,
|
||||||
|
'message': message,
|
||||||
|
'sticky': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
attachments = super().create(vals_list)
|
||||||
|
for attachment in attachments:
|
||||||
|
if attachment._is_msg_file():
|
||||||
|
attachment.process_msg_as_email()
|
||||||
|
return attachments
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
class ResConfigSettings(models.TransientModel):
|
||||||
|
_inherit = 'res.config.settings'
|
||||||
|
|
||||||
|
msg_max_file_size = fields.Integer(
|
||||||
|
string='Maximum MSG File Size (MB)',
|
||||||
|
config_parameter='msg_attachments.max_file_size',
|
||||||
|
default=25
|
||||||
|
)
|
||||||
|
|
||||||
|
msg_format_metadata = fields.Boolean(
|
||||||
|
string='Show Email Metadata in Message',
|
||||||
|
config_parameter='msg_attachments.format_metadata',
|
||||||
|
default=True
|
||||||
|
)
|
||||||
|
|
||||||
|
msg_attachment_mode = fields.Selection([
|
||||||
|
('all', 'Process All Attachments'),
|
||||||
|
('skip_empty', 'Skip Empty Attachments'),
|
||||||
|
('selective', 'Process Selected Types Only')
|
||||||
|
], string='Attachment Processing Mode',
|
||||||
|
config_parameter='msg_attachments.attachment_mode',
|
||||||
|
default='all'
|
||||||
|
)
|
||||||
|
|
||||||
|
msg_allowed_extensions = fields.Char(
|
||||||
|
string='Allowed Attachment Extensions',
|
||||||
|
config_parameter='msg_attachments.allowed_extensions',
|
||||||
|
default='.pdf,.doc,.docx,.xls,.xlsx,.jpg,.png',
|
||||||
|
help='Comma-separated list of allowed file extensions'
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.inherit.msg.attachments</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//form" position="inside">
|
||||||
|
<app data-string="MSG Attachments" string="MSG Attachments" name="msg_attachments" id="msg_attachments">
|
||||||
|
<block title="MSG File Processing" name="msg_file_processing">
|
||||||
|
<setting string="Maximum File Size" help="Maximum size for MSG files in megabytes">
|
||||||
|
<div class="content-group">
|
||||||
|
<div class="mt16">
|
||||||
|
<field name="msg_max_file_size" class="o_light_label"/>
|
||||||
|
<span> MB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting string="Show Email Metadata" help="Display sender and date information in processed messages">
|
||||||
|
<field name="msg_format_metadata"/>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting string="Attachment Processing" help="Configure how attachments should be processed">
|
||||||
|
<div class="content-group">
|
||||||
|
<div class="mt16">
|
||||||
|
<field name="msg_attachment_mode"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting string="Allowed Extensions" help="List of allowed file extensions for attachments (comma-separated)">
|
||||||
|
<div class="content-group">
|
||||||
|
<div class="mt16">
|
||||||
|
<field name="msg_allowed_extensions" class="o_light_label"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
|
</app>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
|
@ -15,26 +15,23 @@
|
||||||
'depends': [
|
'depends': [
|
||||||
'base',
|
'base',
|
||||||
'web',
|
'web',
|
||||||
'mail'
|
'mail',
|
||||||
|
'documents'
|
||||||
],
|
],
|
||||||
'data': [
|
'data': [
|
||||||
'views/msg_viewer_views.xml',
|
'views/msg_viewer_views.xml',
|
||||||
],
|
],
|
||||||
'assets': {
|
'assets': {
|
||||||
'web.assets_backend': [
|
'web.assets_backend': [
|
||||||
'msg_viewer/static/src/js/msg_viewer.js',
|
# MSG viewer components
|
||||||
'msg_viewer/static/src/xml/msg_viewer.xml',
|
'msg_viewer/static/src/models/attachment_card_msg.js',
|
||||||
# 'msg_viewer/static/src/css/msg_viewer.css',
|
'msg_viewer/static/src/components/**/*',
|
||||||
],
|
|
||||||
'msg_viewer.assets': [
|
# CSS
|
||||||
# MSG Viewer library files
|
'msg_viewer/static/src/css/**/*',
|
||||||
'msg_viewer/static/src/lib/msg-viewer.js',
|
|
||||||
'msg_viewer/static/src/lib/scripts/**/*',
|
# MSG Viewer library
|
||||||
'msg_viewer/static/src/lib/components/**/*',
|
('include', 'msg_viewer/static/src/lib/msg-viewer-main/dist/**/*'),
|
||||||
'msg_viewer/static/src/lib/styles/**/*',
|
|
||||||
],
|
|
||||||
'web.assets_qweb': [
|
|
||||||
'msg_viewer/static/src/xml/msg_viewer.xml',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'installable': True,
|
'installable': True,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-inherit="mail.AttachmentList" t-inherit-mode="extension" owl="1">
|
||||||
|
<xpath expr="//div[hasclass('o-mail-AttachmentCard-aside')]" position="before">
|
||||||
|
<div class="o-mail-AttachmentCard-aside position-relative rounded-end overflow-hidden"
|
||||||
|
t-if="isMsgFile(attachment)"
|
||||||
|
t-att-class="{ 'o-hasMultipleActions d-flex flex-column': attachment.isDeletable and !env.inComposer }">
|
||||||
|
<button t-on-click="() => this.openMsgViewer(attachment)"
|
||||||
|
class="btn d-flex justify-content-center align-items-center w-100 h-100 rounded-0"
|
||||||
|
t-attf-class="bg-300">
|
||||||
|
<i class="fa fa-envelope-o" role="img" aria-label="Open in MSG Viewer" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
72
msg_viewer/static/src/components/msg_viewer/msg_viewer.js
Normal file
72
msg_viewer/static/src/components/msg_viewer/msg_viewer.js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { Component, onMounted } from "@odoo/owl";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { Dialog } from "@web/core/dialog/dialog";
|
||||||
|
|
||||||
|
export class MsgViewerAction extends Component {
|
||||||
|
static template = "msg_viewer.Viewer";
|
||||||
|
static components = { Dialog };
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.orm = useService("orm");
|
||||||
|
this.action = useService("action");
|
||||||
|
this.notification = useService("notification");
|
||||||
|
console.log("MsgViewerAction setup", this.props);
|
||||||
|
|
||||||
|
onMounted(() => this.initMsgViewer());
|
||||||
|
}
|
||||||
|
|
||||||
|
async initMsgViewer() {
|
||||||
|
try {
|
||||||
|
console.log("Initializing MSG viewer");
|
||||||
|
const { model, id, filename } = this.props.action.params;
|
||||||
|
console.log("Params:", { model, id, filename });
|
||||||
|
|
||||||
|
// Get the MSG file content from the server
|
||||||
|
console.log("Fetching content from server...");
|
||||||
|
const data = await this.orm.call(
|
||||||
|
model,
|
||||||
|
'read',
|
||||||
|
[[id], ['datas']],
|
||||||
|
{ context: { bin_size: false } }
|
||||||
|
);
|
||||||
|
console.log("Got data from server:", data);
|
||||||
|
|
||||||
|
if (!data || !data.length || !data[0].datas) {
|
||||||
|
throw new Error("No data received from server");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the MSG viewer
|
||||||
|
console.log("Creating viewer instance...");
|
||||||
|
const container = this.el.querySelector("#msg-viewer-container");
|
||||||
|
console.log("Container:", container);
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
throw new Error("MSG viewer container not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.MsgViewer) {
|
||||||
|
throw new Error("MSG viewer library not loaded");
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewer = new window.MsgViewer({
|
||||||
|
data: data[0].datas,
|
||||||
|
container: container,
|
||||||
|
});
|
||||||
|
console.log("Viewer instance created:", viewer);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error initializing MSG viewer:", error);
|
||||||
|
this.notification.add(_t("Failed to initialize MSG viewer"), {
|
||||||
|
type: "danger",
|
||||||
|
});
|
||||||
|
// Close the modal
|
||||||
|
this.action.doAction({ type: "ir.actions.act_window_close" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.category("actions").add("msg_viewer", MsgViewerAction);
|
||||||
16
msg_viewer/static/src/components/msg_viewer/msg_viewer.xml
Normal file
16
msg_viewer/static/src/components/msg_viewer/msg_viewer.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<!-- Template for the MSG viewer modal -->
|
||||||
|
<t t-name="msg_viewer.Viewer">
|
||||||
|
<Dialog title="'MSG Viewer'" size="'xl'">
|
||||||
|
<div class="o_msg_viewer h-100">
|
||||||
|
<div id="msg-viewer-container" class="h-100"></div>
|
||||||
|
</div>
|
||||||
|
<t t-set-slot="footer">
|
||||||
|
<button class="btn btn-primary" t-on-click="() => this.action.doAction({ type: 'ir.actions.act_window_close' })">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</Dialog>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
|
@ -2,6 +2,12 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 600px;
|
height: 600px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_field_msg_viewer_container iframe {
|
.o_field_msg_viewer_container iframe {
|
||||||
|
|
@ -10,3 +16,33 @@
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.o_field_msg_viewer_container .fa {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_field_msg_viewer_container:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_msg_viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#msg-viewer-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_preview_file_button {
|
||||||
|
color: var(--primary);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,88 +1,49 @@
|
||||||
/** @odoo-module */
|
/** @odoo-module **/
|
||||||
|
|
||||||
import { registry } from "@web/core/registry";
|
import { AttachmentList } from "@mail/core/common/attachment_list";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
import { _t } from "@web/core/l10n/translation";
|
import { _t } from "@web/core/l10n/translation";
|
||||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
|
||||||
import { FileUploader } from "@web/views/fields/file_handler";
|
|
||||||
import { Component, onWillUpdateProps, useState } from "@odoo/owl";
|
|
||||||
import { url } from "@web/core/utils/urls";
|
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
class MsgViewerField extends Component {
|
console.log("Loading msg_viewer.js");
|
||||||
static template = "web.MsgViewerField";
|
|
||||||
static components = {
|
|
||||||
FileUploader,
|
|
||||||
};
|
|
||||||
static props = {
|
|
||||||
...standardFieldProps,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
patch(AttachmentList.prototype, {
|
||||||
setup() {
|
setup() {
|
||||||
|
this._super(...arguments);
|
||||||
|
this.action = useService("action");
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
this.state = useState({
|
console.log("MsgViewer patch setup called");
|
||||||
isValid: true,
|
},
|
||||||
objectUrl: "",
|
|
||||||
showViewer: false,
|
|
||||||
});
|
|
||||||
onWillUpdateProps((nextProps) => {
|
|
||||||
if (nextProps.readonly) {
|
|
||||||
this.state.objectUrl = "";
|
|
||||||
this.state.showViewer = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get url() {
|
isMsgFile(attachment) {
|
||||||
if (!this.state.isValid || !this.props.value) {
|
console.log("Checking if is MSG file:", attachment);
|
||||||
return null;
|
return attachment.mimetype === "application/vnd.ms-outlook" ||
|
||||||
}
|
(attachment.name && attachment.name.toLowerCase().endsWith('.msg'));
|
||||||
const file = encodeURIComponent(
|
},
|
||||||
this.state.objectUrl ||
|
|
||||||
url("/msg_viewer/content", {
|
|
||||||
model: this.props.record.resModel,
|
|
||||||
field: this.props.name,
|
|
||||||
id: this.props.record.resId,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return `/msg_viewer/static/src/lib/index.html?file=${file}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async onFileUploaded(file) {
|
async openMsgViewer(attachment) {
|
||||||
this.state.objectUrl = URL.createObjectURL(file);
|
console.log("Opening MSG viewer for:", attachment);
|
||||||
await this.props.update(file);
|
try {
|
||||||
}
|
const action = {
|
||||||
|
type: "ir.actions.client",
|
||||||
async onFileRemove() {
|
tag: "msg_viewer",
|
||||||
if (this.state.objectUrl) {
|
name: _t("MSG Viewer"),
|
||||||
URL.revokeObjectURL(this.state.objectUrl);
|
target: "new",
|
||||||
this.state.objectUrl = "";
|
params: {
|
||||||
}
|
model: attachment.resModel,
|
||||||
this.state.showViewer = false;
|
id: attachment.id,
|
||||||
await this.props.update(false);
|
filename: attachment.name,
|
||||||
}
|
},
|
||||||
|
};
|
||||||
openMsgViewer() {
|
console.log("Executing action:", action);
|
||||||
this.state.showViewer = true;
|
await this.action.doAction(action);
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error("Error in openMsgViewer:", error);
|
||||||
onLoadFailed() {
|
this.notification.add(_t("Failed to open MSG viewer"), {
|
||||||
this.notification.add(
|
|
||||||
_t("Could not display the selected MSG file."),
|
|
||||||
{
|
|
||||||
type: "danger",
|
type: "danger",
|
||||||
}
|
});
|
||||||
);
|
}
|
||||||
this.state.isValid = false;
|
},
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
|
||||||
export const msgViewerField = {
|
console.log("MSG viewer patch registered");
|
||||||
component: MsgViewerField,
|
|
||||||
displayName: _t("MSG Viewer"),
|
|
||||||
supportedTypes: ["binary"],
|
|
||||||
extractProps: ({ props }) => ({
|
|
||||||
acceptedFileExtensions: ".msg",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
registry.category("fields").add("msg_viewer", msgViewerField);
|
|
||||||
|
|
|
||||||
50
msg_viewer/static/src/models/attachment_card_msg.js
Normal file
50
msg_viewer/static/src/models/attachment_card_msg.js
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { AttachmentList } from "@mail/core/common/attachment_list";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
console.log("Loading MSG viewer patch");
|
||||||
|
|
||||||
|
patch(AttachmentList.prototype, {
|
||||||
|
setup() {
|
||||||
|
super.setup(...arguments);
|
||||||
|
this.orm = useService("orm");
|
||||||
|
this.action = useService("action");
|
||||||
|
this.notification = useService("notification");
|
||||||
|
console.log("MsgViewer patch setup called");
|
||||||
|
},
|
||||||
|
|
||||||
|
isMsgFile(attachment) {
|
||||||
|
console.log("Checking if is MSG file:", attachment);
|
||||||
|
return attachment.mimetype === "application/vnd.ms-outlook" ||
|
||||||
|
(attachment.name && attachment.name.toLowerCase().endsWith('.msg'));
|
||||||
|
},
|
||||||
|
|
||||||
|
async openMsgViewer(attachment) {
|
||||||
|
console.log("Opening MSG viewer for:", attachment);
|
||||||
|
try {
|
||||||
|
const action = {
|
||||||
|
type: "ir.actions.client",
|
||||||
|
tag: "msg_viewer",
|
||||||
|
name: _t("MSG Viewer"),
|
||||||
|
target: "new",
|
||||||
|
params: {
|
||||||
|
model: "ir.attachment",
|
||||||
|
id: attachment.id,
|
||||||
|
filename: attachment.name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
console.log("Executing action:", action);
|
||||||
|
await this.action.doAction(action);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in openMsgViewer:", error);
|
||||||
|
this.notification.add(_t("Failed to open MSG viewer"), {
|
||||||
|
type: "danger",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("MSG viewer patch registered");
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<templates xml:space="preserve">
|
|
||||||
<t t-name="web.MsgViewerField">
|
|
||||||
<t t-if="!props.readonly">
|
|
||||||
<div class="o_form_msg_controls d-flex gap-1">
|
|
||||||
<t t-if="props.value">
|
|
||||||
<FileUploader
|
|
||||||
acceptedFileExtensions="'.msg'"
|
|
||||||
onUploaded.bind="onFileUploaded"
|
|
||||||
>
|
|
||||||
<t t-set-slot="toggler">
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary fa fa-pencil o_select_file_button"
|
|
||||||
data-tooltip="Edit"
|
|
||||||
aria-label="Edit"
|
|
||||||
/>
|
|
||||||
</t>
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary fa fa-trash o_clear_file_button"
|
|
||||||
data-tooltip="Clear"
|
|
||||||
aria-label="Clear"
|
|
||||||
t-on-click="onFileRemove"
|
|
||||||
/>
|
|
||||||
</FileUploader>
|
|
||||||
</t>
|
|
||||||
<t t-else="">
|
|
||||||
<label class="o_select_file_button btn btn-primary">
|
|
||||||
<FileUploader
|
|
||||||
acceptedFileExtensions="'.msg'"
|
|
||||||
onUploaded.bind="onFileUploaded"
|
|
||||||
>
|
|
||||||
<t t-set-slot="toggler">
|
|
||||||
Upload your file
|
|
||||||
</t>
|
|
||||||
</FileUploader>
|
|
||||||
</label>
|
|
||||||
</t>
|
|
||||||
</div>
|
|
||||||
</t>
|
|
||||||
<t t-if="props.value">
|
|
||||||
<div class="o_field_msg_viewer_container o_field_widget o_field_binary o_field_binary_file cursor-pointer" t-on-click="openMsgViewer">
|
|
||||||
<i class="fa fa-envelope"/> <span t-esc="props.value.name"/>
|
|
||||||
</div>
|
|
||||||
</t>
|
|
||||||
<t t-if="state.showViewer">
|
|
||||||
<div class="o_field_msg_viewer_container">
|
|
||||||
<iframe t-att-src="url" class="o_field_msg_viewer" t-on-error="onLoadFailed"/>
|
|
||||||
</div>
|
|
||||||
</t>
|
|
||||||
</t>
|
|
||||||
</templates>
|
|
||||||
|
|
@ -6,10 +6,26 @@
|
||||||
<field name="model">ir.attachment</field>
|
<field name="model">ir.attachment</field>
|
||||||
<field name="inherit_id" ref="base.view_attachment_form"/>
|
<field name="inherit_id" ref="base.view_attachment_form"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//field[@name='url']" position="after">
|
<xpath expr="//field[@name='datas']" position="after">
|
||||||
<field name="is_msg_file" invisible="1"/>
|
<field name="is_msg_file" invisible="1"/>
|
||||||
<field name="datas" widget="msg_viewer" invisible="not is_msg_file"/>
|
<field name="datas" widget="msg_viewer"
|
||||||
|
invisible="not is_msg_file"
|
||||||
|
filename="name"/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//field[@name='datas']" position="attributes">
|
||||||
|
<attribute name="invisible">is_msg_file</attribute>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<record id="view_attachment_search_msg" model="ir.ui.view">
|
||||||
|
<field name="name">ir.attachment.search.msg</field>
|
||||||
|
<field name="model">ir.attachment</field>
|
||||||
|
<field name="inherit_id" ref="base.view_attachment_search"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<filter name="url_filter" position="after">
|
||||||
|
<filter string="MSG Files" name="msg_files" domain="[('is_msg_file', '=', True)]"/>
|
||||||
|
</filter>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue