"""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, config
from odoo.tools.translate import _
from odoo.tools import email_normalize, email_split
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
from email.utils import make_msgid, formatdate
from email.header import Header
import base64
import logging
import datetime
import time
import os
import shutil
import mimetypes
import psycopg2
import extract_msg
from io import BytesIO
_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"
# Cache au niveau de la classe
_msg_conversion_cache = {}
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 _detect_encoding(self, data):
"""
Detect the encoding of the data.
Args:
data (bytes): Data to analyze
Returns:
str: Detected encoding (e.g., 'utf-8', 'iso-8859-1', etc.)
"""
try:
import chardet
result = chardet.detect(data)
encoding = result['encoding'] if result and result['encoding'] else 'utf-8'
_logger.debug("Detected encoding: %s with confidence: %s",
encoding, result.get('confidence', 0))
return encoding
except ImportError:
_logger.info("chardet not installed, defaulting to utf-8")
return 'utf-8'
except Exception as e:
_logger.warning("Error detecting encoding: %s", str(e))
return 'utf-8'
def _retry_operation(self, operation, max_retries=3, delay=1):
"""
Retry an operation with exponential backoff.
Args:
operation (callable): Function to retry
max_retries (int): Maximum number of retry attempts
delay (int): Initial delay between retries in seconds
Returns:
Any: Result of the operation
"""
last_error = None
for attempt in range(max_retries):
try:
return operation()
except Exception as e:
last_error = e
if attempt < max_retries - 1:
sleep_time = delay * (2 ** attempt) # Exponential backoff
_logger.warning(
"Operation failed (attempt %d/%d): %s. Retrying in %d seconds...",
attempt + 1, max_retries, str(e), sleep_time
)
time.sleep(sleep_time)
else:
_logger.error(
"Operation failed after %d attempts: %s",
max_retries, str(e)
)
raise last_error
def _get_cache_key(self, data):
"""
Generate a cache key for the data.
Args:
data (bytes): Data to generate key for
Returns:
str: Cache key
"""
import hashlib
return hashlib.sha256(data).hexdigest()
@api.model
def _get_conversion_cache(self):
"""
Get the conversion cache.
Returns:
dict: Conversion cache
"""
return self._msg_conversion_cache
def _cached_msg_to_eml(self, msg_data):
"""
Cached version of MSG to EML conversion.
Args:
msg_data (bytes): MSG file data
Returns:
str: EML file data
"""
cache = self._get_conversion_cache()
cache_key = self._get_cache_key(msg_data)
if cache_key in cache:
_logger.debug("Using cached conversion result")
return cache[cache_key]
result = self._msg_to_eml(msg_data)
cache[cache_key] = result
return result
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,
)
# Clean and normalize email addresses for partner matching
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))
# Find the sender partner with error handling
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))
# Create email message
email_msg = MIMEMultipart('mixed')
# Set basic headers with proper encoding
email_msg['Subject'] = self._encode_header_content(msg_file.subject)
email_msg['From'] = self._encode_header_content(msg_file.sender)
email_msg['To'] = self._encode_header_content(msg_file.to)
# Format the date properly for email header
if msg_file.date:
if isinstance(msg_file.date, datetime.datetime):
timestamp = msg_file.date.timestamp()
else:
timestamp = time.time()
email_msg['Date'] = formatdate(timestamp, localtime=True)
else:
email_msg['Date'] = formatdate(time.time(), localtime=True)
# Generate a new Message-ID if not present
try:
message_id = msg_file.message_id
except AttributeError:
message_id = make_msgid()
email_msg['Message-ID'] = message_id
# Handle CC if present
if msg_file.cc:
email_msg['CC'] = self._encode_header_content(msg_file.cc)
# Create the message body
body_part = MIMEMultipart('alternative')
# Add HTML version if available
if msg_file.htmlBody:
_logger.info("Adding HTML body")
# Detect encoding and decode properly
html_content = msg_file.htmlBody
if isinstance(html_content, bytes):
encoding = self._detect_encoding(html_content)
try:
html_content = html_content.decode(encoding)
except UnicodeDecodeError:
html_content = html_content.decode('utf-8', errors='replace')
html_part = MIMEText(html_content, 'html', 'utf-8')
body_part.attach(html_part)
# Add RTF version if available and no HTML
elif msg_file.rtfBody:
_logger.info("Adding RTF body")
rtf_content = msg_file.rtfBody
if isinstance(rtf_content, bytes):
encoding = self._detect_encoding(rtf_content)
try:
rtf_content = rtf_content.decode(encoding)
except UnicodeDecodeError:
rtf_content = rtf_content.decode('utf-8', errors='replace')
rtf_part = MIMEText(rtf_content, 'rtf', 'utf-8')
body_part.attach(rtf_part)
# Fallback to plain text
if msg_file.body:
_logger.info("Adding plain text body")
text_content = msg_file.body
if isinstance(text_content, bytes):
encoding = self._detect_encoding(text_content)
try:
text_content = text_content.decode(encoding)
except UnicodeDecodeError:
text_content = text_content.decode('utf-8', errors='replace')
text_part = MIMEText(text_content, 'plain', 'utf-8')
body_part.attach(text_part)
email_msg.attach(body_part)
# Process attachments with special handling for inline images
for att in msg_file.attachments:
try:
filename = att.longFilename or att.shortFilename
if not filename:
_logger.info("Skipping attachment with no filename")
continue
_logger.info("=== Processing attachment: %s ===", filename)
# Check if it's an inline image
if hasattr(att, 'cid') and att.cid:
# This is an inline image
_logger.info("Processing inline image with CID: %s", att.cid)
try:
# Detect MIME type based on filename
mime_type, _ = mimetypes.guess_type(filename)
if mime_type and mime_type.startswith('image/'):
image_part = MIMEImage(att.data, _subtype=mime_type.split('/')[-1])
else:
# Fallback to application/octet-stream for unknown types
part = MIMEBase("application", "octet-stream")
part.set_payload(att.data)
encoders.encode_base64(part)
part.add_header('Content-ID', f'<{att.cid}>')
part.add_header('Content-Disposition', 'inline', filename=filename)
email_msg.attach(part)
continue
image_part.add_header('Content-ID', f'<{att.cid}>')
image_part.add_header('Content-Disposition', 'inline', filename=filename)
email_msg.attach(image_part)
continue
except Exception as e:
_logger.warning("Failed to process inline image: %s", str(e))
# Regular attachment processing
_logger.info("Processing as regular attachment: %s", 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)
_logger.info("Regular attachment processed successfully")
except Exception as e:
_logger.warning("Failed to process attachment %s: %s",
filename if 'filename' in locals() else 'Unknown',
str(e))
continue
# Prepare custom values for message processing
custom_values = {}
if from_partner:
custom_values['author_id'] = from_partner.id
# If this is related to a sale, add the sale thread
if self.res_model == 'sale.order' and self.res_id:
custom_values['model'] = 'sale.order'
custom_values['res_id'] = self.res_id
# Return the complete email message as string
try:
eml_content = email_msg.as_string()
# Determine the target model and thread_id
target_model = self.res_model or 'mail.thread'
target_thread_id = self.res_id if self.res_model else False
# Process the email with forced model
self.env['mail.thread'].with_context(
mail_create_nosubscribe=True, # Don't auto-subscribe the sender
mail_create_nolog=True, # Don't create log message
default_model=target_model, # Force the model
).message_process(
model=target_model,
message=eml_content,
custom_values=custom_values,
save_original=True,
strip_attachments=False,
thread_id=target_thread_id
)
# Return the EML content instead of True
return eml_content
except Exception as e:
_logger.error("Error converting email message to string: %s", str(e))
raise
except Exception as e:
_logger.error("Error converting MSG to EML: %s", str(e))
raise
def _encode_header_content(self, content):
"""
Encode header content properly to handle special characters.
Args:
content (str): Content to encode
Returns:
str: Encoded content
"""
if not content:
return ""
if isinstance(content, bytes):
encoding = self._detect_encoding(content)
try:
content = content.decode(encoding)
except UnicodeDecodeError:
content = content.decode('utf-8', errors='replace')
from email.header import Header
return str(Header(content, 'utf-8'))
def _ensure_html_content(self, content, is_html=False):
"""
Ensure content is in HTML format and properly formatted.
Args:
content (str): The content to format
is_html (bool): Whether the content is already HTML
Returns:
str: HTML formatted content
"""
if not content:
return ""
# If content is already HTML, just sanitize it
if is_html or bool(re.search(r'<[^>]+>', content)):
return html_sanitize(content)
# Convert plain text to HTML
content = content.replace('&', '&')
content = content.replace('<', '<')
content = content.replace('>', '>')
content = content.replace('\n', '
')
# Wrap in HTML tags
html_content = f"