diff --git a/account_email_to_pdf/models/account_move.py b/account_email_to_pdf/models/account_move.py
index 8120915..29e5d51 100644
--- a/account_email_to_pdf/models/account_move.py
+++ b/account_email_to_pdf/models/account_move.py
@@ -62,6 +62,37 @@ class AccountMove(models.Model):
Returns:
bytes: PDF content as bytes or False if conversion failed
"""
+ # Check if the content is plain text (not HTML)
+ # More comprehensive check for HTML content
+ is_html = bool(re.search(r"
+
+
+
+
+
+
+ {escaped_content}
+
+
+ """
+ _logger.info(f"Wrapped HTML content length: {len(html_content)}")
# Check if wkhtmltopdf is installed
wkhtmltopdf_bin = find_in_path("wkhtmltopdf")
@@ -80,7 +111,9 @@ class AccountMove(models.Model):
try:
# Write the HTML content to the temporary file
with closing(os.fdopen(html_file_fd, "wb")) as html_file:
- html_file.write(html_content.encode("utf-8"))
+ encoded_content = html_content.encode("utf-8")
+ _logger.info(f"Encoded HTML content length: {len(encoded_content)}")
+ html_file.write(encoded_content)
# Close the PDF file descriptor as wkhtmltopdf will write to it
os.close(pdf_file_fd)
@@ -96,15 +129,24 @@ class AccountMove(models.Model):
command.append(html_file_path)
command.append(pdf_file_path)
+ _logger.info(f"Running wkhtmltopdf command: {' '.join(command)}")
process = subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
- _, err = process.communicate()
+ out, err = process.communicate()
if process.returncode != 0:
_logger.error(
"wkhtmltopdf failed with error code %s: %s", process.returncode, err
)
+ if out:
+ _logger.info(
+ f"wkhtmltopdf stdout: {out.decode('utf-8', errors='replace')[:200]}"
+ )
+ if err:
+ _logger.error(
+ f"wkhtmltopdf stderr: {err.decode('utf-8', errors='replace')}"
+ )
return False
# Read the generated PDF
@@ -112,6 +154,12 @@ class AccountMove(models.Model):
with open(pdf_file_path, "rb") as pdf_file:
pdf_content = pdf_file.read()
+ _logger.info(
+ f"Successfully read PDF file, size: {len(pdf_content)} bytes"
+ )
+ if pdf_content:
+ _logger.info(f"PDF file starts with: {pdf_content[:20]}")
+
return pdf_content
except Exception as e:
_logger.exception("Error reading generated PDF file: %s", e)
@@ -139,6 +187,8 @@ class AccountMove(models.Model):
dict: Dictionary with keys `name` and `datas` containing the name and
base64 encoded data of the attachment
"""
+ # Log message dict keys for debugging
+ _logger.info(f"Message dict keys: {list(message_dict.keys())}")
# Extract email details
email_from = message_dict.get("email_from", "Unknown Sender")
email_date = message_dict.get("date", datetime.now())
@@ -154,6 +204,58 @@ class AccountMove(models.Model):
_logger.warning("Email body is empty, using placeholder content")
body = "This email did not contain any body content.
"
+ # Check if the body is plain text based on content-type
+ # The content type can be found in the message headers or directly in the message_dict
+ content_type = ""
+
+ # Try to get content type from various places in the message dict
+ if "content-type" in message_dict:
+ content_type = message_dict["content-type"].lower()
+ _logger.info(f"Found content-type directly in message_dict: {content_type}")
+
+ # Try to get from headers
+ headers = message_dict.get("headers", {})
+ _logger.info(f"Headers type: {type(headers)}")
+
+ if not content_type and headers:
+ if isinstance(headers, dict):
+ content_type = headers.get("Content-Type", "").lower()
+ _logger.info(f"Found Content-Type in headers dict: {content_type}")
+ elif isinstance(headers, list):
+ _logger.info(f"Headers is a list with {len(headers)} items")
+ for header in headers:
+ _logger.info(f"Header item: {header}")
+ if (
+ isinstance(header, tuple)
+ and len(header) >= 2
+ and header[0].lower() == "content-type"
+ ):
+ content_type = header[1].lower()
+ _logger.info(
+ f"Found Content-Type in headers list: {content_type}"
+ )
+ break
+
+ # Try to determine from the body content if we still don't have a content type
+ if not content_type:
+ if re.search(r" tags and wrap in paragraph tags
+ # Escape HTML special characters to prevent injection
+ body = f"{escape(body)}
"
+ _logger.info(f"Converted plain text body length: {len(body)}")
+ _logger.info(f"First 100 chars of converted body: {body[:100]}")
+
# Create HTML content for the PDF
html_content = f"""
@@ -181,16 +283,29 @@ class AccountMove(models.Model):
"""
# Convert HTML to PDF
+ _logger.info(f"HTML content length before PDF conversion: {len(html_content)}")
+ _logger.info(f"First 200 chars of HTML content: {html_content[:200]}")
+
pdf_content = self._html_to_pdf(html_content)
- if not pdf_content:
+
+ if pdf_content:
+ _logger.info(f"PDF generation successful, size: {len(pdf_content)} bytes")
+ _logger.info(f"PDF starts with: {pdf_content[:20]}")
+ else:
+ _logger.error("PDF generation failed")
return False
# Create a proper ir.attachment record
filename = f"Email_{subject.replace(' ', '_')[:30]}.pdf"
+ # Note: No need to base64 encode the PDF content here
+ # When this attachment is added to message_dict["attachments"], Odoo expects raw binary data
+ # Odoo will handle the base64 encoding when creating the actual ir.attachment record
+ _logger.info(f"Raw PDF length: {len(pdf_content)} bytes")
+
attachment = {
"name": filename,
- "datas": base64.b64encode(pdf_content),
+ "datas": pdf_content,
}
return attachment
diff --git a/account_email_to_pdf/tests/test_email_to_pdf.py b/account_email_to_pdf/tests/test_email_to_pdf.py
index 29fc512..e7ef8e2 100644
--- a/account_email_to_pdf/tests/test_email_to_pdf.py
+++ b/account_email_to_pdf/tests/test_email_to_pdf.py
@@ -1,7 +1,10 @@
from odoo.tests.common import TransactionCase
from odoo.tools.misc import find_in_path
from datetime import datetime
+import base64
+import logging
+_logger = logging.getLogger(__name__)
class TestHtmlToPdf(TransactionCase):
@@ -99,6 +102,39 @@ class TestHtmlToPdf(TransactionCase):
)
self.assertTrue(len(pdf_content) > 100, "PDF should have reasonable size")
+ def test_plain_text_to_pdf_conversion(self):
+ """Test the conversion of plain text email to PDF."""
+ if not self.wkhtmltopdf_available:
+ self.skipTest("wkhtmltopdf not available")
+
+ # Simple plain text content
+ plain_text = "This is a plain text email.\nIt has no HTML formatting.\nJust plain text content.\n\nRegards,\nTest Sender"
+
+ # Log the input content
+ _logger.info(f"Input plain text content: {plain_text}")
+
+ # Call the method to convert plain text to PDF
+ pdf_content = self.account_move._html_to_pdf(plain_text)
+
+ # Log the result
+ if pdf_content:
+ _logger.info(f"PDF content generated, size: {len(pdf_content)} bytes")
+ _logger.info(f"PDF starts with: {pdf_content[:20]}")
+ else:
+ _logger.error("Failed to generate PDF from plain text")
+
+ # Verify the PDF was created
+ self.assertTrue(
+ pdf_content, "PDF content should be generated even for plain text"
+ )
+
+ # Verify it's a valid PDF
+ is_pdf = pdf_content and pdf_content.startswith(b"%PDF-")
+ _logger.info(f"Is valid PDF: {is_pdf}")
+
+ self.assertTrue(is_pdf, "Content should be a valid PDF")
+ self.assertTrue(len(pdf_content) > 100, "PDF should have reasonable size")
+
# Integration test
def test_account_email_to_pdf_full_flow(self):
"""Test that an email without attachments is correctly converted to PDF
@@ -191,3 +227,124 @@ Content-Transfer-Encoding: 7bit
self.assertEqual(
invoice.move_type, "in_invoice", "The invoice should be an incoming invoice"
)
+
+ def test_plain_text_email_to_pdf_full_flow(self):
+ """Test that a plain text email without attachments is correctly converted to PDF
+ and an account move is created.
+
+ This test simulates the full flow of a plain text email being received through
+ the mail alias and verifies that an account move is created with a PDF
+ attachment generated from the email content.
+ """
+ _logger.info("Starting test_plain_text_email_to_pdf_full_flow")
+
+ # Get the current timestamp
+ timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
+ message_id = f""
+ subject = f"Plain Text Invoice from Test Supplier {timestamp}"
+ date = datetime.now().strftime("%a, %d %b %Y %H:%M:%S +0000")
+
+ # Plain text body of the email
+ plain_text_body = """Invoice Test
+
+This is a test invoice from Test Supplier.
+Amount: $100.00
+Date: 2025-03-27
+
+Thank you for your business.
+ """
+
+ # 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/plain; charset=UTF-8
+Content-Transfer-Encoding: 7bit
+
+{plain_text_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")]
+ )
+
+ # Process the email through the mail gateway
+ _logger.info("Processing plain text email through mail gateway")
+ _logger.info(f"Raw email length: {len(raw_email)}")
+ _logger.info(f"Raw email first 200 chars: {raw_email[:200]}")
+
+ try:
+ self.env["mail.thread"].with_context(fetchmail_server_id=1).message_process(
+ model=None,
+ message=raw_email,
+ save_original=True,
+ strip_attachments=False,
+ )
+ _logger.info("Email processing completed successfully")
+ except Exception as e:
+ _logger.error(f"Error during email processing: {str(e)}")
+ import traceback
+
+ _logger.error(f"Traceback: {traceback.format_exc()}")
+ raise
+
+ # Count account moves after the test
+ 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 from plain text email",
+ )
+
+ # 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 from plain text email"
+ )
+
+ messages = invoice.message_ids
+ self.assertEqual(len(messages), 1, "The account move should have one message")
+
+ 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 PDF content if possible
+ if attachment:
+ _logger.info(f"Found PDF attachment: {attachment.name}")
+ pdf_data = base64.b64decode(attachment.datas) if attachment.datas else b""
+ _logger.info(f"PDF data size: {len(pdf_data)} bytes")
+
+ if pdf_data:
+ _logger.info(f"PDF data starts with: {pdf_data[:20]}")
+ is_valid_pdf = pdf_data.startswith(b"%PDF-")
+ _logger.info(f"Is valid PDF: {is_valid_pdf}")
+ else:
+ _logger.error("PDF data is empty")
+
+ self.assertTrue(
+ pdf_data.startswith(b"%PDF-"),
+ f"Attachment should be a valid PDF even when source is plain text, starts with {pdf_data[:20]}",
+ )
+ self.assertTrue(
+ len(pdf_data) > 100, "PDF from plain text should have reasonable size"
+ )
+ else:
+ _logger.error("No PDF attachment found")