"""SMTP email driver. Generic SMTP — works with Google Workspace SMTP relay, AWS SES SMTP interface, Postmark, internal Postfix relays, etc. IT Admin configures host / port / username / password / TLS mode once on the admin form; the platform picks it up for every transactional email (new-hire welcome, transfer-request notification, leaver reminders). One integration per SMTP server. ``is_primary=True`` picks the default sender when multiple SMTP integrations exist (e.g. prod + staging). """ from __future__ import annotations import asyncio import logging import smtplib import ssl from email.message import EmailMessage as _PyEmailMessage from email.utils import formataddr, make_msgid from app.integrations.driver_base import ( EmailDriver, EmailMessage, EmailSendResult, ) from app.integrations.registry import register_driver from app.integrations.schemas import ( IntegrationFieldInfo, IntegrationTestResult, ) logger = logging.getLogger(__name__) @register_driver class SmtpEmailDriver(EmailDriver): provider = "smtp" display_name = "SMTP" description = "通用 SMTP 发件。可配合 Google Workspace、SES、Postmark 等使用。" config_schema = [ IntegrationFieldInfo( key="host", label="SMTP Host", is_secret=False, required=True, placeholder="smtp.gmail.com", ), IntegrationFieldInfo( key="port", label="Port", is_secret=False, required=True, default=587, placeholder="587", ), IntegrationFieldInfo( key="username", label="用户名", is_secret=False, required=False, ), IntegrationFieldInfo( key="password", label="密码 / App Password", is_secret=True, required=False, ), IntegrationFieldInfo( key="use_tls", label="STARTTLS", is_secret=False, required=False, default=True, ), IntegrationFieldInfo( key="use_ssl", label="SMTPS (implicit SSL)", is_secret=False, required=False, default=False, ), IntegrationFieldInfo( key="from_address", label="默认发件人邮箱", is_secret=False, required=True, placeholder="it@example.com", ), IntegrationFieldInfo( key="from_name", label="默认发件人显示名", is_secret=False, required=False, placeholder="Helix IT Bot", ), IntegrationFieldInfo( key="timeout_seconds", label="连接超时(秒)", is_secret=False, required=False, default=15, ), ] async def test_connection(self) -> IntegrationTestResult: try: await asyncio.to_thread(self._test_sync) return IntegrationTestResult(ok=True, message="SMTP 连接成功") except Exception as exc: # noqa: BLE001 — surface any smtp failure return IntegrationTestResult(ok=False, message=str(exc)) async def send(self, msg: EmailMessage) -> EmailSendResult: try: message_id = await asyncio.to_thread(self._send_sync, msg) return EmailSendResult(ok=True, provider_message_id=message_id) except Exception as exc: # noqa: BLE001 — SMTP is flaky; let caller decide retry logger.warning("SMTP send failed: %s", exc) return EmailSendResult(ok=False, error_message=str(exc)) # ── internals ─────────────────────────────────────────────── def _test_sync(self) -> None: with self._connect() as server: server.noop() def _send_sync(self, msg: EmailMessage) -> str: # Pre-launch hardening 2026-05-06: refuse CR/LF in any # user-controllable header value to block email-header # injection. ``EmailMessage`` under the default ``compat32`` # policy will silently accept ``"\r\nBcc: attacker@evil"`` # spliced into ``from_name`` / ``subject`` / ``reply_to`` and # fold it into the wire envelope. Names/subjects are sourced # from HRIS (display_name) and approval form input — both # admin-influenceable but not strictly server-controlled. for label, value in ( ("subject", msg.subject), ("from_name", msg.from_name), ("from_address", msg.from_address), ("reply_to", msg.reply_to), ("message_id", msg.message_id), ): if value and any(c in value for c in ("\r", "\n")): raise ValueError( f"SMTP email header injection refused: " f"{label} contains CR/LF" ) # Round-49 defence-in-depth: extend the CRLF guard to recipient # lists. ``msg.to``/``cc``/``bcc`` flow into the ``To:`` / # ``Cc:`` headers via ``", ".join(...)``; an entry like # ``"victim@x\r\nBcc: attacker@evil"`` would splice in a hidden # Bcc — same primitive the existing guard blocks for subject / # from. Upstream allowlists (account_provisioner.py:443-465) # cover most callers but not all; layer here so a future caller # writing tainted recipients fails closed. for label, addrs in ( ("to", msg.to), ("cc", msg.cc), ("bcc", msg.bcc), ): for addr in (addrs or ()): if addr and any(c in addr for c in ("\r", "\n")): raise ValueError( f"SMTP email header injection refused: " f"{label} recipient contains CR/LF" ) py_msg = _PyEmailMessage() sender_addr = msg.from_address or str(self.config.get("from_address") or "") sender_name = msg.from_name or self.config.get("from_name") py_msg["From"] = ( formataddr((sender_name, sender_addr)) if sender_name else sender_addr ) py_msg["To"] = ", ".join(msg.to) if msg.cc: py_msg["Cc"] = ", ".join(msg.cc) if msg.reply_to: py_msg["Reply-To"] = msg.reply_to py_msg["Subject"] = msg.subject message_id = msg.message_id or make_msgid() py_msg["Message-ID"] = message_id # Prefer text + html alternatives so mail clients render HTML # but degrade gracefully. If only one body is provided, use it. if msg.body_html and msg.body_text: py_msg.set_content(msg.body_text) py_msg.add_alternative(msg.body_html, subtype="html") elif msg.body_html: py_msg.set_content( _html_to_text_stub(msg.body_html), ) py_msg.add_alternative(msg.body_html, subtype="html") else: py_msg.set_content(msg.body_text or "") recipients = list(msg.to) + list(msg.cc) + list(msg.bcc) with self._connect() as server: server.send_message(py_msg, from_addr=sender_addr, to_addrs=recipients) return message_id def _connect(self) -> smtplib.SMTP: host = str(self.config.get("host") or "").strip() if not host: raise ValueError("SMTP host not configured") # Pre-launch hardening 2026-05-06: SSRF check on the SMTP host. # An admin (or attacker who phished one) configuring # ``host=169.254.169.254`` would otherwise send every email's # body — which contains employee PII like termination dates, # asset serials, manager names — plus the SMTP credential to # the cloud metadata service. ``assert_safe_smtp_host`` reuses # the HTTP SSRF allowlist's private/loopback/metadata block. from app.integrations._url_safety import assert_safe_smtp_host assert_safe_smtp_host(host) port = int(self.config.get("port") or 587) timeout = int(self.config.get("timeout_seconds") or 15) use_ssl = bool(self.config.get("use_ssl")) use_tls = bool(self.config.get("use_tls", True)) username = self.config.get("username") or "" password = self.config.get("password") or "" if use_ssl: context = ssl.create_default_context() server: smtplib.SMTP = smtplib.SMTP_SSL(host, port, timeout=timeout, context=context) else: server = smtplib.SMTP(host, port, timeout=timeout) try: server.ehlo() if use_tls and not use_ssl: server.starttls(context=ssl.create_default_context()) server.ehlo() if username and password: server.login(username, password) except Exception: server.close() raise return server def _html_to_text_stub(html: str) -> str: """Last-ditch text fallback when caller only supplied HTML. Intentionally simple — proper markdown/text generation is the template author's job. Strips tags naively so the text part is not literally HTML for plain-text clients. """ import re text = re.sub(r"", "\n", html, flags=re.IGNORECASE) text = re.sub(r"", "\n\n", text, flags=re.IGNORECASE) text = re.sub(r"<[^>]+>", "", text) return text.strip()