# nocheck: comments-valid explanatory WHY-comments in this SPOT lookup predate the stricter lint from __future__ import annotations from pathlib import Path from typing import Any from ansible.errors import AnsibleError from ansible.plugins.loader import lookup_loader from ansible.plugins.lookup import LookupBase from plugins.lookup.applications import LookupModule as ApplicationsLookup from plugins.lookup.domain import LookupModule as DomainLookup from plugins.lookup.users import LookupModule as UsersLookup from utils.domains.primary_domain import get_domain from utils.mail.provider import deployed_roles, resolve_active_provider from utils.roles.entity.name import get_entity_name SYSTEM_EMAIL_PREFIX = "SYSTEM_EMAIL_" # Default mail provider role; overridable per inventory via the MAIL_PROVIDER var. DEFAULT_MAIL_PROVIDER = "web-app-stalwart" # Declared in resolution order so each computed default sees already-resolved # predecessors (for example ``port`` depends on ``tls``). RESOLUTION_ORDER = ( "enabled", "timeout", "external", "environment", "domain", "host", "tls", "port", "auth", "auth_mechanism", "start_tls", "smtp", "from", "username", "password", ) _TRUE_STRINGS = frozenset({"true", "yes", "1", "on"}) _FALSE_STRINGS = frozenset({"false", "no", "0", "off"}) def _short_to_var(short_key: str) -> str: return SYSTEM_EMAIL_PREFIX + short_key.upper() def _as_bool(value: Any) -> bool: if isinstance(value, bool): return value if isinstance(value, (int, float)): return bool(value) if isinstance(value, str): lowered = value.strip().lower() if lowered in _TRUE_STRINGS: return True if lowered in _FALSE_STRINGS: return False return bool(value) def _render( value: Any, templar: Any | None, variables: dict[str, Any] | None = None, ) -> Any: if templar is None: return value if not isinstance(value, str) or "{{" not in value: return value # Loop: inventory expressions like DOMAIN_PRIMARY="{{ lookup('env','INFINITO_DOMAIN') }}" need multiple substitution passes. from utils.templating.ansible import _templar_render_best_effort base_vars: dict[str, Any] = dict(variables or {}) for _ in range(4): if not isinstance(value, str) or "{{" not in value: break prev = value try: value = _templar_render_best_effort(templar, value, base_vars) except Exception: break if value == prev: break return value class LookupModule(LookupBase): def run( self, terms: list[Any] | None, variables: dict[str, Any] | None = None, **kwargs: Any, ) -> list[Any]: terms = terms or [] if len(terms) > 1: raise AnsibleError( "email: expected 0 or 1 terms: lookup('email'[, application_id])" ) variables = variables or getattr(self._templar, "available_variables", {}) or {} self._kwargs = kwargs templar = getattr(self, "_templar", None) resolved: dict[str, Any] = {} for short_key in RESOLUTION_ORDER: var_name = _short_to_var(short_key) raw = variables.get(var_name) if raw is None or raw == "": resolved[short_key] = _render( self._compute(short_key, resolved, variables), templar, variables, ) else: resolved[short_key] = _render(raw, templar, variables) if len(terms) == 0: return [resolved] application_id = str(terms[0]).strip() overrides = self._app_email_overrides(application_id, variables) merged = dict(resolved) for key, value in overrides.items(): merged[str(key).lower()] = _render(value, templar, variables) return [merged] def _compute( self, short_key: str, resolved: dict[str, Any], variables: dict[str, Any], ) -> Any: if short_key == "enabled": return True if short_key == "timeout": return "30" if short_key == "external": # Cluster-wide, not per-host: in swarm the mail provider is # manager-pinned so worker group_names lack it, yet every node # relays through the central provider (routing mesh). Gate on the # group having a host so workers use the authenticated config, not # a localhost root sender the provider rejects # ("Sender address rejected: Domain not found"). groups = variables.get("groups") or {} return bool(groups.get(self._mail_provider(variables))) if short_key == "environment": external = _as_bool(resolved.get("external")) tls_enabled = _as_bool(variables.get("TLS_ENABLED")) docker_in_container = _as_bool(variables.get("DOCKER_IN_CONTAINER")) base = "external" if (external or tls_enabled) else "localhost" suffix = "_container" if (docker_in_container and not tls_enabled) else "" return base + suffix if short_key == "domain": return variables.get("DOMAIN_PRIMARY") if short_key == "tls": external = _as_bool(resolved.get("external")) if not external: return False mail_host = str(resolved.get("host") or "").lower() if mail_host.endswith(".onion") or self._provider_onion_primary(variables): return False return _as_bool(variables.get("TLS_ENABLED")) if short_key == "port": external = _as_bool(resolved.get("external")) if not external: return 25 ports = self._provider_ports(variables) if _as_bool(resolved.get("tls")): return ports.get("smtps", 465) # Plaintext submission is the onion/no-TLS path, but a provider is # only reachable there if it binds one. Stalwart's default config # binds the implicit-TLS client ports plus the MX, so it advertises # no `submission`; fall back to the MX port rather than a listener # nothing answers on (`auth` drops with it -- an MX takes local # recipients without SMTP AUTH). return ports.get("submission") or ports.get("smtp", 25) if short_key == "host": env = resolved.get("environment") # `environment` folds TLS_ENABLED into its "external" base, so a # non-external relay under TLS would otherwise resolve to the mailu # domain while auth/tls/from stay in localhost mode -- an unreachable, # inconsistent render that aborts the msmtp health unit. Gate host on # `external` directly, like tls/auth/from do. if not _as_bool(resolved.get("external")) or env in ( "external_container", "localhost", "localhost_container", ): return "localhost" return self._lookup_mail_provider_domain(variables) if short_key == "auth": env = resolved.get("environment") if env in ("external_container", "localhost"): return False # The MX listener offers no SMTP AUTH: when `port` fell back to it # because the provider binds no plaintext submission, announcing # AUTH aborts msmtp with EX_UNAVAILABLE before it ever sends. ports = self._provider_ports(variables) if not ports.get("submission") and resolved.get("port") == ports.get( "smtp", 25 ): return False return _as_bool(resolved.get("external")) if short_key == "auth_mechanism": if not _as_bool(resolved.get("auth")): return "off" return "on" if _as_bool(resolved.get("tls")) else "plain" if short_key == "start_tls": return False if short_key == "smtp": return True if short_key == "from": external = _as_bool(resolved.get("external")) if external: no_reply = self._lookup_no_reply_user(variables) if isinstance(no_reply, dict): email = no_reply.get("email") if email: return email inventory_hostname = variables.get("inventory_hostname") or "localhost" return f"root@{inventory_hostname}.localdomain" if short_key == "username": return resolved.get("from") if short_key == "password": no_reply = self._lookup_no_reply_user(variables) if isinstance(no_reply, dict): tokens = no_reply.get("tokens") or {} if isinstance(tokens, dict): return tokens.get(self._mail_provider(variables), "") or "" return "" raise AnsibleError(f"email: unknown key {short_key!r}") def _mail_provider(self, variables: dict[str, Any]) -> str: value = variables.get("MAIL_PROVIDER") configured = str(value).strip() if value else DEFAULT_MAIL_PROVIDER return resolve_active_provider( configured, deployed_roles(variables.get("groups")), Path.cwd() / "roles", ) def _provider_services(self, variables: dict[str, Any]) -> dict[str, Any]: """The active provider's ``services`` block, or ``{}`` when it cannot be resolved. The port probe reads the provider's own self-declaration.""" apps = ApplicationsLookup() apps._templar = getattr(self, "_templar", None) forwarded = { k: v for k, v in getattr(self, "_kwargs", {}).items() if k == "roles_dir" } try: entry = apps.run( [self._mail_provider(variables), {}], variables=variables, **forwarded, )[0] except AnsibleError: return {} if not isinstance(entry, dict): return {} services = entry.get("services") or {} return services if isinstance(services, dict) else {} def _provider_ports(self, variables: dict[str, Any]) -> dict[str, int]: """The SMTP ports the active provider publishes, keyed by protocol. Read from the provider's own ``services..ports.public`` rather than assumed, so a provider that binds no plaintext ``submission`` (Stalwart) resolves differently from one that does (Mailu) without either being named here. """ services = self._provider_services(variables) entity = get_entity_name(self._mail_provider(variables)) entry = services.get(entity) or {} if not isinstance(entry, dict): return {} ports = (entry.get("ports") or {}).get("public") or {} if not isinstance(ports, dict): return {} out: dict[str, int] = {} for name, value in ports.items(): try: out[str(name)] = int(value) except (TypeError, ValueError): continue return out def _provider_onion_primary(self, variables: dict[str, Any]) -> bool: """True when the provider's own host serves it onion-first. Resolved from the provider host's ``group_names`` rather than this host's: in swarm only the manager is in ``svc-net-tor``, and the provider's ``tor.enabled`` renders against ``group_names``, so a worker would otherwise see the clearnet name the provider never serves a certificate for. """ groups = variables.get("groups") or {} provider = self._mail_provider(variables) tor_hosts = set(groups.get("svc-net-tor") or []) provider_hosts = [h for h in groups.get(provider) or [] if h in tor_hosts] if not provider_hosts: return False own_groups = list(variables.get("group_names") or []) fallback = own_groups + [ g for g in ("svc-net-tor", provider) if g not in own_groups ] try: provider_groups = variables["hostvars"][provider_hosts[0]]["group_names"] except (KeyError, TypeError): provider_groups = fallback provider_view = dict(variables) provider_view["group_names"] = list(provider_groups or fallback) forwarded = { k: v for k, v in getattr(self, "_kwargs", {}).items() if k == "roles_dir" } try: domains = lookup_loader.get( "domains", loader=getattr(self, "_loader", None), templar=getattr(self, "_templar", None), ).run([], variables=provider_view, **forwarded)[0] domain = get_domain(domains, provider) except Exception: return False return str(domain or "").lower().endswith(".onion") def _lookup_mail_provider_domain(self, variables: dict[str, Any]) -> Any: domain_lookup = DomainLookup() domain_lookup._templar = getattr(self, "_templar", None) try: return domain_lookup.run( [self._mail_provider(variables)], variables=variables )[0] except Exception: return "localhost" def _lookup_no_reply_user(self, variables: dict[str, Any]) -> dict[str, Any]: users_lookup = UsersLookup() users_lookup._templar = getattr(self, "_templar", None) forwarded = { k: v for k, v in getattr(self, "_kwargs", {}).items() if k == "roles_dir" } try: entry = users_lookup.run( ["no-reply", {}], variables=variables, **forwarded )[0] except AnsibleError: return {} return entry if isinstance(entry, dict) else {} def _app_email_overrides( self, application_id: str, variables: dict[str, Any], ) -> dict[str, Any]: apps = ApplicationsLookup() apps._templar = getattr(self, "_templar", None) forwarded = { k: v for k, v in getattr(self, "_kwargs", {}).items() if k == "roles_dir" } try: entry = apps.run( [application_id, {}], variables=variables, **forwarded, )[0] except AnsibleError: return {} if not isinstance(entry, dict): return {} # Per the materialised payload exposes services under the # bare ``services`` key (no ``compose.services`` envelope). services = entry.get("services") or {} email = services.get("email") or {} if isinstance(services, dict) else {} if not isinstance(email, dict): return {} return email