runv-server

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

configure_mailgun.py (11469B)


      1 
      2 #!/usr/bin/env python3
      3 """
      4 Configurador de email runv — Mailgun HTTP API (predefinido).
      5 
      6 Aviso: este script foi feito para Mailgun. Não pré-configura nenhuma credencial.
      7 
      8 Executar como root. Ver docs/08-email.md no repositório.
      9 """
     10 
     11 from __future__ import annotations
     12 
     13 import argparse
     14 import json
     15 import logging
     16 import os
     17 import sys
     18 import time
     19 from pathlib import Path
     20 from typing import Any
     21 
     22 MODULE_ROOT = Path(__file__).resolve().parent
     23 ADMIN_DIR = MODULE_ROOT.parent / "scripts" / "admin"
     24 if str(ADMIN_DIR) not in sys.path:
     25     sys.path.insert(0, str(ADMIN_DIR))
     26 
     27 from admin_guard import ensure_admin_cli
     28 
     29 STATE_PATH = Path("/etc/runv-email.json")
     30 SECRETS_PATH = Path("/etc/runv-email.secrets.json")
     31 
     32 MAILGUN_API_REGION = "us"
     33 
     34 sys.path.insert(0, str(MODULE_ROOT))
     35 # pyre-ignore[21]
     36 from lib.mailgun_client import build_mailgun_messages_url, mailgun_base_url, mask_secret, validate_mailgun_inputs  # noqa: E402
     37 
     38 
     39 def setup_logging(verbose: bool) -> None:
     40     level = logging.DEBUG if verbose else logging.INFO
     41     logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
     42 
     43 
     44 def log() -> logging.Logger:
     45     return logging.getLogger("runv-email-mailgun")
     46 
     47 
     48 def require_root() -> None:
     49     if os.geteuid() != 0:
     50         print("Execute como root (sudo).", file=sys.stderr)
     51         raise SystemExit(1)
     52 
     53 
     54 def prompt_line(msg: str, default: str = "") -> str:
     55     d = f" [{default}]" if default else ""
     56     r = input(f"{msg}{d}: ").strip()
     57     return r if r else default
     58 
     59 
     60 def prompt_yes_no(msg: str, default_no: bool = True) -> bool:
     61     suf = " [s/N]: " if default_no else " [S/n]: "
     62     r = input(msg + suf).strip().lower()
     63     if not r:
     64         return not default_no
     65     return r in ("s", "sim", "y", "yes")
     66 
     67 
     68 def prompt_api_key_twice() -> str:
     69     print()
     70     print(
     71         "Aviso: a API key será mostrada ao digitar (para confirmar cópia/colar). "
     72         "Evite ecrãs partilhados ou sessões gravadas.",
     73     )
     74     while True:
     75         key = input("Mailgun API key: ").strip()
     76         key2 = input("Repita a mesma API key: ").strip()
     77         if not key:
     78             print("Chave vazia — tente de novo.")
     79             continue
     80         if key != key2:
     81             print("As duas entradas não coincidem — tente de novo.")
     82             continue
     83         return key
     84     raise RuntimeError("Unreachable")
     85 
     86 
     87 def print_mailgun_operator_hints() -> None:
     88     print()
     89     print("Requisitos Mailgun (evita 401 «Invalid private key» / Forbidden):")
     90     print("  • API HTTP: Basic user ``api``, password = **Private API key** ou **domain sending key**")
     91     print("    (não use a password SMTP do painel).")
     92     print("  • **IP allowlist:** se estiver activa no painel Mailgun (API), inclua o **IP público")
     93     print("    deste servidor** (o mesmo que faz os pedidos HTTPS à Mailgun).")
     94     print("  • Envio: ``POST /v3/<domínio>/messages`` — o domínio na URL deve coincidir com o")
     95     print("    domínio verificado no painel (ex.: runv.club).")
     96     print()
     97 
     98 
     99 def interactive_config(*, email_package_root: str) -> tuple[dict[str, Any], dict[str, str]]:
    100     print()
    101     print("=== Configurador de email para Mailgun API ===")
    102     print()
    103     print("Aviso: este script foi feito para Mailgun. Não pré-configura nenhuma credencial.")
    104     print()
    105     print_mailgun_operator_hints()
    106 
    107     print("Tipo de chave Mailgun (recomendado: domain sending key — menor privilégio):")
    108     print("  1) Domain sending key (recomendado)")
    109     print("  2) Primary account API key")
    110     choice = prompt_line("Escolha [1/2]", "1").strip()
    111     api_key_kind = "domain_sending" if choice != "2" else "primary"
    112 
    113     domain = prompt_line("Domínio de envio Mailgun (ex.: mg.exemplo.com ou exemplo.com)")
    114     region = MAILGUN_API_REGION
    115     print()
    116     print(
    117         "Região API HTTP: US — https://api.mailgun.net/ "
    118         "(alinhado ao SMTP smtp.mailgun.org indicado nas credenciais SMTP do painel).",
    119     )
    120     print("Contas Mailgun só na UE: edite depois mailgun_region e api_base_url em /etc/runv-email.json.")
    121     print()
    122 
    123     key = prompt_api_key_twice()
    124     default_from = prompt_line("Remetente padrão (From)")
    125     admin_email = prompt_line("Email do administrador (notificações / teste)")
    126 
    127     validated = validate_mailgun_inputs(
    128         domain=domain,
    129         region=region,
    130         from_addr=default_from,
    131         admin_email=admin_email,
    132         api_key=key,
    133     )
    134 
    135     base = mailgun_base_url(validated["region"])
    136     public: dict[str, Any] = {
    137         "backend": "mailgun",
    138         "provider": "mailgun",
    139         "mailgun_domain": validated["domain"],
    140         "mailgun_region": validated["region"],
    141         "api_base_url": base,
    142         "default_from": validated["from_addr"],
    143         "admin_email": validated["admin_email"],
    144         "api_key_kind": api_key_kind,
    145         "api_key_source": "file",
    146         "secrets_path": str(SECRETS_PATH),
    147         "email_package_root": email_package_root,
    148     }
    149     secrets = {"mailgun_api_key": key}
    150     return public, secrets
    151 
    152 
    153 def write_json_atomic(path: Path, data: dict[str, Any], *, mode: int, dry_run: bool) -> None:
    154     if dry_run:
    155         log().info("[dry-run] escreveria %s (modo %o)", path, mode)
    156         return
    157     path.parent.mkdir(parents=True, exist_ok=True)
    158     text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
    159     tmp = path.with_name(f".{path.name}.{os.getpid()}.{int(time.time())}.tmp")
    160     tmp.write_text(text, encoding="utf-8")
    161     os.chmod(tmp, mode)
    162     try:
    163         os.chown(tmp, 0, 0)
    164     except OSError:
    165         pass
    166     tmp.replace(path)
    167     log().info("Escrito %s (%o)", path, mode)
    168 
    169 
    170 def _print_test_failure_hint(exc: BaseException) -> None:
    171     msg = str(exc).lower()
    172     if "401" not in msg and "403" not in msg and "forbidden" not in msg:
    173         return
    174     print(
    175         "\nDica: com chave e domínio correctos, 401/403 na API Mailgun costuma ser **IP allowlist** "
    176         "no painel — adicione o IP público de **esta máquina** (curl ifconfig.me no servidor).",
    177         file=sys.stderr,
    178     )
    179 
    180 
    181 def run_test_send(*, dry_run: bool) -> None:
    182     pub = json.loads(STATE_PATH.read_text(encoding="utf-8"))
    183     admin = str(pub.get("admin_email", "")).strip()
    184     from_addr = str(pub.get("default_from", "")).strip()
    185     if not admin or not from_addr:
    186         raise ValueError("admin_email ou default_from em falta no estado")
    187 
    188     from lib.mailer import render_template, send_mail  # type: ignore
    189 
    190     body = render_template(
    191         "system_test",
    192         admin_email=admin,
    193         default_from=from_addr,
    194         host=pub.get("mailgun_domain", ""),
    195         api_base_url=pub.get("api_base_url", ""),
    196         timestamp=str(int(time.time())),
    197     )
    198     subj = "[runv.club] Email de teste do sistema (Mailgun API)"
    199     if dry_run:
    200         log().info("[dry-run] enviaria teste via Mailgun API para %s", admin)
    201         return
    202     try:
    203         send_mail(admin, subj, body, from_addr=from_addr, _state=pub)
    204     except Exception as e:
    205         log().debug("detalhe (sem segredos): %s", type(e).__name__)
    206         raise
    207     log().info("Email de teste enviado para %s", admin)
    208 
    209 
    210 def print_summary(public: dict[str, Any], *, dry_run: bool) -> None:
    211     print()
    212     print("=== Resumo ===")
    213     print(f"  provider:        Mailgun API")
    214     print(f"  domain:          {public.get('mailgun_domain', '')}")
    215     print(f"  region:          {public.get('mailgun_region', '')}")
    216     print(f"  api base URL:    {public.get('api_base_url', '')}")
    217     print(f"  messages URL:    {build_mailgun_messages_url(base_url=str(public.get('api_base_url','')), domain=str(public.get('mailgun_domain','')))}")
    218     print(f"  default from:    {public.get('default_from', '')}")
    219     print(f"  admin email:     {public.get('admin_email', '')}")
    220     print(f"  estado (meta):   {STATE_PATH}")
    221     print(f"  segredos:        {SECRETS_PATH} (API key — não partilhar; não impressa aqui)")
    222     print(f"  email_pkg_root:  {public.get('email_package_root', '')}")
    223     if dry_run:
    224         print("  (dry-run — ficheiros não gravados)")
    225     print()
    226     print("Documentação: docs/08-email.md (repositório)")
    227 
    228 
    229 def main() -> int:
    230     parser = argparse.ArgumentParser(
    231         description="Configura envio de email via Mailgun HTTP API (predefinido).",
    232     )
    233     parser.add_argument("--dry-run", action="store_true")
    234     parser.add_argument("--verbose", "-v", action="store_true")
    235     parser.add_argument("--force", "-f", action="store_true", help="sobrescrever sem perguntar")
    236     parser.add_argument(
    237         "--test",
    238         action="store_true",
    239         help="enviar apenas email de teste (requer estado em /etc/runv-email.json)",
    240     )
    241     parser.add_argument(
    242         "--legacy-smtp",
    243         action="store_true",
    244         help="usar o configurador SMTP/msmtp legado (desativado por predefinição)",
    245     )
    246     args = parser.parse_args()
    247     ensure_admin_cli(
    248         script_name=Path(__file__).name,
    249         dry_run=bool(args.dry_run),
    250     )
    251 
    252     if args.legacy_smtp:
    253         import configure_msmtp_legacy as leg  # type: ignore
    254 
    255         argv = [sys.argv[0]] + [sys.argv[i] for i in range(1, len(sys.argv)) if sys.argv[i] != "--legacy-smtp"]
    256         sys.argv = argv
    257         return leg.main()
    258 
    259     setup_logging(args.verbose)
    260     require_root()
    261 
    262     print()
    263     print("Aviso: este script foi feito para Mailgun. Não pré-configura nenhuma credencial.")
    264 
    265     try:
    266         if args.test:
    267             if not STATE_PATH.is_file():
    268                 log().error("Estado não encontrado: %s — execute o configurador primeiro.", STATE_PATH)
    269                 return 1
    270             try:
    271                 run_test_send(dry_run=args.dry_run)
    272             except Exception as e:
    273                 log().error("%s", e)
    274                 _print_test_failure_hint(e)
    275                 return 1
    276             print("Teste concluído.")
    277             return 0
    278 
    279         default_pkg = str(MODULE_ROOT)
    280         root_guess = prompt_line(
    281             "Caminho da pasta `email/` do repositório (importações, ex. entre)",
    282             default_pkg,
    283         ).strip()
    284         if not root_guess:
    285             root_guess = default_pkg
    286         ep_root = str(Path(root_guess).resolve())
    287 
    288         public, secrets = interactive_config(email_package_root=ep_root)
    289 
    290         if STATE_PATH.is_file() and not args.force and not args.dry_run:
    291             if not prompt_yes_no(f"Sobrescrever {STATE_PATH} e segredos?", default_no=True):
    292                 print("Cancelado.")
    293                 return 1
    294 
    295         write_json_atomic(STATE_PATH, public, mode=0o600, dry_run=args.dry_run)
    296         write_json_atomic(SECRETS_PATH, secrets, mode=0o600, dry_run=args.dry_run)
    297 
    298         if not args.dry_run:
    299             log().info("API key armazenada em %s (mascarado: %s)", SECRETS_PATH, mask_secret(secrets["mailgun_api_key"]))
    300 
    301         if not args.dry_run and prompt_yes_no("\nEnviar email de teste agora?", default_no=True):
    302             try:
    303                 run_test_send(dry_run=False)
    304                 log().info("Teste enviado.")
    305             except Exception as e:
    306                 log().warning("Teste falhou: %s", e)
    307                 _print_test_failure_hint(e)
    308 
    309         print_summary(public, dry_run=args.dry_run)
    310         print("Teste posterior: sudo python3 email/configure_mailgun.py --test")
    311         return 0
    312 
    313     except (KeyboardInterrupt, EOFError):
    314         print("\nInterrompido.", file=sys.stderr)
    315         return 130
    316     except Exception as e:
    317         log().error("%s", e)
    318         return 1
    319 
    320 
    321 if __name__ == "__main__":
    322     raise SystemExit(main())