Files
computer-playbook/plugins/lookup/email.py
T
kevinveenbirkenbachandClaude Opus 5 5b69b5c7c6 Merge branch 'feature/designer' into feature/store-i18n
Six conflicts, all from git pairing store-i18n's renamed roles with their designer originals.

web-app-meta/meta/services.yml and web-app-sphinx/vars/main.yml: store-i18n renamed web-app-sphinx to web-app-meta and dropped its custom build, so the role keeps the prebuilt ghcr.io/infinito-nexus/meta image and the old sphinx role stays deleted.

svc-ai-tika/templates/compose.yml.j2: a bogus rename pairing dropped sphinx build lines into the tika service. Rejected; the template is byte-identical to its pre-merge state again.

web-app-docs/templates/Dockerfile.j2: the designer file renders sphinx under the design title and reads SPHINX_IMAGE, SPHINX_OUTPUT_DIR and SPHINX_EXEC_DIR, none of which exist in web-app-docs, which builds from files/Dockerfile. Rejected.

web-app-docs/templates/playwright.env.j2 and files/playwright/test-design.js: both sides add independently, so both are kept. Of the designer's three new env reads only designTitle survives; iconUrl and faviconUrl are shadowed inside the test by stricter requireDotenvValue reads and were dead at module scope.

plugins/lookup/email.py merges clean toward designer, which is intended rather than a loss: 08ef66d8de (2026-10-08) replaces the submission_via_relay port-25 rule of 233f15f3e0 (2026-07-01) with per-bot app passwords under SSO, and carries the matching 04_manage_user/app_password tasks.

Checked after resolving: no duplicate YAML keys across the 17 merged YAML files, and no nocheck line dropped above a renamed key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-15 22:21:50 +02:00

381 lines
15 KiB
Python

# 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 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.<entity>.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 = 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