bemade-addons/msg_attachments_to_mail_message/models/ir_attachment.py
2025-01-31 09:05:56 -05:00

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