#!/usr/bin/env python3 import json import logging import os import smtplib import socket from datetime import datetime from email.mime.application import MIMEApplication from email.mime.image import MIMEImage from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText import pypdf from jinja2 import Environment, FileSystemLoader, select_autoescape from app.celery_app import celery from app.config import settings from app.tasks.retry_config import UploadTaskWithRetry from app.utils import log_task_progress logger = logging.getLogger(__name__) # Constants _LOGO_FILENAME = "logo.png" # Mapping from PDF metadata keys (with leading slash stripped) to application-specific names. # This mirrors the inverse of the mapping used in app/tasks/embed_metadata_into_pdf.py. _PDF_METADATA_KEY_MAP = { "Title": "filename", "Author": "absender", "Subject": "document_type", "Keywords": "tags", } def get_email_template(template_name="default.html"): """ Load email template from one of these locations in order of precedence: 1. Custom template from workdir/templates/email/ 2. Default template from app/templates/email/ """ # First try to load from workdir (user customizable location) try: workdir_template_path = os.path.join(settings.workdir, "templates", "email") if os.path.exists(workdir_template_path): env = Environment( loader=FileSystemLoader(workdir_template_path), autoescape=select_autoescape(["html", "xml"]) ) env.globals["now"] = datetime.now # Add the now function to the Jinja environment template = env.get_template(template_name) logger.info(f"Using custom email template from workdir: {template_name}") return template except Exception as e: logger.warning(f"Failed to load custom email template: {str(e)}") # Fallback to built-in template try: # Get the app directory path (where this file is) current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) app_template_path = os.path.join(current_dir, "templates", "email") env = Environment(loader=FileSystemLoader(app_template_path), autoescape=select_autoescape(["html", "xml"])) env.globals["now"] = datetime.now # Add the now function to the Jinja environment template = env.get_template(template_name) logger.info(f"Using built-in email template: {template_name}") return template except Exception as e: logger.error(f"Failed to load built-in email template: {str(e)}") raise ValueError(f"Could not find any valid email template: {str(e)}") def extract_metadata_from_file(file_path): """ Try to extract metadata from a file using several methods: 1. Check for a .json metadata file with the same name 2. Extract embedded metadata from PDF using pypdf JSON metadata takes precedence; embedded PDF metadata fills in any missing fields using the application's standard key mapping (e.g., /Title → filename). Returns a dictionary of metadata (may be empty if none found). """ metadata = {} # Check for separate metadata JSON file metadata_path = os.path.splitext(file_path)[0] + ".json" if os.path.exists(metadata_path): try: with open(metadata_path, "r", encoding="utf-8") as f: metadata = json.load(f) logger.info(f"Loaded metadata from external JSON file: {metadata_path}") except Exception as e: logger.warning(f"Failed to load metadata from JSON file: {str(e)}") # Try to extract embedded metadata from PDF if file_path.lower().endswith(".pdf") and os.path.exists(file_path): try: with open(file_path, "rb") as f: pdf_reader = pypdf.PdfReader(f) pdf_metadata = pdf_reader.metadata if pdf_metadata: for key, value in pdf_metadata.items(): # Remove the leading slash from PDF metadata keys (e.g., '/Title' -> 'Title') clean_key = key[1:] if key.startswith("/") else key # Map to application-specific key names where possible mapped_key = _PDF_METADATA_KEY_MAP.get(clean_key, clean_key) # Only set if not already present (JSON metadata takes precedence) if mapped_key not in metadata: metadata[mapped_key] = str(value) logger.info(f"Extracted embedded metadata from PDF: {file_path}") except Exception as e: logger.warning(f"Failed to extract metadata from PDF {file_path}: {str(e)}") return metadata def attach_logo(msg): """Attach the DocuElevate logo to the email with proper Content-ID.""" try: # Try to find logo in workdir first (for customization) custom_logo_path = os.path.join(settings.workdir, "templates", "email", _LOGO_FILENAME) if os.path.exists(custom_logo_path): logo_path = custom_logo_path else: # Use built-in logo app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) logo_path = os.path.join(app_dir, "static", _LOGO_FILENAME) # Fallback to logo in frontend/static if app/static doesn't exist if not os.path.exists(logo_path): logo_path = os.path.join(app_dir, "..", "frontend", "static", _LOGO_FILENAME) if os.path.exists(logo_path): with open(logo_path, "rb") as img: logo_data = img.read() # Determine image MIME type based on extension mimetype = "image/svg+xml" if logo_path.endswith(".svg") else "image/png" logo_attach = MIMEImage(logo_data, mimetype) logo_attach.add_header("Content-ID", "") logo_attach.add_header("Content-Disposition", "inline", filename=_LOGO_FILENAME) msg.attach(logo_attach) logger.info(f"Logo attached from {logo_path}") return True else: logger.warning("Could not find logo file") return False except Exception as e: logger.warning(f"Error attaching logo: {str(e)}") return False def _prepare_recipients(recipients): """Helper function to prepare email recipients list.""" if not recipients: if not settings.dest_email_default_recipient: error_msg = "No recipients specified and no default recipient configured" logger.error(error_msg) return None, error_msg return [settings.dest_email_default_recipient], None elif isinstance(recipients, str): return [recipients], None # Convert single email to list return recipients, None def _send_email_with_smtp(msg, filename, recipients): """Helper function to handle SMTP connection and sending.""" try: # First try to resolve the hostname socket.gethostbyname(settings.dest_email_host) # Connect to the SMTP server with smtplib.SMTP(settings.dest_email_host, settings.dest_email_port, timeout=30) as server: # Use TLS if specified if settings.dest_email_use_tls: server.starttls() # Login if credentials are provided if settings.dest_email_username and settings.dest_email_password: server.login(settings.dest_email_username, settings.dest_email_password) # Send the email server.send_message(msg) logger.info(f"Successfully sent {filename} via email to {', '.join(recipients)}") return None except socket.gaierror as e: error_msg = f"Failed to resolve email host: {settings.dest_email_host} - {str(e)}" logger.error(error_msg) return {"status": "Failed", "reason": error_msg, "error": str(e)} except (ConnectionRefusedError, TimeoutError) as e: error_msg = f"Connection error to SMTP server {settings.dest_email_host}:{settings.dest_email_port} - {str(e)}" logger.error(error_msg) return {"status": "Failed", "reason": error_msg, "error": str(e)} @celery.task(base=UploadTaskWithRetry, bind=True) def upload_to_email( self, file_path: str, recipients=None, subject=None, message=None, template_name="default.html", include_metadata=True, file_id: int = None, ): """ Sends a file via email to the specified recipients. If recipients is None, uses the configured default email recipient. Args: file_path: Path to the file to send recipients: Optional list of recipient email addresses subject: Optional email subject message: Optional custom message template_name: Email template to use include_metadata: Whether to include metadata in the email file_id: Optional file ID to associate with logs """ task_id = self.request.id logger.info(f"[{task_id}] Starting email send: {file_path}") log_task_progress( task_id, "upload_to_email", "in_progress", f"Sending via email: {os.path.basename(file_path)}", file_id=file_id ) if not os.path.exists(file_path): error_msg = f"File not found: {file_path}" logger.error(f"[{task_id}] {error_msg}") log_task_progress(task_id, "upload_to_email", "failure", error_msg, file_id=file_id) raise FileNotFoundError(error_msg) # Extract filename filename = os.path.basename(file_path) # Check if email destination settings are configured if not settings.dest_email_host: error_msg = "Email destination host is not configured (DEST_EMAIL_HOST)" logger.error(f"[{task_id}] {error_msg}") log_task_progress(task_id, "upload_to_email", "skipped", error_msg, file_id=file_id) return {"status": "Skipped", "reason": error_msg} # Log email configuration for debugging logger.debug( f"[{task_id}] Email destination config - Host: {settings.dest_email_host}, Port: {settings.dest_email_port}, " f"Username: {settings.dest_email_username}, TLS: {settings.dest_email_use_tls}" ) # Process recipients recipients, error = _prepare_recipients(recipients) if error: logger.error(f"[{task_id}] {error}") log_task_progress(task_id, "upload_to_email", "skipped", error, file_id=file_id) return {"status": "Skipped", "reason": error} # Use provided subject or create default subject = subject or f"DocuElevate Document: {filename}" # Extract document metadata if available metadata = {} if include_metadata: metadata = extract_metadata_from_file(file_path) try: # Create the email msg = MIMEMultipart("related") msg["From"] = settings.dest_email_sender or settings.dest_email_username msg["To"] = ", ".join(recipients) msg["Subject"] = subject # Create alternative part for HTML content alt_part = MIMEMultipart("alternative") msg.attach(alt_part) # Attach logo to the email has_logo = attach_logo(msg) # Load and render template template = get_email_template(template_name) # Context data for the template context = { "filename": filename, "message": message or f"Attached is the document: {filename}", "app_name": "DocuElevate", "app_url": f"https://{settings.external_hostname}" if settings.external_hostname else None, "custom_message": message, "metadata": metadata, "has_metadata": bool(metadata), "has_logo": has_logo, "current_year": datetime.now().year, } # Render HTML body html_content = template.render(**context) alt_part.attach(MIMEText(html_content, "html")) # Attach the file with open(file_path, "rb") as file: attachment = MIMEApplication(file.read(), _subtype="pdf") attachment.add_header("Content-Disposition", f'attachment; filename="{filename}"') msg.attach(attachment) # Send the email through SMTP error_result = _send_email_with_smtp(msg, filename, recipients) if error_result: logger.error(f"[{task_id}] Failed to send email: {error_result.get('reason')}") log_task_progress(task_id, "upload_to_email", "failure", error_result.get("reason"), file_id=file_id) return error_result logger.info(f"[{task_id}] Successfully sent {filename} via email to {len(recipients)} recipients") log_task_progress(task_id, "upload_to_email", "success", f"Sent via email: {filename}", file_id=file_id) return { "status": "Completed", "file": file_path, "recipients": recipients, "subject": subject, "metadata_included": bool(metadata), "logo_included": has_logo, } except Exception as e: error_msg = f"Failed to send {filename} via email: {str(e)}" logger.error(f"[{task_id}] {error_msg}") log_task_progress(task_id, "upload_to_email", "failure", error_msg, file_id=file_id) raise Exception(error_msg)