mailer.py (6612B)
1 #!/usr/bin/env python3 2 """ 3 Envio de correio: Mailgun por HTTP por defeito; se não houver estado, cai para sendmail/msmtp. 4 5 Stdlib só; nada de shell=True. 6 """ 7 8 from __future__ import annotations 9 10 import json 11 import logging 12 import os 13 import subprocess 14 from email.message import EmailMessage 15 from email.utils import formataddr 16 from pathlib import Path 17 from typing import Mapping, Sequence 18 19 from .mailgun_client import ( # type: ignore 20 MailgunHTTPError, 21 build_mailgun_runtime_config, 22 format_mailgun_failure, 23 load_public_state, 24 send_via_mailgun_api, 25 state_path, 26 ) 27 28 _DEFAULT_SENDMAIL = "/usr/sbin/sendmail" 29 _LOG = logging.getLogger("runv.mailer") 30 31 32 def _email_root() -> Path: 33 env = os.environ.get("RUNV_EMAIL_ROOT", "").strip() 34 if env: 35 return Path(env).resolve() 36 return Path(__file__).resolve().parents[1] 37 38 39 def templates_dir() -> Path: 40 return _email_root() / "templates" 41 42 43 def render_template(name: str, **kwargs: object) -> str: 44 """Lê ``templates/<name>.txt`` e faz ``.format``. Placeholder sem valor fica lá à mostra.""" 45 base = name.removesuffix(".txt") 46 path = templates_dir() / f"{base}.txt" 47 if not path.is_file(): 48 raise FileNotFoundError(f"template de email não encontrado: {path}") 49 text = path.read_text(encoding="utf-8") 50 str_kw = {k: str(v) for k, v in kwargs.items()} 51 try: 52 return text.format(**str_kw) 53 except KeyError as e: 54 raise KeyError(f"placeholder em falta no template {name}: {e}") from e 55 56 57 def _resolve_backend( 58 injected: dict | None, 59 *, 60 sendmail: str | None, 61 ) -> tuple[str, dict]: 62 """Tuple (mailgun|sendmail, dict lido de runv-email.json ou injectado).""" 63 if injected is not None: 64 state = injected 65 else: 66 sp = state_path() 67 if not sp.is_file(): 68 return "sendmail", {} 69 state = json.loads(sp.read_text(encoding="utf-8")) 70 71 be = str(state.get("backend") or "").strip().lower() 72 if be == "mailgun": 73 return "mailgun", state 74 if be == "sendmail": 75 return "sendmail", state 76 if state.get("smtp_host"): # json velho do configure_msmtp 77 return "sendmail", state 78 if state.get("mailgun_domain") and state.get("mailgun_region"): # mailgun sem campo backend 79 return "mailgun", state 80 return "sendmail", state 81 82 83 def send_mail( 84 to_addrs: str | Sequence[str], 85 subject: str, 86 body: str, 87 *, 88 from_addr: str, 89 sendmail: str | None = None, 90 html: str | None = None, 91 headers: Mapping[str, str] | None = None, 92 timeout: int = 120, 93 _state: dict | None = None, 94 ) -> None: 95 """Mailgun se o estado pedir; senão ``sendmail -t -i``. ``html`` só interessa mesmo no ramo Mailgun.""" 96 sm_path = sendmail if sendmail is not None else _DEFAULT_SENDMAIL 97 backend, st = _resolve_backend(_state, sendmail=sendmail) 98 99 if backend == "mailgun": 100 try: 101 pub = st 102 if not pub: 103 pub = load_public_state() 104 cfg = build_mailgun_runtime_config(pub) 105 except FileNotFoundError: 106 raise 107 except Exception as e: 108 raise RuntimeError(f"configuração Mailgun inválida: {e}") from e 109 110 try: 111 code, _raw = send_via_mailgun_api( 112 base_url=cfg["api_base_url"], 113 domain=cfg["domain"], 114 api_key=cfg["api_key"], 115 from_addr=from_addr, 116 to_addrs=to_addrs, 117 subject=subject, 118 text=body, 119 html=html, 120 timeout=timeout, 121 ) 122 _LOG.debug("mailgun envio OK status=%s", code) 123 except MailgunHTTPError as e: 124 msg = format_mailgun_failure(e.status, e.body_snippet) 125 raise RuntimeError(msg) from e 126 return 127 128 # --- sendmail --- 129 sm = Path(sm_path) 130 if not sm.is_file(): 131 raise FileNotFoundError( 132 f"sendmail não encontrado: {sm_path} " 133 f"(modo legado). Configure Mailgun com configure_mailgun.py ou instale msmtp-mta.", 134 ) 135 136 if isinstance(to_addrs, str): 137 recipients = [to_addrs.strip()] 138 else: 139 recipients = [a.strip() for a in to_addrs if a and str(a).strip()] 140 141 if not recipients: 142 raise ValueError("lista de destinatários vazia") 143 144 msg = EmailMessage() 145 msg["Subject"] = subject 146 msg["From"] = from_addr 147 msg["To"] = ", ".join(recipients) 148 if headers: 149 for k, v in headers.items(): 150 if k.lower() in ("subject", "from", "to", "bcc", "cc"): 151 continue 152 msg[k] = v 153 msg.set_content(body, subtype="plain", charset="utf-8") 154 if html: 155 msg.add_alternative(html, subtype="html", charset="utf-8") 156 157 try: 158 proc = subprocess.run( 159 [str(sm), "-t", "-i"], 160 input=msg.as_bytes(), 161 capture_output=True, 162 timeout=timeout, 163 ) 164 except OSError as e: 165 raise RuntimeError(f"erro ao executar sendmail: {e}") from e 166 except subprocess.TimeoutExpired as e: 167 raise RuntimeError("timeout ao executar sendmail") from e 168 169 if proc.returncode != 0: 170 err = (proc.stderr or b"").decode("utf-8", errors="replace").strip() 171 raise RuntimeError( 172 f"sendmail falhou (código {proc.returncode})" + (f": {err}" if err else "") 173 ) 174 175 176 def send_admin_notice( 177 template_name: str, 178 admin_email: str, 179 *, 180 subject: str, 181 from_addr: str, 182 sendmail: str | None = None, 183 html_body: str | None = None, 184 _state: dict | None = None, 185 **kwargs: object, 186 ) -> None: 187 """Renderiza template administrativo e envia para admin_email.""" 188 body = render_template(template_name, **kwargs) 189 send_mail( 190 admin_email, 191 subject, 192 body, 193 from_addr=from_addr, 194 sendmail=sendmail, 195 html=html_body, 196 _state=_state, 197 ) 198 199 200 def send_user_notice( 201 template_name: str, 202 user_email: str, 203 *, 204 subject: str, 205 from_addr: str, 206 sendmail: str | None = None, 207 html_body: str | None = None, 208 _state: dict | None = None, 209 **kwargs: object, 210 ) -> None: 211 """Renderiza template para utilizador e envia para user_email.""" 212 body = render_template(template_name, **kwargs) 213 send_mail( 214 user_email, 215 subject, 216 body, 217 from_addr=from_addr, 218 sendmail=sendmail, 219 html=html_body, 220 _state=_state, 221 ) 222 223 224 def format_from_display(name: str, addr: str) -> str: 225 """Cabeçalho From com nome amigável (opcional).""" 226 return formataddr((name, addr))