GDPRScanner/routes/email.py
2026-04-11 04:38:11 +02:00

304 lines
14 KiB
Python

"""
SMTP configuration, test, and report sending
"""
from __future__ import annotations
from flask import Blueprint, jsonify, request
from routes import state
from app_config import _load_smtp_config, _save_smtp_config
from routes.export import _build_excel_bytes
bp = Blueprint("email", __name__)
def _send_report_email(xl_bytes: bytes, fname: str,
smtp_cfg: dict, recipients: list[str]) -> None:
"""Send the scan report Excel as an email attachment via SMTP."""
import smtplib as _smtp
import email.mime.text as _mime_text
import email.mime.multipart as _mime_mp
import email.mime.base as _mime_base
import email.encoders as _encoders
import datetime as _dt
host = smtp_cfg.get("host", "").strip()
port = int(smtp_cfg.get("port", 587))
username = smtp_cfg.get("username", "").strip()
password = smtp_cfg.get("password", "")
from_addr = smtp_cfg.get("from_addr", "").strip() or username
use_ssl = bool(smtp_cfg.get("use_ssl", False))
use_tls = bool(smtp_cfg.get("use_tls", True)) and not use_ssl
if not host:
raise ValueError("No SMTP host configured")
subject = f"GDPR Scanner \u2014 scan report {_dt.datetime.now().strftime('%Y-%m-%d')}"
body_html = (
"<html><body style='font-family:Arial,sans-serif;color:#333;padding:24px'>"
"<h2 style='color:#1F3864'>\u2601\ufe0f GDPR Scanner \u2014 scan report</h2>"
f"<p>Please find the latest scan report attached ({fname}).</p>"
f"<p style='color:#888;font-size:12px'>Generated: {_dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>"
"</body></html>"
)
msg = _mime_mp.MIMEMultipart("mixed")
msg["Subject"] = subject
msg["From"] = from_addr
msg["To"] = ", ".join(recipients)
msg.attach(_mime_text.MIMEText(body_html, "html"))
part = _mime_base.MIMEBase(
"application",
"vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
part.set_payload(xl_bytes)
_encoders.encode_base64(part)
part.add_header("Content-Disposition", f'attachment; filename="{fname}"')
msg.attach(part)
if use_ssl:
server = _smtp.SMTP_SSL(host, port, timeout=30)
else:
server = _smtp.SMTP(host, port, timeout=30)
with server:
server.ehlo()
if use_tls:
server.starttls()
server.ehlo()
if username and password:
server.login(username, password)
server.sendmail(from_addr, recipients, msg.as_string())
def _send_email_graph(subject: str, html_body: str,
recipients: list[str],
attachment_bytes: bytes = None,
attachment_name: str = None) -> None:
"""Send an email via Microsoft Graph API using the current connector token.
Requires Mail.Send permission (delegated or application).
Raises on failure."""
if not state.connector or not state.connector.is_authenticated():
raise RuntimeError("Not connected to Microsoft 365")
to_list = [{"emailAddress": {"address": r}} for r in recipients]
message: dict = {
"subject": subject,
"body": {"contentType": "HTML", "content": html_body},
"toRecipients": to_list,
}
if attachment_bytes and attachment_name:
import base64 as _b64
message["attachments"] = [{
"@odata.type": "#microsoft.graph.fileAttachment",
"name": attachment_name,
"contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"contentBytes": _b64.b64encode(attachment_bytes).decode(),
}]
if state.connector.is_app_mode:
smtp_cfg = _load_smtp_config()
sender = smtp_cfg.get("from_addr") or smtp_cfg.get("username") or recipients[0]
state.connector._post(f"/users/{sender}/sendMail", {"message": message, "saveToSentItems": False})
else:
state.connector._post("/me/sendMail", {"message": message, "saveToSentItems": False})
@bp.route("/api/smtp/config", methods=["GET"])
def smtp_config_get():
"""Return saved SMTP config (password redacted — never sent to client)."""
cfg = _load_smtp_config()
safe = {k: v for k, v in cfg.items() if k != "password"}
safe["has_password"] = bool(cfg.get("password"))
return jsonify(safe)
@bp.route("/api/smtp/config", methods=["POST"])
def smtp_config_save():
"""Save SMTP config. Omitting 'password' preserves any previously saved password."""
data = request.get_json() or {}
existing = _load_smtp_config()
if not data.get("password") and existing.get("password"):
data["password"] = existing["password"]
_save_smtp_config(data)
return jsonify({"status": "saved"})
@bp.route("/api/smtp/test", methods=["POST"])
def smtp_test():
"""Send a test email. Tries Microsoft Graph API first (no SMTP config needed),
falls back to SMTP if Graph is unavailable."""
import datetime as _dt
saved = _load_smtp_config()
recipients = saved.get("recipients", [])
if isinstance(recipients, str):
recipients = [r.strip() for r in recipients.replace(";", ",").split(",") if r.strip()]
if not recipients:
return jsonify({"error": "No recipients configured — add at least one recipient and save first"}), 400
subject = f"GDPR Scanner — test email ({_dt.datetime.now().strftime('%Y-%m-%d %H:%M')})"
body_html = (
"<html><body style='font-family:Arial,sans-serif;color:#333;padding:24px'>"
"<h2 style='color:#1F3864'>☁️ GDPR Scanner — test email</h2>"
"<p>This is a test email confirming that your email configuration is working correctly.</p>"
f"<p style='color:#888;font-size:12px'>Sent: {_dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>"
"</body></html>"
)
# Try Graph API first
if state.connector and state.connector.is_authenticated():
try:
_send_email_graph(subject, body_html, recipients)
return jsonify({"ok": True,
"message": f"Test email sent via Microsoft Graph to {', '.join(recipients)}"})
except Exception as graph_err:
graph_error_str = str(graph_err)
else:
graph_error_str = None
# Fall back to SMTP
host = saved.get("host", "").strip()
port = int(saved.get("port", 587))
username = saved.get("username", "").strip()
password = saved.get("password", "")
from_addr = saved.get("from_addr", "").strip() or username
use_ssl = bool(saved.get("use_ssl", False))
use_tls = bool(saved.get("use_tls", True)) and not use_ssl
if not host:
return jsonify({"error": "No SMTP host configured. To send via Microsoft 365 Graph (no SMTP needed), add Mail.Send to your Azure app registration."}), 400
try:
import smtplib as _smtp
import email.mime.text as _mime_text
import email.mime.multipart as _mime_mp
msg = _mime_mp.MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = from_addr
msg["To"] = ", ".join(recipients)
msg.attach(_mime_text.MIMEText(body_html, "html"))
if use_ssl:
server = _smtp.SMTP_SSL(host, port, timeout=15)
else:
server = _smtp.SMTP(host, port, timeout=15)
with server:
server.ehlo()
if use_tls:
server.starttls()
server.ehlo()
if username and password:
server.login(username, password)
server.sendmail(from_addr, recipients, msg.as_string())
suffix = " (⚠ Graph also failed — Mail.Send permission not granted)" if graph_error_str else ""
return jsonify({"ok": True, "message": f"Test email sent via SMTP to {', '.join(recipients)}{suffix}"})
except Exception as smtp_err:
err_str = str(smtp_err)
_h = host.lower()
_corp_m365 = "office365" in _h or "microsoft" in _h
_personal_ms = not _corp_m365 and any(s in _h for s in ("outlook", "live", "hotmail"))
_gmail_host = "gmail" in _h or "smtp.google" in _h
_auth_err = "5.7.57" in err_str or "530" in err_str or "535" in err_str or \
"534" in err_str or "not authenticated" in err_str.lower() or \
"Username and Password" in err_str
_conn_err = "nodename nor servname" in err_str or "Name or service not known" in err_str or \
"getaddrinfo" in err_str or "Connection refused" in err_str or \
"Errno 8" in err_str or "Errno 111" in err_str or "Errno 61" in err_str or \
"timed out" in err_str.lower()
if _conn_err:
err_str = (f"Could not connect to SMTP server \"{host}\" on port {port}. "
f"Check that the hostname and port are correct.")
elif _corp_m365 and _auth_err:
err_str = ("M365 blocked SMTP AUTH. Fix: enable Authenticated SMTP in the M365 admin centre "
"(Users → Active users → [user] → Mail → Manage email apps → Authenticated SMTP), "
"or add Mail.Send to your Azure app to use Graph instead.")
elif (_personal_ms or _gmail_host) and _auth_err:
provider = "Microsoft" if _personal_ms else "Google"
url = "account.microsoft.com/security" if _personal_ms else "myaccount.google.com → Security → 2-Step Verification"
err_str = (f"Authentication failed — {provider} blocks regular passwords for SMTP when MFA is enabled.\n\n"
f"Fix: create an App Password at {url} → App passwords "
f"and use that instead of your normal password.")
elif graph_error_str:
err_str = f"SMTP: {err_str} | Graph also unavailable (Mail.Send not granted)"
return jsonify({"error": err_str}), 200
@bp.route("/api/send_report", methods=["POST"])
def send_report():
"""Build Excel and email it to the requested recipients.
Tries Microsoft Graph API first, falls back to SMTP."""
if not state.flagged_items:
return jsonify({"error": "No results to send — run a scan first"}), 400
data = request.get_json() or {}
smtp_cfg = _load_smtp_config()
recipients = data.get("recipients", []) or smtp_cfg.get("recipients", [])
if isinstance(recipients, str):
recipients = [r.strip() for r in recipients.replace(";", ",").split(",") if r.strip()]
if data.get("smtp"):
smtp_cfg = {**smtp_cfg, **data["smtp"]}
if not recipients:
return jsonify({"error": "No recipients specified"}), 400
try:
xl_bytes, fname = _build_excel_bytes()
except Exception as e:
return jsonify({"error": f"Excel build failed: {e}"}), 500
import datetime as _dt
subject = f"GDPR Scanner — scan report {_dt.datetime.now().strftime('%Y-%m-%d')}"
body_html = (
"<html><body style='font-family:Arial,sans-serif;color:#333;padding:24px'>"
"<h2 style='color:#1F3864'>\u2601\ufe0f GDPR Scanner \u2014 scan report</h2>"
f"<p>Please find the latest scan report attached ({fname}).</p>"
f"<p style='color:#888;font-size:12px'>Generated: {_dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}<br>"
f"Items flagged: {len(state.flagged_items)}</p>"
"</body></html>"
)
# Try Graph API first
if state.connector and state.connector.is_authenticated():
try:
_send_email_graph(subject, body_html, recipients,
attachment_bytes=xl_bytes, attachment_name=fname)
return jsonify({"status": "sent", "method": "graph",
"recipients": recipients, "filename": fname})
except Exception as graph_err:
graph_err_str = str(graph_err)
if "403" in graph_err_str or "Forbidden" in graph_err_str \
or "Mail.Send" in graph_err_str or "insufficient" in graph_err_str.lower():
return jsonify({"error": (
"Mail.Send permission not granted on the Azure app registration. "
"Go to Azure AD → App registrations → [your app] → API permissions → "
"Add → Microsoft Graph → Mail.Send → Grant admin consent."
)}), 500
# Fall back to SMTP
try:
_send_report_email(xl_bytes, fname, smtp_cfg, recipients)
return jsonify({"status": "sent", "method": "smtp",
"recipients": recipients, "filename": fname})
except Exception as e:
err = str(e)
_h2 = smtp_cfg.get("host", "").lower()
_p2 = int(smtp_cfg.get("port", 587))
_corp_m365_2 = "office365" in _h2 or "microsoft" in _h2
_personal_ms_2 = not _corp_m365_2 and any(s in _h2 for s in ("outlook", "live", "hotmail"))
_gmail_2 = "gmail" in _h2 or "smtp.google" in _h2
_auth_err_2 = "5.7.57" in err or "530" in err or "535" in err or \
"534" in err or "not authenticated" in err.lower()
_conn_err_2 = "nodename nor servname" in err or "Name or service not known" in err or \
"getaddrinfo" in err or "Connection refused" in err or \
"Errno 8" in err or "Errno 111" in err or "Errno 61" in err or \
"timed out" in err.lower()
if _conn_err_2:
err = (f"Could not connect to SMTP server \"{_h2}\" on port {_p2}. "
f"Check that the hostname and port are correct.")
elif _corp_m365_2 and _auth_err_2:
err = (f"{err}\n\nTip: Enable SMTP AUTH for this mailbox in the Microsoft 365 admin centre, "
"or connect to M365 first so the scanner can send via Microsoft Graph instead.")
elif (_personal_ms_2 or _gmail_2) and _auth_err_2:
provider2 = "Microsoft" if _personal_ms_2 else "Google"
url2 = "account.microsoft.com/security" if _personal_ms_2 else "myaccount.google.com → Security → 2-Step Verification"
err = (f"Authentication failed — {provider2} blocks regular passwords for SMTP when MFA is enabled.\n\n"
f"Fix: create an App Password at {url2} → App passwords "
f"and use that instead of your normal password.")
return jsonify({"error": err}), 500