diff --git a/msg_attachments_to_mail_message/models/ir_attachment.py b/msg_attachments_to_mail_message/models/ir_attachment.py
index 1dad193..4f8fd41 100644
--- a/msg_attachments_to_mail_message/models/ir_attachment.py
+++ b/msg_attachments_to_mail_message/models/ir_attachment.py
@@ -19,6 +19,10 @@ 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__)
@@ -104,6 +108,35 @@ class IrAttachment(models.Model):
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(
@@ -121,300 +154,148 @@ class IrAttachment(models.Model):
to_emails = email_split(msg_file.to) if msg_file.to else []
cc_emails = email_split(msg_file.cc) if msg_file.cc else []
- # 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
- )
-
# 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"):
- current_partner = record.partner_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))
- 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,
- )
- else:
- _logger.warning(
- "Email sender not found in partners and no current partner to link to: %s",
- from_email,
- )
- # Continue processing with the original sender
- from_partner = None
- elif len(from_partners) > 1:
- if 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:
- # 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,
- )
- else:
- # Si aucun contact lié à la compagnie n'est trouvé, utiliser la logique précédente
- company_partner = from_partners.filtered("is_company")
- 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]
+ # 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
)
- _logger.info(
- "Plusieurs partenaires trouvés pour l'email %s - Utilisation de %s (aucun contact lié à la compagnie actuelle)",
- from_email,
- from_partner.name,
- )
- else:
- # Si pas de partenaire courant, utiliser la logique précédente
- company_partner = from_partners.filtered("is_company")
- 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,
- )
- else:
- from_partner = from_partners
+ if company_contacts:
+ from_partner = company_contacts[0]
+ except Exception as e:
+ _logger.warning("Failed to process sender partner: %s", str(e))
- # Add headers
- email_msg["Subject"] = (
- self._clean_header_value(msg_file.subject) or "No Subject"
- )
+ # 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)
- 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)
- )
+ if msg_file.cc:
+ email_msg["Cc"] = self._clean_header_value(msg_file.cc)
- # Add original MSG headers if present
- for header in ["Message-ID", "In-Reply-To", "References"]:
- if header.lower() in msg_file.header:
- header_value = self._clean_header_value(
- msg_file.header[header.lower()]
- )
- if header_value: # Only add header if it has a value
- email_msg[header] = header_value
-
- # Set the body
+ # Add email body
if msg_file.body:
- body = msg_file.body
-
- # Check if metadata should be included
- show_metadata = (
- self.env["ir.config_parameter"]
- .sudo()
- .get_param("msg_attachments.format_metadata", "True")
- .lower()
- == "true"
- )
-
- if show_metadata:
- # Format the metadata section
- metadata_html = f"""
-
-
Original Email Details:
-
-
- | From: |
- {html_sanitize(msg_file.sender or "")} |
-
-
- | Date: |
- {msg_file.date.strftime("%Y-%m-%d %H:%M:%S") if msg_file.date else "Unknown"} |
-
-
- | To: |
- {html_sanitize(msg_file.to or "")} |
-
- """
-
- if msg_file.cc:
- metadata_html += f"""
-
- | CC: |
- {html_sanitize(msg_file.cc)} |
-
- """
-
- if "message-id" in msg_file.header:
- metadata_html += f"""
-
- | Message-ID: |
- {html_sanitize(msg_file.header["message-id"])} |
-
- """
-
- metadata_html += """
-
-
- """
-
- # Check if the body is HTML or contains HTML-like content
- is_html = "")
- # Convert URLs to links
- body = re.sub(
- r'(https?://[^\s<>"]+|www\.[^\s<>"]+)',
- r'\1',
- body,
- )
- # Convert CID references to img tags
- if "[cid:" in body:
- body = re.sub(
- r"\[cid:([^\]]+)\]",
- lambda m: f'
',
- body,
- )
- # Wrap in HTML tags
- body = f'{body}
'
-
- if show_metadata:
- # Add metadata at the top
- body = metadata_html + body
-
- # Always sanitize HTML
- body = html_sanitize(
- body, sanitize_tags=False, sanitize_attributes=False, sanitize_style=False
- )
-
- html_part = MIMEText(body, "html")
- email_msg.attach(html_part)
- else:
- html_part = MIMEText("", "html")
- email_msg.attach(html_part)
-
- # Add attachments
- for attachment in msg_file.attachments:
- if not hasattr(attachment, "data") or not attachment.data:
- continue
+ body = html_sanitize(msg_file.body)
+ email_msg.attach(MIMEText(body, "html"))
+ # Process attachments
+ for att in msg_file.attachments:
try:
- filename = attachment.getFilename()
- except AttributeError:
- filename = "unknown.bin"
+ 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(
- "Could not get filename for attachment, using default: %s",
+ "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"
)
-
- if hasattr(attachment, "contentId") and attachment.contentId:
- # Image avec CID - utiliser MIMEImage
- maintype, subtype = (attachment.mimetype or "image/jpeg").split(
- "/", 1
- )
- if maintype == "image":
- 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,
- )
- 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
- )
- else:
- # Pièce jointe normale
- maintype, subtype = (
- attachment.mimetype or "application/octet-stream"
- ).split("/", 1)
- 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
- )
-
- email_msg.attach(mime_part)
+ # Move problematic attachment to review directory
+ self._move_to_review_dir('msg_conversion')
return email_msg.as_string()
except Exception as e:
- _msg_import_logger.error(
- "Error converting MSG to EML: %s", str(e), exc_info=True
- )
+ _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.
@@ -422,31 +303,40 @@ class IrAttachment(models.Model):
Returns:
bool: True if successful, False otherwise
"""
- self.ensure_one()
- _logger.debug(
- "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():
- 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"Invalid MSG file: {self.name} (model: {self.res_model}, res_id: {self.res_id})"
- if log_errors and log_file:
- _msg_import_logger.error(error_msg)
- self._notify_error(
- "Invalid File", _("The selected file is not a valid MSG file")
- )
- return False
-
try:
- # Convert MSG to EML
- msg_data = base64.b64decode(self.datas)
- eml_content = self._msg_to_eml(msg_data)
+ self.ensure_one()
- # Process using Odoo's standard mail handling
+ 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"]
)
@@ -461,45 +351,78 @@ class IrAttachment(models.Model):
"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
}
- # 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,
- )
+ 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
- else:
- 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')
+ 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
+
+ return False
except Exception as e:
- 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"Error processing MSG file: {self.name} - {str(e)}"
- if log_errors and log_file:
+ 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)
)
@@ -529,8 +452,72 @@ class IrAttachment(models.Model):
@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:
- if attachment._is_msg_file():
- attachment.process_msg_as_email()
+ 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