"""Catalog of admin-editable email templates. Single source of truth for the 邮件模板 admin page. Historically that page hard-coded 4 slugs while the platform exposed ~35 templates (29 in :data:`app.notifications.service._DEFAULT_TEMPLATES` plus a handful that live only as migration-seeded ``email_template.*`` rows). This module enumerates them all, groups them by domain, extracts each template's ``{placeholder}`` variables from its rendered default, and merges any ``platform_settings`` override so the editor can prefill the *effective* content (builtin default unless an admin has saved an override). Storage contract is unchanged: every template is a ``platform_settings`` row keyed ``email_template.``; this module only *reads* defaults + overrides. Edits still go through ``PUT/DELETE /platform-settings/{key}`` and the runtime still resolves via ``NotificationService._load_template``. """ from __future__ import annotations import re from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.integrations.models import PlatformSetting from app.notifications.service import _DEFAULT_TEMPLATES _PREFIX = "email_template." # Keys that reached the editor only as migration-seeded ``email_template.*`` # rows. As of 2026-08-09 every one of them ALSO has a builtin (the # localisation sweep registered them so non-Chinese recipients stop getting # Chinese), so this tuple is now redundant with ``_DEFAULT_TEMPLATES`` — # ``_all_keys`` dedupes, so listing them twice is harmless. Kept as the record # of which keys carry a seeded DB override, because for those the seed's # Chinese wins over the builtin's and the two must be kept in sync by hand. _EXTRA_OVERRIDE_KEYS: tuple[str, ...] = ( "password_reset", "mail_group_created", "mail_group_owner_transfer", "cloud.scope_request.manual_required", "cloud.scope_request.rejected", "cloud.scope_request.provisioned", ) # Dead key: nothing in the codebase sends ``template_key="welcome"`` — # onboarding uses ``hire_day_it_info`` (rich code-generated HTML). The # legacy ``email_template.welcome`` row (migration 035) is never read. # Never surface it; migration 278 deletes the orphan row. # ``cloud.scope_request.pending``: ``_notify_admins_pending`` was removed # 2026-05-12. The seeded override row survives but nothing reads it; offering # it for editing invites an admin to carefully word copy that will never send. _HIDDEN_KEYS: frozenset[str] = frozenset({"welcome", "cloud.scope_request.pending"}) # Templates whose HTML body is generated in code (the onboarding mail). # An admin-saved ``body_html`` here would clobber the rich layout, so the # editor hides the body_html field and notes that only subject + text # (the Feishu card / text fallback) are editable. _HTML_GENERATED: frozenset[str] = frozenset({"hire_day_it_info"}) # Templates the runtime resolves from the in-code builtin ONLY — the # ``platform_settings`` override layer is deliberately not consulted, so an # admin edit saves fine and then never ships. Both are the plaintext-credential # mails (``service.SECRET_TEMPLATE_KEYS``): they bypass # ``NotificationService.dispatch`` entirely and hand the copy to the email # driver at the call site, precisely so the one mail carrying a live temporary # password cannot be broken by an override. # # * ``google_account_password`` — ``account_provisioner._send_password_email`` # formats the module-level ``_PASSWORD_MAIL_TEMPLATE`` snapshot. # * ``tencent_cam_initial_password`` — ``cmdb.lifecycle_service.grant_access`` # formats the module-level ``_CAM_MAIL_TEMPLATE`` snapshot. Registered in # ``templates/cloud.py`` since #2900 (before that the copy was inline and # Chinese-only), so unlike the sentence that used to sit here it DOES reach # the registry — and the flag it was pre-listed for is now load-bearing. # # Without the flag the editor lists these as ordinary editable rows and reports # "saved" for an edit that can never take effect — a config entry pointing at # something other than the real switch. Surfaced to the editor as # ``builtin_only`` and enforced server-side by the ``PUT /platform-settings`` # guard, so the same lie can't be told over the raw API either. BUILTIN_ONLY_KEYS: frozenset[str] = frozenset({ "google_account_password", "tencent_cam_initial_password", }) # Explicit domain grouping. Keeps the editor readable (one section per # domain) instead of one orphan group per flat key. Dotted keys fall back # to their prefix; anything unmapped lands in "other". _GROUP_OF: dict[str, str] = { "hire_day_it_info": "onboarding", "offboarding.process_started_leaver": "offboarding", "offboarding.process_started_manager": "offboarding", "offboarding.ready": "offboarding", "offboarding.completed": "offboarding", "leaver_reminder": "offboarding", "leaver_escalation": "offboarding", "leaver_it_grace": "offboarding", "leaver_blocked_sla_warn": "offboarding", "leaver_blocked_sla_urgent": "offboarding", "leaver_blocked_sla_breach": "offboarding", "offboarding.handover_assigned": "offboarding", "offboarding.handover_reassigned_ack": "offboarding", "transfer_received": "asset", "transfer_accepted_it": "asset", "asset_inventory_notify": "asset", "asset_inventory_reminder": "asset", "asset_inventory_escalation": "asset", # Filed under asset, not software: the trigger is a disposal and the # subject is a piece of hardware. An MDM admin looking for these will # look where the asset templates are. "mdm.device_cleanup": "asset", "mdm.device_cleanup_reminder": "asset", "alias_created": "email_account", "alias_deleted": "email_account", "mail_group_created": "email_account", "mail_group_owner_transfer": "email_account", "password_reset": "email_account", "provisioning.manual_notify": "software", "provisioning.offboarding_auto_release_reminder": "software", "provisioning.revoke_failure": "software", # Grouped with lifecycle rather than software: the reader is the # person who was waiting, not the admin who owns the connector. "automation_exception.subject_recovered": "lifecycle", "automation_exception.owner_recurred": "lifecycle", "zoom.seat_evicted": "software", "cloud.scope_request.pending": "cloud", "cloud.scope_request.manual_required": "cloud", "cloud.scope_request.rejected": "cloud", "cloud.scope_request.provisioned": "cloud", "cloud.billing_alert": "cloud", # Flat key, so neither the dotted ``cloud.`` nor the ``cmdb`` prefix below # catches it — without this line the CAM credential mail lands in "other", # away from every other cloud row in the editor. "tencent_cam_initial_password": "cloud", "apikey.budget_alert": "apikey", "apikey.ready": "apikey", "sso.app_create_pending": "system", "sso.idp_config_ready": "system", "scheduled_job.failure": "system", "identity_sync.run_failure": "system", } # Prefix → group, consulted after the exact-key map above. Ordered: the # first matching prefix wins, so put the specific ones first. # # This replaces a "group = text before the first dot" fallback that scattered # the catalog the moment the template set tripled: it invented one-off groups # ("jira", "procurement", "hris", "dynamic_email_group") and dumped every flat # key ("borrow_auto_returned", "compliance_incident_alert") into "other". The # editor is a navigation surface — a group per key is the same as no groups. _GROUP_BY_PREFIX: tuple[tuple[str, str], ...] = ( ("hire_day_it_info", "onboarding"), ("onboarding.", "onboarding"), ("offboarding", "offboarding"), ("leaver", "offboarding"), ("asset.", "asset"), ("borrow_", "asset"), ("force_transfer", "asset"), ("transfer_", "asset"), ("provisioning.", "software"), ("zoom.", "software"), ("mail_group", "email_account"), ("alias_", "email_account"), ("dynamic_email_group", "email_account"), ("google_account", "email_account"), ("password_reset", "email_account"), ("cloud.", "cloud"), ("cmdb", "cloud"), ("apikey.", "apikey"), ("support.", "support"), ("feed.", "feed"), ("finance.", "finance"), ("procurement.", "procurement"), ("hris.", "hr"), ("hr_leave.", "hr"), ("role_change", "hr"), ("lifecycle_daily", "hr"), ("identity_sync.", "system"), ("scheduled_job.", "system"), ("approval.", "system"), # ``approval_feishu_cancel_desync`` uses an underscore, so the dotted # prefix above misses it and the key would land in "other". ("approval_feishu", "system"), ("access_review.", "system"), ("sso.", "system"), ("jira.", "system"), ("compliance", "system"), ) # Stable display order for groups in the editor. _GROUP_ORDER: tuple[str, ...] = ( "onboarding", "offboarding", "hr", "asset", "software", "email_account", "cloud", "apikey", "support", "feed", "finance", "procurement", "system", "other", ) _VAR_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}") # Auto-injected by the send path — surfaced to admins as read-only hints # rather than "missing variables". _AUTO_VARS: frozenset[str] = frozenset({"cta_line", "portal_url"}) # Fields carried through for an email template value. _FIELDS: tuple[str, ...] = ( "subject", "subject_en", "subject_ja", "body_text", "body_text_en", "body_text_ja", "body_html", "body_html_en", "body_html_ja", "card_button_label", "card_button_label_en", "card_button_label_ja", # Card CHROME. Editorial copy an admin should be able to reword without a # deploy, and — because everything in this tuple goes through # ``_select_template_field`` — localized on the same ja → en → zh ladder as # the body. Before this, the subtitle ("为什么发这张卡") and the footer were # producer-hardcoded in one language each. "card_subtitle", "card_subtitle_en", "card_subtitle_ja", "card_footer", "card_footer_en", "card_footer_ja", "card_highlights_title", "card_highlights_title_en", "card_highlights_title_ja", ) def _all_keys() -> list[str]: keys: list[str] = [] seen: set[str] = set() for k in list(_DEFAULT_TEMPLATES.keys()) + list(_EXTRA_OVERRIDE_KEYS): if k in _HIDDEN_KEYS or k in seen: continue seen.add(k) keys.append(k) return keys def _group_of(key: str) -> str: if key in _GROUP_OF: return _GROUP_OF[key] for prefix, group in _GROUP_BY_PREFIX: if key.startswith(prefix): return group return "other" def _extract_vars(value: dict[str, object]) -> list[str]: """Placeholders an admin may reference, across EVERY localised field. Scanning only a couple of fields hid placeholders that appear solely in a translation — the editor then listed fewer variables than the template actually interpolates, which reads as "this one is unsupported". """ found: list[str] = [] for field in _FIELDS: text = value.get(field) if isinstance(text, str): for m in _VAR_RE.findall(text): if m not in found: found.append(m) return found async def build_registry(session: AsyncSession) -> list[dict[str, object]]: """Return all admin-editable email templates with their effective content (override ∪ builtin), variables, group and flags. One query loads every ``email_template.*`` override; results are merged over the in-code defaults. The list is sorted by group order then key so the editor renders deterministically. """ rows = ( await session.execute( select(PlatformSetting).where( PlatformSetting.key.like(f"{_PREFIX}%") ) ) ).scalars().all() overrides: dict[str, dict] = {} for row in rows: if isinstance(row.value, dict): overrides[row.key[len(_PREFIX):]] = row.value items: list[dict[str, object]] = [] for key in _all_keys(): builtin = _DEFAULT_TEMPLATES.get(key, {}) override = overrides.get(key) configured = override is not None # Effective value = override when present (admin intent wins), # otherwise the in-code builtin baseline. effective: dict[str, object] = ( dict(override) if override is not None else dict(builtin) ) item: dict[str, object] = { "key": key, "group": _group_of(key), "configured": configured, "has_builtin": bool(builtin), "html_generated": key in _HTML_GENERATED, "builtin_only": key in BUILTIN_ONLY_KEYS, "variables": _extract_vars(effective), "auto_variables": sorted( v for v in _extract_vars(effective) if v in _AUTO_VARS ), } for field in _FIELDS: val = effective.get(field) item[field] = val if isinstance(val, str) else "" items.append(item) order = {g: i for i, g in enumerate(_GROUP_ORDER)} items.sort(key=lambda it: (order.get(str(it["group"]), 99), str(it["key"]))) return items __all__ = ["BUILTIN_ONLY_KEYS", "build_registry"]