"""
Pure-Python EML exporter for the QBR Flask application.
No pywin32, no Outlook installation required.
Uses only Python standard library.
Features:
- X-Unsent: 1 → Outlook opens as fresh email with Send enabled
- From is OPTIONAL — if omitted, Outlook uses default account
- To is OPTIONAL — if omitted, user fills manually in Outlook
- HTML minifier → strips redundant whitespace/mso styles
"""
import base64
import binascii
import os
import re
import shutil
import tempfile
from pathlib import Path
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
from email import encoders
from flask import jsonify, request, send_file
def _minify_html(html):
if not html:
return html
html = re.sub(r'', '', html, flags=re.DOTALL)
html = re.sub(r'mso-[a-z\-]+\s*:\s*[^;"]+;?\s*', '', html)
html = re.sub(r'\s*style\s*=\s*"\s*"', '', html)
html = re.sub(r'>\s+<', '><', html)
html = re.sub(r'\n\s*\n', '\n', html)
html = re.sub(r'[ \t]+', ' ', html)
lines = [line.strip() for line in html.split('\n') if line.strip()]
return '\n'.join(lines)
def _extract_inline_data_images(html):
"""
Convert data:image/* base64 URLs in HTML into CID references.
Outlook/Word is much more reliable with MIME inline images than with
data: URLs. The browser export creates PNG data URLs for report slides;
this function turns those images into proper MIME related parts.
Returns:
(updated_html, inline_images)
inline_images = [(content_id, mime_type, bytes), ...]
"""
if not html:
return html, []
pattern = re.compile(
r'data:(image/(?:png|jpe?g|gif|webp));base64,([A-Za-z0-9+/=\s]+)',
flags=re.IGNORECASE
)
inline_images = []
cache = {}
counter = 0
def replace(match):
nonlocal counter
mime_type = match.group(1).lower()
encoded = re.sub(r'\s+', '', match.group(2))
try:
image_bytes = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError):
return match.group(0)
if not image_bytes:
return match.group(0)
# Reuse the same CID for identical images to keep the EML smaller.
import hashlib
cache_key = (mime_type, hashlib.sha256(image_bytes).hexdigest())
if cache_key in cache:
content_id = cache[cache_key]
else:
counter += 1
content_id = f'qbr-inline-{counter}@prosupport-plus'
cache[cache_key] = content_id
inline_images.append((content_id, mime_type, image_bytes))
return f'cid:{content_id}'
updated_html = pattern.sub(replace, html)
return updated_html, inline_images
def _safe_filename(name):
name = str(name or "ProSupport-Plus-MBR")
name = os.path.basename(name)
for ext in (".msg", ".eml"):
if name.lower().endswith(ext):
name = name[: -len(ext)]
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]+', " ", name)
name = re.sub(r"\s+", " ", name).strip()
if not name:
name = "ProSupport-Plus-MBR"
return name + ".eml"
def _decode_attachment(item):
if not isinstance(item, dict):
raise ValueError("Invalid attachment data.")
original_name = str(item.get("name") or "attachment")
original_name = os.path.basename(original_name)
original_name = re.sub(r'[<>:"/\\|?*\x00-\x1f]+', " ", original_name).strip()
if not original_name:
original_name = "attachment"
encoded = item.get("base64", "")
if not encoded:
raise ValueError(f"Attachment '{original_name}' has no data.")
if isinstance(encoded, str) and encoded.startswith("data:"):
encoded = encoded.split(",", 1)[1]
try:
data = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError, TypeError) as exc:
raise ValueError(f"Invalid base64 data for '{original_name}'.") from exc
return original_name, data
def register_msg_export(app):
"""Register GET and POST /download-msg endpoints."""
@app.route("/download-msg", methods=["GET"])
def download_msg_check():
return jsonify(status="ok", format="eml"), 200
@app.route("/download-msg", methods=["POST"])
def download_msg():
data = request.get_json(silent=True) or {}
from_address = str(data.get("from") or "").strip()
to_address = str(data.get("to") or "").strip()
subject = str(data.get("subject") or "").strip()
html_body = str(data.get("html") or "")
text_body = str(data.get("text") or "")
filename = _safe_filename(data.get("filename"))
# FROM IS OPTIONAL — NO VALIDATION!
# If empty, Outlook uses default account
# TO IS NOW OPTIONAL — NO VALIDATION!
# If empty, user fills it manually in Outlook before sending
if not subject:
return jsonify(error="Please enter the Subject."), 400
if not html_body:
html_body = text_body or "ProSupport Plus Business Review"
attachments_payload = data.get("attachments") or []
if not isinstance(attachments_payload, list):
return jsonify(error="Invalid attachments payload."), 400
temp_dir = None
try:
html_body = _minify_html(html_body)
# Browser-side report slides are exported as data:image/png URLs.
# Convert them to CID inline MIME images because Outlook/Word does
# not reliably render data: URLs inside an EML HTML body.
html_body, inline_images = _extract_inline_data_images(html_body)
msg = MIMEMultipart("mixed")
if from_address:
msg["From"] = from_address
if to_address:
msg["To"] = to_address
msg["Subject"] = subject
msg["X-Unsent"] = "1"
# related = HTML + its inline images
related = MIMEMultipart("related")
alt = MIMEMultipart("alternative")
if text_body:
alt.attach(MIMEText(text_body, "plain", "utf-8"))
alt.attach(MIMEText(html_body, "html", "utf-8"))
related.attach(alt)
for content_id, mime_type, image_bytes in inline_images:
main_type, _, sub_type = mime_type.partition("/")
image_part = MIMEImage(image_bytes, _subtype=sub_type)
image_part.add_header("Content-ID", f"<{content_id}>")
image_part.add_header("Content-Disposition", "inline")
related.attach(image_part)
msg.attach(related)
for idx, item in enumerate(attachments_payload):
att_name, att_bytes = _decode_attachment(item)
mime_type = str(item.get("type") or "application/octet-stream")
main_type, _, sub_type = mime_type.partition("/")
if not sub_type:
main_type, sub_type = "application", "octet-stream"
part = MIMEBase(main_type, sub_type)
part.set_payload(att_bytes)
encoders.encode_base64(part)
part.add_header("Content-Disposition", "attachment", filename=att_name)
msg.attach(part)
temp_dir = Path(tempfile.mkdtemp(prefix="qbr_eml_"))
eml_path = temp_dir / filename
with open(eml_path, "wb") as fp:
fp.write(msg.as_bytes())
response = send_file(
str(eml_path),
mimetype="message/rfc822",
as_attachment=True,
download_name=filename,
max_age=0,
)
@response.call_on_close
def _cleanup():
try:
shutil.rmtree(temp_dir, ignore_errors=True)
except Exception:
pass
return response
except ValueError as ve:
return jsonify(error=str(ve)), 400
except Exception as exc:
if temp_dir:
try:
shutil.rmtree(temp_dir, ignore_errors=True)
except Exception:
pass
return jsonify(error=f"Email export failed: {exc}"), 500