commit 3f22625820e04dd53cbf188fdfa0a98936bed2ad
parent 3dabcfef33203c0e0b2f7836e6e18d9469025025
Author: Pablo Murad <pablo@pablomurad.com>
Date: Tue, 19 May 2026 20:55:18 -0300
better mail
Diffstat:
7 files changed, 523 insertions(+), 13 deletions(-)
diff --git a/docs/08-email.md b/docs/08-email.md
@@ -23,26 +23,42 @@
## O que o repo não é
-- **Não** é MTA completo (não recebe correio para caixas locais de membros como produto deste repositório).
+- **Não** substitui a instalação Postfix/Dovecot/Roundcube no servidor (isso é operação de sistema).
+- **Não** usa Mailgun para correio de membros.
+
+## Dois canais de email (não misturar)
+
+| Canal | Função | Config no servidor |
+|-------|--------|-------------------|
+| **Mailgun** | Transacional / admin (`entre`, boas-vindas, avisos) | `/etc/runv-email.json` |
+| **MTA local** | Correio `@runv.club` para membros (caixa, webmail, encaminhamento) | Postfix + Dovecot + Roundcube (fora deste repo) |
+
+O Mailgun **não** deve receber pedidos de alias `username@runv.club → Gmail`. Isso é papel do **Postfix** (ou mapa virtual equivalente) já instalado na VPS.
## Aliases de email para membros
-O email transacional da runv.club (Mailgun, `/etc/runv-email.json`) continua separado deste fluxo.
+O fluxo runv regista pedidos e aprovações em JSON. O encaminhamento real pode ser aplicado no Postfix com `runv-admin-email-alias sync` quando `/etc/runv-member-mail.json` estiver activo (ver abaixo).
-Nesta etapa **não** há mailbox local nem caixa `@runv.club` no servidor. Um membro pode pedir um alias fixo:
+Um membro pode pedir um alias fixo:
`username@runv.club` → email externo de destino
-O alias **não** é activado automaticamente: o membro pede no terminal, um admin aprova, e o registo fica em JSON local. Criar o encaminhamento real no provedor de email (Mailgun, DNS, etc.) continua a ser passo manual ou integração futura.
+Por omissão o membro pede no terminal, o admin aprova, e o registo fica em JSON. Com sync Postfix configurado, o encaminhamento no MTA local pode ser automático após `approve` ou via `sync`.
### Membro
+**Sem `sudo` e sem root.** O membro corre os comandos na própria sessão SSH (conta Unix da comunidade, ex. `pmurad`). O sistema usa o username dessa sessão; contas de operador/admin (ex. `pmurad-admin`) **não** estão em `runv-members` e não podem pedir alias por design.
+
```bash
runv-email-alias request usuario@example.org
runv-email-alias status
runv-email-alias cancel
```
+- `status` lê `/var/lib/runv/email-aliases.json` (modo `640`, grupo `runv-members`).
+- `request` / `cancel` escrevem só na fila `email-alias-queue/` (modo `2770`, mesmo grupo).
+- Aprovação e alteração do JSON activo são sempre **admin** (`runv-admin-email-alias` como root).
+
O alias é sempre `username@runv.club` (username Unix do utilizador que corre o comando). Não é possível escolher outro nome de alias.
### Admin
@@ -51,9 +67,30 @@ O alias é sempre `username@runv.club` (username Unix do utilizador que corre o
sudo runv-admin-email-alias pending
sudo runv-admin-email-alias list
sudo runv-admin-email-alias approve pablo
+sudo runv-admin-email-alias approve pablo --sync-mail
+sudo runv-admin-email-alias sync
sudo runv-admin-email-alias reject pablo --reason "email destino inválido"
```
+### Inventário e sync Postfix (MTA local)
+
+Na VPS, antes de activar sync:
+
+```bash
+sudo python3 scripts/admin/discover_mail_stack.py
+```
+
+Copiar e editar o exemplo:
+
+```bash
+sudo cp email/config/runv-member-mail.example.json /etc/runv-member-mail.json
+# enabled: true após validar postconf virtual_alias_maps
+sudo python3 scripts/admin/sync_member_email_aliases.py --dry-run
+sudo runv-admin-email-alias sync
+```
+
+O sync gera `hash:/etc/postfix/runv-member-aliases` a partir de `/var/lib/runv/email-aliases.json`, corre `postmap` e `systemctl reload postfix`. **Não** altera Mailgun.
+
### Setup inicial no servidor
```bash
@@ -93,11 +130,11 @@ Mais detalhe dos comandos: [17-community-commands.md](17-community-commands.md).
- Não configura SPF/DKIM/DMARC.
- Não configura Postfix/Dovecot.
- Não configura Mailgun para aliases de membros.
-- Não activa encaminhamento real automaticamente.
+- Não configura Roundcube/Dovecot directamente (só mapa virtual Postfix quando sync activo).
### Próximo passo futuro
-Um script como `runv-email-provider-sync` poderá ler `email-aliases.json` e aplicar aliases no provedor real.
+Suporte a backends além de `postfix-hash` (ex. SQL já usado pelo servidor) após mapear o que `discover_mail_stack.py` reportar.
## Testes
diff --git a/docs/17-community-commands.md b/docs/17-community-commands.md
@@ -249,12 +249,16 @@ Permite pedir um alias de email fixo `username@runv.club` que, após aprovação
### Exemplos
+Na sessão SSH do **membro** (sem `sudo`):
+
```bash
runv-email-alias request usuario@example.org
runv-email-alias status
runv-email-alias cancel
```
+Requisito: o utilizador tem de pertencer ao grupo `runv-members` (contas criadas com `create_runv_user.py`). Contas só de administração do servidor não usam este comando.
+
Política, ficheiros em `/var/lib/runv/` e setup: [08-email.md](08-email.md).
---
@@ -271,9 +275,12 @@ Comando **root** para listar pedidos, aprovar ou rejeitar aliases, e actualizar
sudo runv-admin-email-alias pending
sudo runv-admin-email-alias list
sudo runv-admin-email-alias approve pablo
+sudo runv-admin-email-alias sync
sudo runv-admin-email-alias reject pablo --reason "email destino inválido"
```
+Sync Postfix (membros, não Mailgun): [08-email.md](08-email.md) e `scripts/admin/discover_mail_stack.py`.
+
Setup inicial da fila e permissões:
```bash
diff --git a/email/config/runv-member-mail.example.json b/email/config/runv-member-mail.example.json
@@ -0,0 +1,12 @@
+{
+ "enabled": false,
+ "backend": "postfix-hash",
+ "virtual_alias_file": "/etc/postfix/runv-member-aliases",
+ "file_mode": "0o644",
+ "check_maps": true,
+ "run_postmap": true,
+ "postmap_command": ["postmap", "/etc/postfix/runv-member-aliases"],
+ "reload_postfix": true,
+ "reload_command": ["systemctl", "reload", "postfix"],
+ "auto_sync_on_approve": false
+}
diff --git a/scripts/admin/discover_mail_stack.py b/scripts/admin/discover_mail_stack.py
@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+"""
+Inventário read-only do stack de email no servidor (Postfix, Dovecot, Roundcube, vmail).
+
+Não altera configuração. Use na VPS antes de activar sync_member_email_aliases.
+"""
+
+from __future__ import annotations
+
+import argparse
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+
+def run(cmd: list[str], *, timeout: int = 30) -> tuple[int, str]:
+ if shutil.which(cmd[0]) is None:
+ return 127, f"(comando ausente: {cmd[0]})"
+ try:
+ proc = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+ out = (proc.stdout or "") + (proc.stderr or "")
+ return proc.returncode, out.strip()
+ except subprocess.TimeoutExpired:
+ return 124, "(timeout)"
+ except OSError as e:
+ return 1, str(e)
+
+
+def section(title: str) -> None:
+ print(f"\n=== {title} ===")
+
+
+def main() -> int:
+ p = argparse.ArgumentParser(description="Inventário do stack de email (read-only)")
+ p.parse_args()
+
+ if sys.platform == "win32":
+ print("Execute este script na VPS Linux (Debian).", file=sys.stderr)
+ return 2
+
+ section("Pacotes Debian (dpkg)")
+ code, out = run(
+ [
+ "dpkg-query",
+ "-W",
+ "-f=${Package}\t${Status}\n",
+ "postfix",
+ "dovecot-core",
+ "dovecot-imapd",
+ "roundcube-core",
+ "roundcube",
+ "postfix-mysql",
+ "postfix-ldap",
+ ],
+ )
+ if code == 127:
+ code, out = run(["dpkg", "-l"])
+ if code == 0:
+ for line in out.splitlines():
+ low = line.lower()
+ if any(
+ k in low
+ for k in (
+ "postfix",
+ "dovecot",
+ "roundcube",
+ "rspamd",
+ "clamav",
+ "spamassassin",
+ )
+ ):
+ print(line)
+ else:
+ print(out)
+ else:
+ print(out or "(nenhum pacote listado com esses nomes exactos)")
+
+ section("Serviços (systemctl is-active)")
+ for unit in (
+ "postfix",
+ "dovecot",
+ "apache2",
+ "nginx",
+ "php8.2-fpm",
+ "php8.3-fpm",
+ ):
+ code, out = run(["systemctl", "is-active", unit])
+ if code != 127:
+ print(f"{unit}: {out}")
+
+ section("Postfix (postconf)")
+ for key in (
+ "myhostname",
+ "mydomain",
+ "virtual_mailbox_domains",
+ "virtual_mailbox_maps",
+ "virtual_alias_maps",
+ "relay_domains",
+ "transport_maps",
+ ):
+ code, out = run(["postconf", "-h", key])
+ if code == 127:
+ print("postconf não instalado")
+ break
+ print(f"{key} = {out}")
+
+ section("Ficheiros comuns")
+ paths = [
+ "/etc/postfix/main.cf",
+ "/etc/postfix/virtual",
+ "/etc/postfix/virtual.db",
+ "/etc/postfix/mysql-virtual-alias-maps.cf",
+ "/etc/dovecot/dovecot.conf",
+ "/var/vmail",
+ "/etc/roundcube",
+ "/usr/share/roundcube",
+ "/var/www/roundcube",
+ "/etc/runv-email.json",
+ "/etc/runv-member-mail.json",
+ "/var/lib/runv/email-aliases.json",
+ ]
+ for path in paths:
+ pth = Path(path)
+ if pth.is_dir():
+ print(f"{path}/ [dir]")
+ elif pth.is_file():
+ print(f"{path} [file]")
+ else:
+ print(f"{path} (ausente)")
+
+ section("RunV aliases de membros")
+ aliases = Path("/var/lib/runv/email-aliases.json")
+ if aliases.is_file():
+ print(f"{aliases} existe ({aliases.stat().st_size} bytes)")
+ else:
+ print(f"{aliases} ausente")
+
+ cfg = Path("/etc/runv-member-mail.json")
+ if cfg.is_file():
+ print(f"sync MTA configurado: {cfg}")
+ else:
+ print(
+ f"sync MTA não configurado ({cfg} ausente). "
+ "Ver email/config/runv-member-mail.example.json"
+ )
+
+ section("Mailgun transacional (só leitura de paths)")
+ for path in ("/etc/runv-email.json", "/etc/runv-email.secrets.json"):
+ pth = Path(path)
+ print(f"{path}: {'presente' if pth.is_file() else 'ausente'}")
+
+ print(
+ "\nPróximo passo: alinhar virtual_alias_maps com hash:/etc/postfix/runv-member-aliases "
+ "e activar /etc/runv-member-mail.json; depois: runv-admin-email-alias sync"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/admin/sync_member_email_aliases.py b/scripts/admin/sync_member_email_aliases.py
@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+"""Aplica email-aliases.json no Postfix local (hash). Requer /etc/runv-member-mail.json."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+from pathlib import Path
+
+_SCRIPT_DIR = Path(__file__).resolve().parent
+_REPO_TOOLS_LIB = _SCRIPT_DIR.parent.parent / "tools" / "lib"
+if str(_REPO_TOOLS_LIB) not in sys.path:
+ sys.path.insert(0, str(_REPO_TOOLS_LIB))
+
+import runv_mail_sync as ms # noqa: E402
+
+
+def require_root(dry_run: bool) -> None:
+ if dry_run:
+ return
+ geteuid = getattr(os, "geteuid", None)
+ if geteuid is None or geteuid() != 0:
+ print("este script precisa ser executado como root (ou use --dry-run).", file=sys.stderr)
+ raise SystemExit(1)
+
+
+def main() -> int:
+ p = argparse.ArgumentParser(
+ description="Sincroniza aliases aprovados com Postfix (MTA local, não Mailgun)."
+ )
+ p.add_argument("--dry-run", action="store_true", help="simular escrita e comandos")
+ p.add_argument(
+ "--config",
+ default="",
+ help=f"ficheiro JSON (predefinição: {ms.DEFAULT_CONFIG_PATH})",
+ )
+ args = p.parse_args()
+ require_root(args.dry_run)
+
+ cfg = None
+ if args.config.strip():
+ cfg = ms.load_config(Path(args.config.strip()))
+
+ return ms.sync_postfix_hash(dry_run=args.dry_run, cfg=cfg)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/bin/runv-admin-email-alias b/tools/bin/runv-admin-email-alias
@@ -25,6 +25,11 @@ def _bootstrap() -> None:
_bootstrap()
import runv_email_aliases as ea # noqa: E402
+try:
+ import runv_mail_sync as mail_sync # noqa: E402
+except ImportError:
+ mail_sync = None # type: ignore[assignment]
+
def cmd_pending() -> int:
pending = ea.iter_pending_requests()
@@ -53,7 +58,7 @@ def cmd_list() -> int:
return 0
-def cmd_approve(username: str) -> int:
+def cmd_approve(username: str, *, sync_mail: bool) -> int:
user = ea.validate_alias_username(username)
operator = ea.admin_operator()
entry = ea.approve_pending(user, operator)
@@ -62,11 +67,41 @@ def cmd_approve(username: str) -> int:
print(f" {entry.get('alias')}\n")
print("Destino:")
print(f" {entry.get('destination')}\n")
- print("Próximo passo manual:")
- print(
- " criar/atualizar o encaminhamento real no provedor de email:\n"
- f" {entry.get('alias')} -> {entry.get('destination')}"
- )
+ if sync_mail and mail_sync is not None:
+ try:
+ mail_sync.sync_postfix_hash()
+ print("Encaminhamento aplicado no Postfix (sync OK).\n")
+ except SystemExit:
+ print(
+ "Aviso: sync Postfix falhou; registo JSON está activo.\n"
+ " Corrija /etc/runv-member-mail.json ou execute:\n"
+ " sudo runv-admin-email-alias sync\n",
+ file=sys.stderr,
+ )
+ elif mail_sync is not None and mail_sync.is_sync_enabled():
+ print(
+ "Próximo passo MTA:\n"
+ " sudo runv-admin-email-alias sync\n"
+ " (ou active auto_sync_on_approve em /etc/runv-member-mail.json)\n"
+ )
+ else:
+ print("Próximo passo manual (MTA):")
+ print(
+ " configurar encaminhamento no Postfix/Dovecot ou\n"
+ " copiar email/config/runv-member-mail.example.json para\n"
+ " /etc/runv-member-mail.json e executar: runv-admin-email-alias sync\n"
+ )
+ print(
+ f" destino: {entry.get('alias')} -> {entry.get('destination')}\n"
+ )
+ return 0
+
+
+def cmd_sync() -> int:
+ if mail_sync is None:
+ ea.rc.friendly_exit("módulo runv_mail_sync não encontrado.")
+ mail_sync.sync_postfix_hash()
+ print("Sync Postfix concluído.")
return 0
@@ -94,6 +129,20 @@ def build_parser() -> argparse.ArgumentParser:
sub.add_parser("list", help="listar aliases activos")
appr = sub.add_parser("approve", help="aprovar pedido pendente mais recente")
appr.add_argument("user", metavar="USER", help="nome de utilizador Unix")
+ appr.add_argument(
+ "--sync-mail",
+ action="store_true",
+ help="aplicar imediatamente no Postfix (requer /etc/runv-member-mail.json enabled)",
+ )
+ appr.add_argument(
+ "--no-sync-mail",
+ action="store_true",
+ help="não tentar sync Postfix mesmo com auto_sync_on_approve",
+ )
+ sub.add_parser(
+ "sync",
+ help="sincronizar aliases activos com Postfix (hash runv-member-aliases)",
+ )
rej = sub.add_parser("reject", help="rejeitar pedido pendente")
rej.add_argument("user", metavar="USER", help="nome de utilizador Unix")
rej.add_argument(
@@ -113,7 +162,18 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "list":
return cmd_list()
if args.command == "approve":
- return cmd_approve(args.user)
+ do_sync = bool(args.sync_mail)
+ if (
+ not args.no_sync_mail
+ and not do_sync
+ and mail_sync is not None
+ and mail_sync.is_sync_enabled()
+ ):
+ cfg = mail_sync.load_config()
+ do_sync = bool(cfg.get("auto_sync_on_approve"))
+ return cmd_approve(args.user, sync_mail=do_sync)
+ if args.command == "sync":
+ return cmd_sync()
if args.command == "reject":
return cmd_reject(args.user, args.reason)
return 1
diff --git a/tools/lib/runv_mail_sync.py b/tools/lib/runv_mail_sync.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+"""
+Sincroniza aliases aprovados (email-aliases.json) com o MTA local (Postfix hash).
+
+Separado do Mailgun transacional (/etc/runv-email.json). Requer configuração
+explícita em /etc/runv-member-mail.json no servidor.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+from typing import Any
+
+import runv_community as rc
+
+DEFAULT_CONFIG_PATH = Path("/etc/runv-member-mail.json")
+
+try:
+ import runv_email_aliases as ea
+except ImportError: # pragma: no cover
+ ea = None # type: ignore[assignment]
+
+
+def config_path() -> Path:
+ raw = os.environ.get("RUNV_MEMBER_MAIL_CONFIG", "").strip()
+ return Path(raw) if raw else DEFAULT_CONFIG_PATH
+
+
+def load_config(path: Path | None = None) -> dict[str, Any]:
+ cfg_path = path or config_path()
+ if not cfg_path.is_file():
+ return {}
+ try:
+ data = json.loads(cfg_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as e:
+ rc.friendly_exit(f"config inválida em {cfg_path}: {e}")
+ if not isinstance(data, dict):
+ rc.friendly_exit(f"config inválida em {cfg_path}: esperado objecto JSON.")
+ return data
+
+
+def is_sync_enabled(cfg: dict[str, Any] | None = None) -> bool:
+ data = cfg if cfg is not None else load_config()
+ return bool(data.get("enabled"))
+
+
+def active_forwarding_rows() -> list[tuple[str, str]]:
+ if ea is None:
+ rc.friendly_exit("módulo runv_email_aliases indisponível.")
+ rows: list[tuple[str, str]] = []
+ for username, alias, dest in ea.list_active_aliases():
+ _ = username
+ rows.append((alias.lower(), dest.lower()))
+ rows.sort(key=lambda r: r[0])
+ return rows
+
+
+def render_postfix_virtual(rows: list[tuple[str, str]]) -> str:
+ lines = [
+ "# Gerado por runv — não editar à mão; use runv-admin-email-alias sync",
+ "# Formato: alias@dominio destino@externo",
+ ]
+ for alias, dest in rows:
+ lines.append(f"{alias}\t{dest}")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def _run_cmd(cmd: list[str], *, dry_run: bool) -> None:
+ if dry_run:
+ print(f"[dry-run] {' '.join(cmd)}")
+ return
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
+ if proc.returncode != 0:
+ err = (proc.stderr or proc.stdout or "").strip()
+ rc.friendly_exit(f"comando falhou ({' '.join(cmd)}): {err}")
+
+
+def _atomic_write(path: Path, content: str, *, mode: int, dry_run: bool) -> None:
+ if dry_run:
+ print(f"[dry-run] escrever {path} ({len(content)} bytes, mode {oct(mode)})")
+ return
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp_name = tempfile.mkstemp(
+ prefix=f".{path.name}.",
+ suffix=".tmp",
+ dir=str(path.parent),
+ )
+ tmp_path = Path(tmp_name)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as out:
+ out.write(content)
+ out.flush()
+ os.fsync(out.fileno())
+ os.chmod(tmp_path, mode)
+ os.replace(tmp_path, path)
+ except Exception:
+ tmp_path.unlink(missing_ok=True)
+ raise
+
+
+def check_postfix_maps_include(target: Path, *, dry_run: bool) -> None:
+ proc = subprocess.run(
+ ["postconf", "-h", "virtual_alias_maps"],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if proc.returncode != 0:
+ print(
+ "aviso: postconf falhou; confirme manualmente que virtual_alias_maps inclui "
+ f"hash:{target}",
+ file=sys.stderr,
+ )
+ return
+ maps = (proc.stdout or "").strip()
+ needle = f"hash:{target}"
+ if needle in maps.replace(" ", ""):
+ return
+ print(
+ f"aviso: virtual_alias_maps actual não referencia {needle}\n"
+ f" actual: {maps or '(vazio)'}\n"
+ " adicione (exemplo):\n"
+ f' postconf -e "virtual_alias_maps = ${{virtual_alias_maps}}, hash:{target}"\n'
+ " ou inclua o ficheiro na configuração existente (MySQL/LDAP/etc.).",
+ file=sys.stderr,
+ )
+
+
+def sync_postfix_hash(*, dry_run: bool = False, cfg: dict[str, Any] | None = None) -> int:
+ data = cfg if cfg is not None else load_config()
+ if not data.get("enabled"):
+ rc.friendly_exit(
+ f"sincronização desactivada; defina enabled=true em {config_path()}"
+ )
+ backend = str(data.get("backend", "postfix-hash")).strip().lower()
+ if backend != "postfix-hash":
+ rc.friendly_exit(f"backend não suportado: {backend!r}")
+
+ target = Path(str(data.get("virtual_alias_file", "/etc/postfix/runv-member-aliases")))
+ file_mode = int(str(data.get("file_mode", "0o644")), 8)
+ rows = active_forwarding_rows()
+ body = render_postfix_virtual(rows)
+
+ _atomic_write(target, body, mode=file_mode, dry_run=dry_run)
+ print(f"mapa Postfix: {target} ({len(rows)} alias(es) activo(s))")
+
+ if data.get("check_maps", True):
+ check_postfix_maps_include(target, dry_run=dry_run)
+
+ if data.get("run_postmap", True):
+ postmap = data.get("postmap_command")
+ if isinstance(postmap, list) and postmap:
+ cmd = [str(x) for x in postmap]
+ else:
+ cmd = ["postmap", str(target)]
+ _run_cmd(cmd, dry_run=dry_run)
+
+ if data.get("reload_postfix", True):
+ reload_cmd = data.get("reload_command")
+ if isinstance(reload_cmd, list) and reload_cmd:
+ cmd = [str(x) for x in reload_cmd]
+ else:
+ cmd = ["systemctl", "reload", "postfix"]
+ _run_cmd(cmd, dry_run=dry_run)
+
+ return 0
+
+
+def maybe_sync_after_approve(*, dry_run: bool = False) -> None:
+ cfg = load_config()
+ if not cfg.get("enabled") or not cfg.get("auto_sync_on_approve"):
+ return
+ sync_postfix_hash(dry_run=dry_run, cfg=cfg)