runv-server

server tooling for runv.club
Log | Files | Refs | README

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))