From bfbc5d6491c94ad8218d3fdb2f42f4f6a51affaa Mon Sep 17 00:00:00 2001 From: Marc Durepos Date: Thu, 27 Mar 2025 16:34:09 -0400 Subject: [PATCH] further fixes and tests for email_to_pdf --- account_email_to_pdf/__manifest__.py | 2 +- account_email_to_pdf/models/account_move.py | 95 +++++---- account_email_to_pdf/tests/__init__.py | 1 - .../tests/test_email_integration.py | 88 -------- .../tests/test_email_to_pdf.py | 193 ++++++++++++++---- 5 files changed, 202 insertions(+), 177 deletions(-) delete mode 100644 account_email_to_pdf/tests/test_email_integration.py diff --git a/account_email_to_pdf/__manifest__.py b/account_email_to_pdf/__manifest__.py index 8b4e4f0..ea12b32 100644 --- a/account_email_to_pdf/__manifest__.py +++ b/account_email_to_pdf/__manifest__.py @@ -15,7 +15,7 @@ creation process to continue. """, "author": "Bemade", "website": "https://bemade.org", - "depends": ["account"], + "depends": ["account", "mail"], "data": [], "installable": True, "auto_install": False, diff --git a/account_email_to_pdf/models/account_move.py b/account_email_to_pdf/models/account_move.py index df99587..01028d0 100644 --- a/account_email_to_pdf/models/account_move.py +++ b/account_email_to_pdf/models/account_move.py @@ -5,8 +5,8 @@ import subprocess import tempfile from contextlib import closing from datetime import datetime -from email.utils import formatdate from html import escape +import re from odoo import models, fields from odoo.tools.misc import find_in_path @@ -17,36 +17,37 @@ _logger = logging.getLogger(__name__) class AccountMove(models.Model): _inherit = "account.move" - def _check_and_decode_attachment(self, attachments): - """Override to convert email message to PDF if no attachments are present.""" - if not attachments or self.env.context.get("no_new_invoice"): - # Original code would return False here, causing email rejection - # Instead, we'll create a PDF from the email message - message_dict = self.env.context.get("message_dict", {}) - if message_dict: - try: - # Create a PDF from the email content - pdf_attachment = self._create_pdf_from_email(message_dict) - if pdf_attachment: - _logger.info( - "Successfully created PDF attachment, proceeding with invoice creation" - ) - # We need to return the result of _extend_with_attachments directly - # as that's what the original method would return - # Convert the list to a recordset before passing to _extend_with_attachments - attachment_recordset = self.env["ir.attachment"].browse( - [pdf_attachment.id] - ) - return self._extend_with_attachments( - attachment_recordset, - new=bool(self._context.get("from_alias")), - ) - except Exception as e: - _logger.exception("Error creating PDF from email: %s", e) + def message_new(self, msg_dict, custom_values={}): + """Override to create a PDF attachment from the email content before processing. + This ensures that emails without attachments can still be processed and converted to invoices. + """ + # Get existing attachments in the email + attachments = msg_dict.get("attachments", []) - # Proceed with the original method - # If we were unable to generate a PDF, the original method will bounce the email - return super()._check_and_decode_attachment(attachments) + # Check if there are attachments that are not the .eml of the message itself + regex = re.compile(r"^.*\.eml$", re.IGNORECASE) + if len(attachments) > 1 or (len(attachments) == 1 and not regex.match(attachments[0][0])): + return super().message_new(msg_dict, custom_values=custom_values) + + try: + # Create a PDF from the email content + pdf_attachment = self._create_pdf_from_email(msg_dict) + if pdf_attachment: + # Add the PDF to the message's attachments + if not msg_dict.get("attachments"): + msg_dict["attachments"] = [] + + # Convert the attachment to the format expected by mail_thread + # Format: (name, base64_data, info_dict) + attachment_data = ( + pdf_attachment["name"], + pdf_attachment["datas"], + {"mimetype": "application/pdf"}, + ) + attachments.append(attachment_data) + except Exception as e: + _logger.exception("Error creating PDF from email: %s", e) + return super().message_new(msg_dict, custom_values=custom_values) @classmethod def _html_to_pdf(cls, html_content): @@ -58,6 +59,8 @@ class AccountMove(models.Model): Returns: bytes: PDF content as bytes or False if conversion failed """ + + # Check if wkhtmltopdf is installed wkhtmltopdf_bin = find_in_path("wkhtmltopdf") if not wkhtmltopdf_bin: _logger.error("Cannot find wkhtmltopdf executable in system path") @@ -90,23 +93,26 @@ class AccountMove(models.Model): command.append(html_file_path) command.append(pdf_file_path) - # Execute wkhtmltopdf process = subprocess.Popen( command, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) - out, err = process.communicate() + _, err = process.communicate() - if process.returncode not in [0, 1]: + if process.returncode != 0: _logger.error( "wkhtmltopdf failed with error code %s: %s", process.returncode, err ) return False # Read the generated PDF - with open(pdf_file_path, "rb") as pdf_file: - pdf_content = pdf_file.read() + try: + with open(pdf_file_path, "rb") as pdf_file: + pdf_content = pdf_file.read() - return pdf_content + return pdf_content + except Exception as e: + _logger.exception("Error reading generated PDF file: %s", e) + return False except Exception as e: _logger.exception("Error during PDF generation: %s", e) @@ -127,7 +133,8 @@ class AccountMove(models.Model): message_dict (dict): Email message dictionary Returns: - ir.attachment: The created attachment record or False if failed + dict: Dictionary with keys `name` and `datas` containing the name and + base64 encoded data of the attachment """ # Extract email details email_from = message_dict.get("email_from", "Unknown Sender") @@ -139,10 +146,16 @@ class AccountMove(models.Model): if isinstance(email_date, datetime): email_date = email_date.strftime("%Y-%m-%d %H:%M:%S") + # Check if body is empty or None + if not body: + _logger.warning("Email body is empty, using placeholder content") + body = "

This email did not contain any body content.

" + # Create HTML content for the PDF html_content = f""" + - - -

Complex HTML Test

- - - - - - - - - - - - - - - - -
Header 1Header 2Header 3
Row 1, Cell 1Row 1, Cell 2Row 1, Cell 3
Row 2, Cell 1Row 2, Cell 2Row 2, Cell 3
- - + This test simulates the full flow of an email being received through + the mail alias and verifies that an account move is created with a PDF + attachment generated from the email content. """ - # Convert complex HTML to PDF - pdf_content = self.account_move._html_to_pdf(complex_html) + # Get the current timestamp + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + message_id = f"" + subject = f"Invoice from Test Supplier {timestamp}" + date = datetime.now().strftime("%a, %d %b %Y %H:%M:%S +0000") - # Verify the PDF was created - self.assertTrue( - pdf_content, "PDF content should be generated from complex HTML" + # HTML body of the email + html_body = "

Invoice Test

This is a test invoice from Test Supplier.

Amount: $100.00

Date: 2025-03-27

" + + # Construct the raw email with proper headers + # Make sure the From header is correctly formatted for Odoo to parse + raw_email = f"""Return-Path: <{self.sender_email}> +X-Original-To: {self.alias_email} +Delivered-To: {self.alias_email} +Received: from mail.example.com (mail.example.com [192.168.1.1]) +From: {self.sender_email} +To: {self.alias_email} +Subject: {subject} +Date: {date} +Message-ID: {message_id} +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +{html_body}""" + + # Process the email through the mail gateway + # This simulates what happens when fetchmail receives an email + invoice_count_before = self.env["account.move"].search_count( + [("move_type", "=", "in_invoice")] ) - self.assertTrue( - pdf_content.startswith(b"%PDF-"), "Content should be a valid PDF" + + # Process the email through the mail gateway + # We need to specify the model explicitly since we're in a test environment + # In production, the alias would determine this automatically + self.env["mail.thread"].with_context(fetchmail_server_id=1).message_process( + model=None, + message=raw_email, + save_original=True, + strip_attachments=False, + ) + + # Count account moves after the test + # Note: move_type is the correct field name in Odoo, even if the linter doesn't recognize it + move_count_after = self.env["account.move"].search_count( + [("move_type", "=", "in_invoice")] + ) + self.assertEqual( + move_count_after, + invoice_count_before + 1, + "A new account move should be created", + ) + + # Find the newly created invoice + invoice = self.env["account.move"].search( + [("move_type", "=", "in_invoice")], order="id desc", limit=1 + ) + self.assertTrue(invoice, "An account move should be created") + + messages = invoice.message_ids + self.assertEqual(len(messages), 1, "The account move should have one message") + + # Debug message attachments + _logger.info("Message ID: %s", messages.id) + _logger.info("Message attachments: %s", messages.attachment_ids) + _logger.info("Message attachment count: %s", len(messages.attachment_ids)) + + # Debug all attachments in the system + all_attachments = self.env["ir.attachment"].search( + [("res_model", "=", "mail.message"), ("res_id", "=", messages.id)] + ) + _logger.info("All attachments for this message: %s", all_attachments) + _logger.info("All attachment count: %s", len(all_attachments)) + + attachment = messages.attachment_ids + + self.assertTrue(attachment, "The message should have an attachment") + + attachment = attachment.filtered( + lambda a: a.mimetype == "application/pdf" or ".pdf" in a.name + ) + self.assertTrue(attachment, "The message should have a PDF attachment") + + # Verify the invoice is linked to the correct supplier + self.assertEqual( + invoice.partner_id, + self.supplier, + "The invoice should be linked to the correct supplier", + ) + + # Verify the invoice type + self.assertEqual( + invoice.move_type, "in_invoice", "The invoice should be an incoming invoice" ) - self.assertTrue(len(pdf_content) > 100, "PDF should have reasonable size")