bemade-addons/msg_attachments_to_mail_message/models/ir_attachment.py

469 lines
18 KiB
Python
Raw Normal View History

2025-01-21 22:10:31 -05:00
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
2025-01-22 11:29:29 -05:00
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
2025-01-21 22:10:31 -05:00
import lxml.html
import re
from odoo.tools import email_normalize, email_split
2025-01-28 13:47:09 -05:00
from io import BytesIO
2025-01-21 22:10:31 -05:00
_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")
2025-01-28 13:47:09 -05:00
handler.setFormatter(formatter)
_msg_import_logger.addHandler(handler)
_msg_import_logger.setLevel(logging.ERROR)
2025-01-21 22:10:31 -05:00
2025-01-21 22:10:31 -05:00
class IrAttachment(models.Model):
_inherit = "ir.attachment"
2025-01-21 22:10:31 -05:00
msg_processed = fields.Boolean(string="MSG Processed", default=False)
mail_message_id = fields.Many2one("mail.message", string="Created Mail Message")
2025-01-21 22:10:31 -05:00
def _is_msg_file(self):
2025-01-28 09:41:50 -05:00
"""
Check if the attachment is an MSG file and validate size.
Returns:
bool: True if the attachment is an MSG file, False otherwise.
"""
2025-01-21 22:10:31 -05:00
self.ensure_one()
2025-01-21 22:10:31 -05:00
# Check if it's an MSG file
is_msg = self.mimetype == "application/vnd.ms-outlook" or (
self.name and self.name.lower().endswith(".msg")
2025-01-21 22:10:31 -05:00
)
2025-01-21 22:10:31 -05:00
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
2025-01-21 22:10:31 -05:00
if file_size_mb > max_size:
2025-01-28 13:47:09 -05:00
error_msg = f"File size error for {self.name}: File size ({file_size_mb:.2f} MB) exceeds maximum allowed size ({max_size} MB)"
_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,
)
2025-01-21 22:10:31 -05:00
return False
2025-01-21 22:10:31 -05:00
return True
def _clean_header_value(self, value):
2025-01-28 09:41:50 -05:00
"""
Clean header value by removing line breaks and extra whitespace.
Args:
value (str): Header value to clean.
Returns:
str: Cleaned header value.
"""
2025-01-21 22:10:31 -05:00
if not value:
return ""
2025-01-21 22:10:31 -05:00
# Replace any combination of whitespace (including newlines) with a single space
return " ".join(str(value).split())
2025-01-21 22:10:31 -05:00
2025-01-22 11:29:29 -05:00
def _msg_to_eml(self, msg_data):
2025-01-28 09:41:50 -05:00
"""
Convert MSG file data to EML format.
Args:
msg_data (bytes): MSG file data.
Returns:
str: EML file data.
2025-01-28 09:41:50 -05:00
"""
2025-01-21 22:10:31 -05:00
try:
# Read the MSG file
2025-01-28 13:47:09 -05:00
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,
)
2025-01-22 11:29:29 -05:00
# Create email message
email_msg = MIMEMultipart("related")
2025-01-28 09:41:50 -05:00
# 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 []
2025-01-28 09:41:50 -05:00
# Vérifier si les adresses existent dans les partenaires
partner_model = self.env["res.partner"]
from_partners = (
partner_model.search([("email", "=ilike", from_email)])
if from_email
else False
)
2025-01-28 09:41:50 -05:00
# Get current record's partner if exists
current_partner = False
if self.res_model and self.res_id:
record = self.env[self.res_model].browse(self.res_id)
if hasattr(record, "partner_id"):
2025-01-28 09:41:50 -05:00
current_partner = record.partner_id
if not from_partners:
if current_partner and from_email:
# Create new contact under the parent company
company = current_partner.commercial_partner_id
# Extract name from email if possible
display_name = msg_file.sender
if "<" in display_name and ">" in display_name:
display_name = display_name.split("<")[0].strip()
from_partner = partner_model.create(
{
"name": display_name,
"email": from_email,
"parent_id": company.id,
"company_id": (
company.company_id.id
if company.company_id
else self.env.company.id
),
"type": "contact",
}
)
_logger.info(
"Created new contact %s under company %s for email %s",
display_name,
company.name,
from_email,
)
2025-01-28 09:41:50 -05:00
else:
_logger.warning(
"Email sender not found in partners and no current partner to link to: %s",
from_email,
)
2025-01-28 13:47:09 -05:00
# Continue processing with the original sender
from_partner = None
2025-01-28 09:41:50 -05:00
elif len(from_partners) > 1:
if current_partner:
# Chercher parmi les contacts liés à la compagnie de l'objet courant
company = current_partner.commercial_partner_id
company_contacts = from_partners.filtered(
lambda p: p.commercial_partner_id == company
)
2025-01-28 09:41:50 -05:00
if company_contacts:
# Si on trouve des contacts liés à la compagnie, prendre le plus récent
from_partner = (
company_contacts.filtered("active").sorted(
"write_date", reverse=True
)[0]
if company_contacts.filtered("active")
else company_contacts[0]
)
_logger.info(
"Plusieurs partenaires trouvés pour l'email %s - Utilisation du contact %s lié à la compagnie %s",
from_email,
from_partner.name,
company.name,
)
2025-01-28 09:41:50 -05:00
else:
# Si aucun contact lié à la compagnie n'est trouvé, utiliser la logique précédente
company_partner = from_partners.filtered("is_company")
2025-01-28 09:41:50 -05:00
if len(company_partner) == 1:
from_partner = company_partner
else:
from_partner = (
from_partners.filtered("active").sorted(
"write_date", reverse=True
)[0]
if from_partners.filtered("active")
else from_partners[0]
)
_logger.info(
"Plusieurs partenaires trouvés pour l'email %s - Utilisation de %s (aucun contact lié à la compagnie actuelle)",
from_email,
from_partner.name,
)
2025-01-28 09:41:50 -05:00
else:
# Si pas de partenaire courant, utiliser la logique précédente
company_partner = from_partners.filtered("is_company")
2025-01-28 09:41:50 -05:00
if len(company_partner) == 1:
from_partner = company_partner
else:
from_partner = (
from_partners.filtered("active").sorted(
"write_date", reverse=True
)[0]
if from_partners.filtered("active")
else from_partners[0]
)
_logger.info(
"Plusieurs partenaires trouvés pour l'email %s - Utilisation de %s",
from_email,
from_partner.name,
)
2025-01-28 09:41:50 -05:00
else:
from_partner = from_partners
2025-01-22 10:11:03 -05:00
# Add headers
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) if msg_file.cc else None
)
email_msg["Date"] = (
msg_file.date.strftime("%a, %d %b %Y %H:%M:%S %z")
if msg_file.date
else formatdate(localtime=True)
)
2025-01-22 10:11:03 -05:00
# Add original MSG headers if present
for header in ["Message-ID", "In-Reply-To", "References"]:
2025-01-22 10:11:03 -05:00
if header.lower() in msg_file.header:
header_value = self._clean_header_value(
msg_file.header[header.lower()]
)
2025-01-22 11:29:29 -05:00
if header_value: # Only add header if it has a value
email_msg[header] = header_value
2025-01-21 22:10:31 -05:00
# Set the body
if msg_file.body:
body = msg_file.body
2025-01-28 13:47:09 -05:00
# Check if the body is HTML or contains HTML-like content
is_html = "<html" in body.lower() or any(
tag in body.lower() for tag in ["<div", "<p", "<br", "<table", "<a"]
)
2025-01-28 13:47:09 -05:00
if not is_html:
# Convert plain text to HTML
body = body.replace("\n", "<br>")
# Convert URLs to links
body = re.sub(
r'(https?://[^\s<>"]+|www\.[^\s<>"]+)',
r'<a href="\1">\1</a>',
body,
)
2025-01-28 13:47:09 -05:00
# Convert CID references to img tags
if "[cid:" in body:
body = re.sub(
r"\[cid:([^\]]+)\]",
lambda m: f'<img src="cid:{m.group(1)}" alt="{m.group(1)}">',
body,
)
# Wrap in HTML tags
body = f"<html><body>{body}</body></html>"
2025-01-28 13:47:09 -05:00
# Always sanitize HTML to ensure proper formatting
body = html_sanitize(
body,
sanitize_tags=False,
sanitize_attributes=False,
sanitize_style=False,
)
html_part = MIMEText(body, "html")
2025-01-22 11:29:29 -05:00
email_msg.attach(html_part)
2025-01-21 22:10:31 -05:00
else:
html_part = MIMEText("", "html")
2025-01-22 11:29:29 -05:00
email_msg.attach(html_part)
2025-01-21 22:10:31 -05:00
# Add attachments
2025-01-22 10:11:03 -05:00
for attachment in msg_file.attachments:
if not hasattr(attachment, "data") or not attachment.data:
2025-01-21 22:10:31 -05:00
continue
2025-01-22 10:11:03 -05:00
try:
filename = attachment.getFilename()
except AttributeError:
filename = "unknown.bin"
_logger.warning(
"Could not get filename for attachment, using default: %s",
filename,
)
if hasattr(attachment, "contentId") and attachment.contentId:
2025-01-22 11:29:29 -05:00
# Image avec CID - utiliser MIMEImage
maintype, subtype = (attachment.mimetype or "image/jpeg").split(
"/", 1
)
if maintype == "image":
2025-01-22 11:29:29 -05:00
mime_part = MIMEImage(attachment.data, _subtype=subtype)
mime_part.add_header(
"Content-ID", f"<{attachment.contentId.strip('<>')}>"
)
mime_part.add_header(
"Content-Disposition", "inline", filename=filename
)
_logger.debug(
"Adding inline image with CID: %s, filename: %s",
attachment.contentId,
filename,
)
2025-01-22 11:29:29 -05:00
else:
# Fallback pour les non-images avec CID
mime_part = MIMEBase(maintype, subtype)
mime_part.set_payload(attachment.data)
encoders.encode_base64(mime_part)
mime_part.add_header(
"Content-ID", f"<{attachment.contentId.strip('<>')}>"
)
mime_part.add_header(
"Content-Disposition", "inline", filename=filename
)
2025-01-22 11:29:29 -05:00
else:
# Pièce jointe normale
maintype, subtype = (
attachment.mimetype or "application/octet-stream"
).split("/", 1)
2025-01-22 11:29:29 -05:00
mime_part = MIMEBase(maintype, subtype)
mime_part.set_payload(attachment.data)
encoders.encode_base64(mime_part)
mime_part.add_header(
"Content-Disposition", "attachment", filename=filename
)
2025-01-22 11:29:29 -05:00
email_msg.attach(mime_part)
2025-01-22 11:29:29 -05:00
return email_msg.as_string()
2025-01-22 11:29:29 -05:00
except Exception as e:
_msg_import_logger.error(
"Error converting MSG to EML: %s", str(e), exc_info=True
)
2025-01-22 11:29:29 -05:00
raise
2025-01-22 11:29:29 -05:00
def process_msg_as_email(self):
2025-01-28 09:41:50 -05:00
"""
Convert MSG file to EML and process it using Odoo's mail module.
2025-01-28 09:41:50 -05:00
Returns:
bool: True if successful, False otherwise
"""
2025-01-22 11:29:29 -05:00
self.ensure_one()
_logger.debug(
"Starting MSG processing for file: %s (model: %s, res_id: %s)",
self.name,
self.res_model,
self.res_id,
)
2025-01-22 11:29:29 -05:00
if not self._is_msg_file():
2025-01-28 13:47:09 -05:00
error_msg = f"Invalid MSG file: {self.name} (model: {self.res_model}, res_id: {self.res_id})"
_msg_import_logger.error(error_msg)
self._notify_error(
"Invalid File", _("The selected file is not a valid MSG file")
)
2025-01-22 11:29:29 -05:00
return False
try:
# Convert MSG to EML
msg_data = base64.b64decode(self.datas)
eml_content = self._msg_to_eml(msg_data)
2025-01-21 22:10:31 -05:00
# Process using Odoo's standard mail handling
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)
2025-01-22 10:11:03 -05:00
# Set context for message creation
2025-01-21 22:10:31 -05:00
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,
2025-01-21 22:10:31 -05:00
}
2025-01-22 11:29:29 -05:00
# Process the EML using Odoo's standard message_process
2025-01-22 10:11:03 -05:00
thread_id = thread_model.with_context(**context).message_process(
model=self.res_model,
2025-01-22 11:29:29 -05:00
message=eml_content,
2025-01-22 10:11:03 -05:00
custom_values={
"res_id": self.res_id,
"model": self.res_model,
"msg_attachment_id": self.id,
2025-01-22 10:11:03 -05:00
},
thread_id=self.res_id,
2025-01-22 10:11:03 -05:00
)
2025-01-22 10:11:03 -05:00
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,
}
)
2025-01-21 22:10:31 -05:00
return thread_id
2025-01-22 10:11:03 -05:00
else:
2025-01-28 13:47:09 -05:00
error_msg = f"Failed to process MSG file: {self.name} - message_process failed to create thread_id"
_msg_import_logger.error(error_msg)
2025-01-22 10:11:03 -05:00
return False
2025-01-21 22:10:31 -05:00
except Exception as e:
2025-01-28 13:47:09 -05:00
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)
)
2025-01-21 22:10:31 -05:00
return False
def _notify_error(self, title, message):
2025-01-28 09:41:50 -05:00
"""
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,
},
)
2025-01-21 22:10:31 -05:00
@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