service.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  1. # -*- coding: utf-8 -*-
  2. """
  3. Trixy Email-Service.
  4. Core-Service fuer SMTP-Versand mit optionaler PGP-Signierung/Verschluesselung.
  5. Kann von Plugins verwendet werden um E-Mails zu versenden.
  6. Verwendung durch Plugins:
  7. email_service = self.application.services.get_service("EmailService")
  8. if email_service:
  9. await email_service.send(to="admin@example.com", subject="Fehler", body="...")
  10. """
  11. from __future__ import annotations
  12. import asyncio
  13. import html as html_module
  14. import re
  15. import smtplib
  16. import socket
  17. import ssl
  18. from email import encoders
  19. from email.mime.base import MIMEBase
  20. from email.mime.text import MIMEText
  21. from email.mime.multipart import MIMEMultipart
  22. from email.utils import formatdate, make_msgid, formataddr
  23. from typing import TYPE_CHECKING, ClassVar
  24. from trixy_core.service.iservice import IService
  25. from trixy_core.service.enums import ServicePriority, ServiceGroup
  26. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  27. if TYPE_CHECKING:
  28. from trixy_core.application import IApplication
  29. # =========================================================================
  30. # Text-Konvertierung
  31. # =========================================================================
  32. def _plain_to_html(text: str) -> str:
  33. """Konvertiert Plain-Text in minimales HTML."""
  34. escaped = html_module.escape(text)
  35. paragraphs = re.split(r"\n{2,}", escaped)
  36. body_parts = []
  37. for p in paragraphs:
  38. lines = p.replace("\n", "<br>\n")
  39. body_parts.append(f"<p>{lines}</p>")
  40. body_html = "\n".join(body_parts)
  41. return (
  42. '<!DOCTYPE html>\n'
  43. '<html lang="de">\n'
  44. '<head><meta charset="utf-8"></head>\n'
  45. '<body style="font-family:sans-serif;font-size:14px;color:#333;">\n'
  46. f'{body_html}\n'
  47. '</body>\n</html>'
  48. )
  49. def _html_to_plain(html_text: str) -> str:
  50. """Konvertiert HTML in Plain-Text (einfach)."""
  51. text = re.sub(r"<br\s*/?>", "\n", html_text, flags=re.IGNORECASE)
  52. text = re.sub(r"</p>", "\n\n", text, flags=re.IGNORECASE)
  53. text = re.sub(r"</div>", "\n", text, flags=re.IGNORECASE)
  54. text = re.sub(r"</li>", "\n", text, flags=re.IGNORECASE)
  55. text = re.sub(r"<[^>]+>", "", text)
  56. text = html_module.unescape(text)
  57. text = re.sub(r"\n{3,}", "\n\n", text)
  58. return text.strip()
  59. # =========================================================================
  60. # PGP-Hilfsklasse
  61. # =========================================================================
  62. class _PGPHelper:
  63. """
  64. Kapselt PGP-Operationen via python-gnupg.
  65. Unterstuetzt:
  66. - PGP/MIME Signierung (RFC 3156, multipart/signed)
  67. - PGP/MIME Verschluesselung (RFC 3156, multipart/encrypted)
  68. - Kombiniert: Signieren + Verschluesseln
  69. """
  70. def __init__(
  71. self,
  72. gpg_home: str,
  73. sign_key: str,
  74. passphrase: str,
  75. always_sign: bool,
  76. always_encrypt: bool,
  77. encrypt_keys: dict[str, str],
  78. ) -> None:
  79. self._gpg = None
  80. self._gpg_home = gpg_home
  81. self._sign_key = sign_key
  82. self._passphrase = passphrase
  83. self._always_sign = always_sign
  84. self._always_encrypt = always_encrypt
  85. # Mapping: E-Mail-Adresse → PGP Key-ID/Fingerprint
  86. self._encrypt_keys = {k.lower(): v for k, v in encrypt_keys.items()}
  87. def initialize(self) -> bool:
  88. """Initialisiert GnuPG. Gibt False zurueck wenn nicht verfuegbar."""
  89. try:
  90. import gnupg
  91. self._gpg = gnupg.GPG(gnupghome=self._gpg_home)
  92. # Pruefen ob der Signaturschluessel vorhanden ist
  93. if self._sign_key:
  94. keys = self._gpg.list_keys(True) # Private Keys
  95. found = any(
  96. self._sign_key.lower() in k.get("fingerprint", "").lower()
  97. or self._sign_key.lower() in k.get("keyid", "").lower()
  98. for k in keys
  99. )
  100. if not found:
  101. pwarn(f"PGP: Signaturschluessel '{self._sign_key}' nicht im Keyring gefunden")
  102. return True
  103. except ImportError:
  104. pwarn("PGP: python-gnupg nicht installiert — PGP deaktiviert")
  105. return False
  106. except Exception as e:
  107. perror(f"PGP: Initialisierung fehlgeschlagen: {e}")
  108. return False
  109. @property
  110. def can_sign(self) -> bool:
  111. """Prueft ob PGP-Signierung moeglich ist."""
  112. return self._gpg is not None and bool(self._sign_key)
  113. @property
  114. def should_sign(self) -> bool:
  115. """Prueft ob standardmaessig signiert werden soll."""
  116. return self._always_sign and self.can_sign
  117. def get_encrypt_key(self, recipient: str) -> str | None:
  118. """Gibt den PGP-Key fuer einen Empfaenger zurueck (oder None)."""
  119. return self._encrypt_keys.get(recipient.lower())
  120. def should_encrypt(self, recipients: list[str]) -> bool:
  121. """Prueft ob fuer alle Empfaenger Schluessel vorhanden sind."""
  122. if not self._always_encrypt or not self._gpg:
  123. return False
  124. return all(self.get_encrypt_key(r) for r in recipients)
  125. def sign_message(self, mime_msg: MIMEMultipart) -> MIMEMultipart:
  126. """
  127. Signiert eine MIME-Nachricht (PGP/MIME, RFC 3156).
  128. Erzeugt multipart/signed mit:
  129. - Original-Nachricht als erster Teil
  130. - PGP-Signatur als zweiter Teil (application/pgp-signature)
  131. """
  132. if not self._gpg or not self._sign_key:
  133. return mime_msg
  134. # Nachricht fuer Signierung vorbereiten (kanonische Form)
  135. payload = mime_msg.as_string().replace("\n", "\r\n")
  136. sig = self._gpg.sign(
  137. payload,
  138. keyid=self._sign_key,
  139. passphrase=self._passphrase,
  140. detach=True,
  141. clearsign=False,
  142. )
  143. if not sig or not sig.data:
  144. perror("PGP: Signierung fehlgeschlagen")
  145. return mime_msg
  146. # multipart/signed Wrapper erstellen
  147. signed_msg = MIMEMultipart(
  148. "signed",
  149. micalg="pgp-sha256",
  150. protocol="application/pgp-signature",
  151. )
  152. # Header vom Original uebernehmen
  153. for key in ("From", "To", "Cc", "Subject", "Date", "Message-ID",
  154. "MIME-Version", "Reply-To", "Organization",
  155. "X-Mailer", "X-Auto-Response-Suppress", "Auto-Submitted"):
  156. value = mime_msg.get(key)
  157. if value:
  158. signed_msg[key] = value
  159. # Individuelle Header uebernehmen (X-Header und andere)
  160. for key, value in mime_msg.items():
  161. if key not in signed_msg:
  162. signed_msg[key] = value
  163. # Original-Nachricht als erster Teil
  164. signed_msg.attach(mime_msg)
  165. # Signatur als zweiter Teil
  166. sig_part = MIMEBase("application", "pgp-signature", name="signature.asc")
  167. sig_part.set_payload(str(sig))
  168. sig_part.add_header("Content-Description", "OpenPGP digital signature")
  169. sig_part.add_header("Content-Disposition", "attachment", filename="signature.asc")
  170. signed_msg.attach(sig_part)
  171. return signed_msg
  172. def encrypt_message(
  173. self, mime_msg: MIMEMultipart, recipients: list[str], sign: bool = False,
  174. ) -> MIMEMultipart:
  175. """
  176. Verschluesselt eine MIME-Nachricht (PGP/MIME, RFC 3156).
  177. Erzeugt multipart/encrypted mit:
  178. - PGP Version-Header als erster Teil
  179. - Verschluesselte Daten als zweiter Teil (application/octet-stream)
  180. """
  181. if not self._gpg:
  182. return mime_msg
  183. # Empfaenger-Keys sammeln
  184. key_ids = []
  185. for r in recipients:
  186. key_id = self.get_encrypt_key(r)
  187. if not key_id:
  188. pwarn(f"PGP: Kein Schluessel fuer '{r}' — Verschluesselung uebersprungen")
  189. return mime_msg
  190. key_ids.append(key_id)
  191. payload = mime_msg.as_string().replace("\n", "\r\n")
  192. encrypt_kwargs: dict = {
  193. "recipients": key_ids,
  194. "armor": True,
  195. "always_trust": True,
  196. }
  197. # Optional gleichzeitig signieren
  198. if sign and self._sign_key:
  199. encrypt_kwargs["sign"] = self._sign_key
  200. encrypt_kwargs["passphrase"] = self._passphrase
  201. encrypted = self._gpg.encrypt(payload, **encrypt_kwargs)
  202. if not encrypted.ok:
  203. perror(f"PGP: Verschluesselung fehlgeschlagen: {encrypted.status}")
  204. return mime_msg
  205. # multipart/encrypted Wrapper erstellen
  206. enc_msg = MIMEMultipart(
  207. "encrypted",
  208. protocol="application/pgp-encrypted",
  209. )
  210. # Header vom Original uebernehmen
  211. for key in ("From", "To", "Cc", "Subject", "Date", "Message-ID",
  212. "MIME-Version", "Reply-To", "Organization",
  213. "X-Mailer", "X-Auto-Response-Suppress", "Auto-Submitted"):
  214. value = mime_msg.get(key)
  215. if value:
  216. enc_msg[key] = value
  217. for key, value in mime_msg.items():
  218. if key not in enc_msg:
  219. enc_msg[key] = value
  220. # Teil 1: PGP Version-Identifikation
  221. version_part = MIMEBase("application", "pgp-encrypted")
  222. version_part.set_payload("Version: 1\n")
  223. version_part.add_header("Content-Description", "PGP/MIME version identification")
  224. enc_msg.attach(version_part)
  225. # Teil 2: Verschluesselte Daten
  226. data_part = MIMEBase("application", "octet-stream", name="encrypted.asc")
  227. data_part.set_payload(str(encrypted))
  228. data_part.add_header("Content-Description", "OpenPGP encrypted message")
  229. data_part.add_header("Content-Disposition", "inline", filename="encrypted.asc")
  230. enc_msg.attach(data_part)
  231. return enc_msg
  232. # =========================================================================
  233. # EmailService
  234. # =========================================================================
  235. class EmailService(IService):
  236. """
  237. Core-E-Mail-Service fuer SMTP-Versand mit optionaler PGP-Unterstuetzung.
  238. Plugins koennen ueber diesen Service E-Mails versenden,
  239. z.B. Fehlerbenachrichtigungen, Bestellungen, Reports.
  240. Jede E-Mail wird als multipart/alternative mit Plain-Text
  241. UND HTML-Version gesendet, um Spam-Filter zu passieren
  242. und maximale Kompatibilitaet zu gewaehrleisten.
  243. PGP-Unterstuetzung (optional, benoetigt python-gnupg + GnuPG):
  244. - Signierung: Alle ausgehenden E-Mails werden mit dem konfigurierten
  245. Schluessel signiert (PGP/MIME, RFC 3156)
  246. - Verschluesselung: E-Mails an Empfaenger mit hinterlegtem PGP-Key
  247. werden verschluesselt (PGP/MIME, RFC 3156)
  248. - Pro Aufruf steuerbar via sign/encrypt Parameter
  249. """
  250. NAME: ClassVar[str] = "EmailService"
  251. PRIORITY: ClassVar[ServicePriority] = ServicePriority.OPTIONAL
  252. GROUP: ClassVar[ServiceGroup] = ServiceGroup.UTILITY
  253. DEPENDENCIES: ClassVar[list[str]] = []
  254. def __init__(self, application: "IApplication", config: dict) -> None:
  255. super().__init__(application)
  256. self._smtp_host = config.get("smtp_host", "")
  257. self._smtp_port = config.get("smtp_port", 587)
  258. self._smtp_user = config.get("smtp_user", "")
  259. self._smtp_password = config.get("smtp_password", "")
  260. self._smtp_ssl = config.get("smtp_ssl", True)
  261. self._smtp_starttls = config.get("smtp_starttls", True)
  262. self._from_address = config.get("from_address", "") or self._smtp_user
  263. self._from_name = config.get("from_name", "Trixy")
  264. self._reply_to = config.get("reply_to", "")
  265. self._organization = config.get("organization", "")
  266. # PGP-Konfiguration
  267. pgp_cfg = config.get("pgp", {})
  268. self._pgp_enabled = pgp_cfg.get("enabled", False)
  269. self._pgp: _PGPHelper | None = None
  270. if self._pgp_enabled:
  271. self._pgp = _PGPHelper(
  272. gpg_home=pgp_cfg.get("gpg_home", "~/.gnupg"),
  273. sign_key=pgp_cfg.get("sign_key", ""),
  274. passphrase=pgp_cfg.get("passphrase", ""),
  275. always_sign=pgp_cfg.get("always_sign", False),
  276. always_encrypt=pgp_cfg.get("always_encrypt", False),
  277. encrypt_keys=pgp_cfg.get("recipient_keys", {}),
  278. )
  279. async def start(self) -> None:
  280. """Startet den EmailService."""
  281. if self._smtp_host:
  282. pinfo(f"EmailService gestartet: {self._smtp_host}:{self._smtp_port}")
  283. else:
  284. pdebug("EmailService gestartet (kein SMTP-Host konfiguriert)")
  285. # PGP initialisieren (im Executor, da GnuPG-Keyring geladen wird)
  286. if self._pgp:
  287. loop = asyncio.get_running_loop()
  288. ok = await loop.run_in_executor(None, self._pgp.initialize)
  289. if ok:
  290. features = []
  291. if self._pgp.can_sign:
  292. features.append("Signierung")
  293. if self._pgp._always_encrypt:
  294. features.append("Verschluesselung")
  295. pinfo(f"PGP aktiviert: {', '.join(features) if features else 'bereit'}")
  296. else:
  297. self._pgp = None
  298. async def stop(self) -> None:
  299. """Stoppt den EmailService."""
  300. pdebug("EmailService gestoppt")
  301. @property
  302. def is_configured(self) -> bool:
  303. """Prueft ob der SMTP-Versand konfiguriert ist."""
  304. return bool(self._smtp_host and self._smtp_user and self._smtp_password)
  305. @property
  306. def pgp_available(self) -> bool:
  307. """Prueft ob PGP verfuegbar und initialisiert ist."""
  308. return self._pgp is not None and self._pgp.can_sign
  309. async def send(
  310. self,
  311. to: str | list[str],
  312. subject: str,
  313. body: str,
  314. html_body: str | None = None,
  315. cc: str | list[str] | None = None,
  316. bcc: str | list[str] | None = None,
  317. reply_to: str | None = None,
  318. headers: dict[str, str] | None = None,
  319. sign: bool | None = None,
  320. encrypt: bool | None = None,
  321. ) -> bool:
  322. """
  323. Versendet eine E-Mail via SMTP.
  324. Die E-Mail wird immer als multipart/alternative mit Plain-Text
  325. und HTML-Version gesendet. Wird nur ``body`` uebergeben, wird
  326. daraus automatisch eine HTML-Version erzeugt. Wird nur
  327. ``html_body`` uebergeben, wird daraus automatisch Plain-Text
  328. extrahiert. Werden beide uebergeben, werden beide direkt verwendet.
  329. Args:
  330. to: Empfaenger-Adresse(n)
  331. subject: Betreff
  332. body: Plain-Text Nachrichtentext
  333. html_body: HTML-Nachrichtentext (optional, wird sonst aus body erzeugt)
  334. cc: CC-Empfaenger (optional)
  335. bcc: BCC-Empfaenger (optional)
  336. reply_to: Reply-To Adresse (optional, ueberschreibt Config-Default)
  337. headers: Zusaetzliche Header als Dict (optional)
  338. sign: PGP-Signierung (None=Config-Default, True=erzwingen, False=deaktivieren)
  339. encrypt: PGP-Verschluesselung (None=Config-Default, True=erzwingen, False=deaktivieren)
  340. Returns:
  341. True bei Erfolg, False bei Fehler
  342. """
  343. if not self.is_configured:
  344. perror("EmailService: SMTP nicht konfiguriert — E-Mail nicht gesendet")
  345. return False
  346. # Empfaenger normalisieren
  347. to_list = [to] if isinstance(to, str) else list(to)
  348. cc_list = [cc] if isinstance(cc, str) else list(cc) if cc else []
  349. bcc_list = [bcc] if isinstance(bcc, str) else list(bcc) if bcc else []
  350. # Plain-Text und HTML sicherstellen
  351. if body and not html_body:
  352. html_body = _plain_to_html(body)
  353. elif html_body and not body:
  354. body = _html_to_plain(html_body)
  355. elif not body and not html_body:
  356. body = ""
  357. html_body = _plain_to_html("")
  358. effective_reply_to = reply_to or self._reply_to
  359. # PGP-Flags bestimmen
  360. do_sign = sign if sign is not None else (self._pgp.should_sign if self._pgp else False)
  361. all_recipients = to_list + cc_list + bcc_list
  362. do_encrypt = encrypt if encrypt is not None else (self._pgp.should_encrypt(all_recipients) if self._pgp else False)
  363. loop = asyncio.get_running_loop()
  364. try:
  365. return await loop.run_in_executor(
  366. None,
  367. self._send_sync,
  368. to_list, cc_list, bcc_list, subject,
  369. body, html_body, effective_reply_to, headers,
  370. do_sign, do_encrypt,
  371. )
  372. except Exception as e:
  373. perror(f"EmailService: Versand fehlgeschlagen: {e}")
  374. return False
  375. def _send_sync(
  376. self,
  377. to_list: list[str],
  378. cc_list: list[str],
  379. bcc_list: list[str],
  380. subject: str,
  381. body: str,
  382. html_body: str,
  383. reply_to: str,
  384. extra_headers: dict[str, str] | None,
  385. do_sign: bool,
  386. do_encrypt: bool,
  387. ) -> bool:
  388. """Versendet eine E-Mail synchron (fuer run_in_executor)."""
  389. # multipart/alternative: Plain-Text + HTML
  390. content = MIMEMultipart("alternative")
  391. content.attach(MIMEText(body, "plain", "utf-8"))
  392. content.attach(MIMEText(html_body, "html", "utf-8"))
  393. # =====================================================================
  394. # Standard-Header (RFC 5322 konform, Spam-Vermeidung)
  395. # =====================================================================
  396. domain = self._from_address.split("@")[-1] if "@" in self._from_address else socket.getfqdn()
  397. content["From"] = formataddr((self._from_name, self._from_address))
  398. content["To"] = ", ".join(to_list)
  399. content["Subject"] = subject
  400. content["Date"] = formatdate(localtime=True)
  401. content["Message-ID"] = make_msgid(domain=domain)
  402. content["MIME-Version"] = "1.0"
  403. if cc_list:
  404. content["Cc"] = ", ".join(cc_list)
  405. if reply_to:
  406. content["Reply-To"] = reply_to
  407. if self._organization:
  408. content["Organization"] = self._organization
  409. # Spam-Reduktion: Kennzeichnung als automatische Mail
  410. content["X-Mailer"] = "Trixy Email-Service"
  411. content["X-Auto-Response-Suppress"] = "OOF, AutoReply"
  412. content["Auto-Submitted"] = "auto-generated"
  413. # =====================================================================
  414. # Individuelle Header
  415. # =====================================================================
  416. if extra_headers:
  417. for key, value in extra_headers.items():
  418. if key in content:
  419. del content[key]
  420. content[key] = value
  421. # =====================================================================
  422. # PGP: Signieren und/oder Verschluesseln
  423. # =====================================================================
  424. msg = content
  425. all_recipients = to_list + cc_list + bcc_list
  426. if self._pgp and do_encrypt:
  427. # Verschluesseln (optional gleichzeitig signieren)
  428. msg = self._pgp.encrypt_message(content, all_recipients, sign=do_sign)
  429. elif self._pgp and do_sign:
  430. # Nur signieren
  431. msg = self._pgp.sign_message(content)
  432. # =====================================================================
  433. # SMTP-Versand
  434. # =====================================================================
  435. try:
  436. if self._smtp_ssl and self._smtp_port == 465:
  437. context = ssl.create_default_context()
  438. with smtplib.SMTP_SSL(self._smtp_host, self._smtp_port, context=context) as server:
  439. server.login(self._smtp_user, self._smtp_password)
  440. server.sendmail(self._from_address, all_recipients, msg.as_string())
  441. else:
  442. with smtplib.SMTP(self._smtp_host, self._smtp_port) as server:
  443. server.ehlo()
  444. if self._smtp_starttls:
  445. context = ssl.create_default_context()
  446. server.starttls(context=context)
  447. server.ehlo()
  448. server.login(self._smtp_user, self._smtp_password)
  449. server.sendmail(self._from_address, all_recipients, msg.as_string())
  450. extras = []
  451. if do_sign:
  452. extras.append("signiert")
  453. if do_encrypt:
  454. extras.append("verschluesselt")
  455. suffix = f" ({', '.join(extras)})" if extras else ""
  456. pdebug(f"EmailService: E-Mail gesendet an {', '.join(to_list)}{suffix}")
  457. return True
  458. except smtplib.SMTPAuthenticationError as e:
  459. perror(f"EmailService: Authentifizierung fehlgeschlagen: {e}")
  460. return False
  461. except smtplib.SMTPException as e:
  462. perror(f"EmailService: SMTP-Fehler: {e}")
  463. return False
  464. except Exception as e:
  465. perror(f"EmailService: Unerwarteter Fehler: {e}")
  466. return False