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