523 lines
21 KiB
Python
523 lines
21 KiB
Python
"""This module extends ir.attachment model to handle MSG file attachments.
|
|
|
|
It provides functionality to convert MSG files (Outlook messages) to EML format
|
|
and process them as regular email messages in Odoo. This includes extracting
|
|
attachments, handling headers, and creating proper mail.message records.
|
|
"""
|
|
|
|
from odoo import models, api, fields
|
|
from odoo.tools import html_sanitize
|
|
import base64
|
|
import logging
|
|
from odoo.tools.translate import _
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
from email.mime.image import MIMEImage
|
|
from email.mime.base import MIMEBase
|
|
from email import encoders
|
|
import re
|
|
from odoo.tools import email_normalize, email_split
|
|
from io import BytesIO
|
|
import extract_msg
|
|
import psycopg2
|
|
import os
|
|
import shutil
|
|
from odoo.tools import config
|
|
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_msg_import_logger = logging.getLogger("msg.import")
|
|
handler = logging.FileHandler("/var/log/odoo/msg_import.log")
|
|
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
handler.setFormatter(formatter)
|
|
_msg_import_logger.addHandler(handler)
|
|
_msg_import_logger.setLevel(logging.ERROR)
|
|
|
|
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.
|
|
|
|
Returns:
|
|
bool: True if the attachment is an MSG file, False otherwise.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
# Check if it's an MSG file
|
|
is_msg = self.with_context().sudo().mimetype in ['application/vnd.ms-outlook', 'application/x-ole-storage'] 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") or 25
|
|
)
|
|
file_size_mb = (
|
|
len(self.datas) * 3 / 4 / 1024 / 1024
|
|
) # Convert from base64 to MB
|
|
|
|
if file_size_mb > max_size:
|
|
log_errors = self.env['ir.config_parameter'].sudo().get_param('msg_attachments.log_msg_error', 'False').lower() == 'true'
|
|
log_file = self.env['ir.config_parameter'].sudo().get_param('msg_attachments.log_file_path')
|
|
error_msg = f"File size error for {self.name}: File size ({file_size_mb:.2f} MB) exceeds maximum allowed size ({max_size} MB)"
|
|
if log_errors and log_file:
|
|
_msg_import_logger.error(error_msg)
|
|
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.
|
|
|
|
Args:
|
|
value (str): Header value to clean.
|
|
|
|
Returns:
|
|
str: Cleaned header value.
|
|
"""
|
|
if not value:
|
|
return ""
|
|
# Replace any combination of whitespace (including newlines) with a single space
|
|
return " ".join(str(value).split())
|
|
|
|
def _msg_to_eml(self, msg_data):
|
|
"""
|
|
Convert MSG file data to EML format.
|
|
|
|
Args:
|
|
msg_data (bytes): MSG file data.
|
|
|
|
Returns:
|
|
str: EML file data.
|
|
"""
|
|
try:
|
|
# Debug logging avant conversion
|
|
_logger.info("=== DEBUG INFO AVANT CONVERSION [%s] ===", self.name)
|
|
_logger.info("[%s] Type de msg_data: %s", self.name, type(msg_data))
|
|
_logger.info("[%s] Attributs disponibles: %s", self.name,
|
|
dir(msg_data) if hasattr(msg_data, '__dict__') else 'No attributes')
|
|
|
|
# Ensure we have bytes
|
|
if not isinstance(msg_data, bytes):
|
|
_logger.info("[%s] Conversion nécessaire", self.name)
|
|
if hasattr(msg_data, 'read'):
|
|
_logger.info("[%s] Conversion via read()", self.name)
|
|
msg_data = msg_data.read()
|
|
elif isinstance(msg_data, str):
|
|
_logger.info("[%s] Conversion string vers bytes", self.name)
|
|
msg_data = msg_data.encode('utf-8')
|
|
elif hasattr(msg_data, 'datas'):
|
|
_logger.info("[%s] Utilisation de l'attribut datas", self.name)
|
|
msg_data = base64.b64decode(msg_data.datas)
|
|
else:
|
|
error_msg = f"Expected bytes-like object, got {type(msg_data)}"
|
|
_logger.error("[%s] %s", self.name, error_msg)
|
|
self._move_to_review_dir('msg_conversion')
|
|
raise ValueError(error_msg)
|
|
|
|
# Debug logging après conversion
|
|
_logger.info("=== DEBUG INFO APRÈS CONVERSION [%s] ===", self.name)
|
|
_logger.info("[%s] Type final de msg_data: %s", self.name, type(msg_data))
|
|
_logger.info("[%s] Taille des données: %d bytes", self.name, len(msg_data))
|
|
|
|
# Read the MSG file
|
|
msg_file = extract_msg.Message(BytesIO(msg_data))
|
|
_logger.debug(
|
|
"Converting MSG to EML - Subject: %s, From: %s, To: %s",
|
|
msg_file.subject,
|
|
msg_file.sender,
|
|
msg_file.to,
|
|
)
|
|
|
|
# Create email message
|
|
email_msg = MIMEMultipart("related")
|
|
|
|
# Clean and normalize email addresses
|
|
from_email = email_normalize(msg_file.sender) if msg_file.sender else False
|
|
to_emails = email_split(msg_file.to) if msg_file.to else []
|
|
cc_emails = email_split(msg_file.cc) if msg_file.cc else []
|
|
|
|
# Get current record's partner if exists
|
|
current_partner = False
|
|
if self.res_model and self.res_id:
|
|
try:
|
|
record = self.env[self.res_model].browse(self.res_id)
|
|
if hasattr(record, "partner_id"):
|
|
current_partner = record.partner_id
|
|
except Exception as e:
|
|
_logger.warning("Failed to get current partner: %s", str(e))
|
|
|
|
# Recherche du partenaire expéditeur avec gestion d'erreur
|
|
from_partner = None
|
|
if from_email:
|
|
try:
|
|
with self.env.cr.savepoint():
|
|
from_partners = self.env["res.partner"].search([
|
|
("email", "=ilike", from_email),
|
|
("active", "in", [True, False])
|
|
])
|
|
|
|
if len(from_partners) == 1:
|
|
from_partner = from_partners[0]
|
|
elif len(from_partners) > 1 and current_partner:
|
|
# Search among contacts linked to the current object's company
|
|
company = current_partner.commercial_partner_id
|
|
company_contacts = from_partners.filtered(
|
|
lambda p: p.commercial_partner_id == company
|
|
)
|
|
if company_contacts:
|
|
from_partner = company_contacts[0]
|
|
except Exception as e:
|
|
_logger.warning("Failed to process sender partner: %s", str(e))
|
|
|
|
# Set email headers
|
|
email_msg["Subject"] = self._clean_header_value(msg_file.subject)
|
|
email_msg["From"] = self._clean_header_value(msg_file.sender)
|
|
email_msg["To"] = self._clean_header_value(msg_file.to)
|
|
if msg_file.cc:
|
|
email_msg["Cc"] = self._clean_header_value(msg_file.cc)
|
|
|
|
# Add email body
|
|
if msg_file.body:
|
|
body = html_sanitize(msg_file.body)
|
|
email_msg.attach(MIMEText(body, "html"))
|
|
|
|
# Process attachments
|
|
for att in msg_file.attachments:
|
|
try:
|
|
filename = att.longFilename or att.shortFilename
|
|
if filename:
|
|
part = MIMEBase("application", "octet-stream")
|
|
part.set_payload(att.data)
|
|
encoders.encode_base64(part)
|
|
part.add_header(
|
|
"Content-Disposition",
|
|
f"attachment; filename={filename}",
|
|
)
|
|
email_msg.attach(part)
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"Failed to process attachment %s: %s - Attachment data type: %s, Content: %s",
|
|
filename,
|
|
str(e),
|
|
type(att.data),
|
|
str(att.data)[:100] if att.data else "None"
|
|
)
|
|
# Move problematic attachment to review directory
|
|
self._move_to_review_dir('msg_conversion')
|
|
|
|
return email_msg.as_string()
|
|
|
|
except Exception as e:
|
|
_logger.error("Error converting MSG to EML: %s", str(e))
|
|
raise
|
|
|
|
def _get_review_dir(self, error_type):
|
|
"""
|
|
Get the review directory path.
|
|
|
|
Args:
|
|
error_type (str): Type of error encountered
|
|
|
|
Returns:
|
|
str: Path to the review directory
|
|
"""
|
|
# Get Odoo's data directory
|
|
data_dir = config.get('data_dir', os.path.dirname(self._full_path(self.store_fname)))
|
|
|
|
# Create a specific directory for review files
|
|
review_base = os.path.join(data_dir, 'review_files')
|
|
|
|
# Create subdirectory for specific error type
|
|
review_dir = os.path.join(review_base, error_type)
|
|
|
|
return review_dir
|
|
|
|
def _move_to_review_dir(self, error_type):
|
|
"""
|
|
Move problematic files to a review directory.
|
|
|
|
Args:
|
|
error_type (str): Type of error encountered (e.g., 'pdf_page_count', 'msg_conversion')
|
|
"""
|
|
self.ensure_one()
|
|
try:
|
|
if not self.store_fname:
|
|
_logger.warning("[%s] No stored file to move", self.name or 'Unknown')
|
|
return
|
|
|
|
# Get review directory
|
|
review_dir = self._get_review_dir(error_type)
|
|
os.makedirs(review_dir, exist_ok=True)
|
|
|
|
# Move file to review directory
|
|
src_path = self._full_path(self.store_fname)
|
|
if os.path.exists(src_path):
|
|
# Create a subdirectory for the current date
|
|
date_dir = os.path.join(review_dir, fields.Date.today().strftime('%Y%m%d'))
|
|
os.makedirs(date_dir, exist_ok=True)
|
|
|
|
# Add timestamp to filename to avoid conflicts
|
|
timestamp = fields.Datetime.now().strftime('%H%M%S')
|
|
filename = f"{timestamp}_{self.id}_{self.name}"
|
|
dst_path = os.path.join(date_dir, filename)
|
|
|
|
# Copy file instead of moving to preserve original
|
|
shutil.copy2(src_path, dst_path)
|
|
|
|
# Log the action
|
|
_logger.info(
|
|
"File %s moved to review directory due to %s error. New location: %s",
|
|
self.name or 'Unknown', error_type, dst_path
|
|
)
|
|
|
|
# Update attachment record
|
|
self.write({
|
|
'description': f"{self.description or ''}\nMoved to review directory due to {error_type} error on {fields.Datetime.now()}"
|
|
})
|
|
|
|
except Exception as e:
|
|
_logger.error("[%s] Failed to move file to review directory: %s", self.name or 'Unknown', str(e))
|
|
|
|
def process_msg_as_email(self):
|
|
"""
|
|
Convert MSG file to EML and process it using Odoo's mail module.
|
|
|
|
Returns:
|
|
bool: True if successful, False otherwise
|
|
"""
|
|
try:
|
|
self.ensure_one()
|
|
|
|
if not self._is_msg_file():
|
|
_logger.warning("File is not a valid MSG file: %s", self.name)
|
|
return False
|
|
|
|
if not self.datas:
|
|
_logger.warning("No data found in MSG file: %s", self.name)
|
|
return False
|
|
|
|
if self.msg_processed:
|
|
_logger.debug("MSG file already processed: %s", self.name)
|
|
return True
|
|
|
|
try:
|
|
# Convert base64 to bytes with validation
|
|
msg_data = base64.b64decode(self.datas)
|
|
if not isinstance(msg_data, bytes):
|
|
raise ValueError(f"Expected bytes after b64decode, got {type(msg_data)}")
|
|
|
|
eml_content = self._msg_to_eml(msg_data)
|
|
if not isinstance(eml_content, str):
|
|
raise ValueError(f"Expected string EML content, got {type(eml_content)}")
|
|
|
|
except (ValueError, TypeError) as e:
|
|
_logger.error("Data conversion error for %s: %s", self.name, str(e))
|
|
return False
|
|
|
|
if not eml_content:
|
|
_logger.error("Failed to convert MSG to EML: %s", self.name)
|
|
return False
|
|
|
|
# Get thread model based on res_model
|
|
thread_model = (
|
|
self.env[self.res_model] if self.res_model else self.env["mail.thread"]
|
|
)
|
|
_logger.debug("Using thread model: %s", thread_model._name)
|
|
|
|
# Set context for message creation
|
|
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,
|
|
"mail_create_skip_followers": True,
|
|
"mail_auto_subscribe_no_notify": True, # Prevent auto-subscription notifications
|
|
"no_auto_thread": True # Prevent automatic thread creation
|
|
}
|
|
|
|
try:
|
|
with self.env.cr.savepoint(): # Use savepoint for transaction management
|
|
# Process the EML using Odoo's standard message_process
|
|
thread_id = thread_model.with_context(**context).message_process(
|
|
model=self.res_model,
|
|
message=eml_content,
|
|
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.debug(
|
|
"Message processed successfully, thread_id: %s", thread_id
|
|
)
|
|
self.write(
|
|
{
|
|
"description": _("Processed as email on %s")
|
|
% fields.Datetime.now(),
|
|
"msg_processed": True,
|
|
}
|
|
)
|
|
return thread_id
|
|
|
|
except psycopg2.IntegrityError as e:
|
|
if "mail_followers_mail_followers_res_partner_res_model_id_uniq" in str(e):
|
|
_logger.warning("Follower already exists, skipping: %s", str(e))
|
|
return thread_id if 'thread_id' in locals() else False
|
|
raise
|
|
|
|
except Exception as e:
|
|
_logger.error("Error processing MSG file %s: %s", self.name, str(e), exc_info=True)
|
|
if not self.env.context.get('skip_msg_processing_on_error'):
|
|
raise
|
|
|
|
# Get error logging preference safely
|
|
try:
|
|
with self.env.cr.savepoint():
|
|
log_errors = self.env['ir.config_parameter'].sudo().get_param(
|
|
'msg_attachments.log_msg_error', 'False'
|
|
).lower() == 'true'
|
|
except Exception:
|
|
log_errors = False # Default to False if parameter can't be read
|
|
|
|
# Si thread_id est False, on log l'erreur si configuré
|
|
if log_errors:
|
|
error_msg = f"Failed to process MSG file: {self.name} - message_process failed to create thread_id"
|
|
_msg_import_logger.error(error_msg, exc_info=True)
|
|
|
|
return False
|
|
|
|
except Exception as e:
|
|
if not self.env.context.get('skip_msg_processing_on_error'):
|
|
raise
|
|
|
|
# Log l'erreur si configuré
|
|
log_errors = self.env['ir.config_parameter'].sudo().get_param(
|
|
'msg_attachments.log_msg_error', 'False'
|
|
).lower() == 'true'
|
|
|
|
if log_errors:
|
|
error_msg = f"Error processing MSG file: {self.name} - {str(e)}"
|
|
_msg_import_logger.error(error_msg, 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.
|
|
|
|
Args:
|
|
title (str): Title of the notification.
|
|
message (str): Message to display in the notification.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
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):
|
|
"""Override create to handle problematic files."""
|
|
if not isinstance(vals_list, list):
|
|
vals_list = [vals_list]
|
|
|
|
attachments = super().create(vals_list)
|
|
|
|
for attachment in attachments:
|
|
try:
|
|
# Check if it's a PDF file
|
|
if attachment.mimetype == 'application/pdf' and attachment.store_fname:
|
|
# Try to open and read the PDF
|
|
try:
|
|
import PyPDF2
|
|
with open(attachment._full_path(attachment.store_fname), 'rb') as pdf_file:
|
|
try:
|
|
pdf_reader = PyPDF2.PdfReader(pdf_file)
|
|
num_pages = len(pdf_reader.pages)
|
|
_logger.info("[%s] PDF processed successfully, pages: %d", attachment.name or 'Unknown', num_pages)
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"[%s] Failed to process PDF: %s",
|
|
attachment.name or 'Unknown', str(e)
|
|
)
|
|
attachment._move_to_review_dir('pdf_error')
|
|
except Exception as e:
|
|
_logger.error("[%s] Error accessing PDF file: %s", attachment.name or 'Unknown', str(e))
|
|
|
|
# Process MSG files
|
|
if attachment._is_msg_file():
|
|
attachment.process_msg_as_email()
|
|
|
|
except Exception as e:
|
|
_logger.error("[%s] Error processing attachment: %s", attachment.name or 'Unknown', str(e))
|
|
|
|
return attachments
|
|
|
|
def write(self, vals):
|
|
"""Override write to handle problematic files on update."""
|
|
res = super().write(vals)
|
|
|
|
if 'datas' in vals:
|
|
for attachment in self:
|
|
try:
|
|
# Check if it's a PDF file
|
|
if attachment.mimetype == 'application/pdf' and attachment.store_fname:
|
|
try:
|
|
import PyPDF2
|
|
with open(attachment._full_path(attachment.store_fname), 'rb') as pdf_file:
|
|
try:
|
|
pdf_reader = PyPDF2.PdfReader(pdf_file)
|
|
num_pages = len(pdf_reader.pages)
|
|
_logger.info("[%s] PDF processed successfully, pages: %d", attachment.name or 'Unknown', num_pages)
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"[%s] Failed to process PDF: %s",
|
|
attachment.name or 'Unknown', str(e)
|
|
)
|
|
attachment._move_to_review_dir('pdf_error')
|
|
except Exception as e:
|
|
_logger.error("[%s] Error accessing PDF file: %s", attachment.name or 'Unknown', str(e))
|
|
|
|
# Process MSG files
|
|
if attachment._is_msg_file():
|
|
attachment.process_msg_as_email()
|
|
|
|
except Exception as e:
|
|
_logger.error("[%s] Error processing attachment: %s", attachment.name or 'Unknown', str(e))
|
|
|
|
return res
|