""" Saved email "customizations" — a named header + footer + brand color a company designs once and picks per campaign, instead of redesigning them on every send. Stored per org at orgs/{org_id}/customizations/{id}. The frontend merges the chosen customization around the message body into the final HTML (frontend/src/lib/emailFrame.ts), so sending itself is unchanged and never needs to know which customization was used. """ from __future__ import annotations import re import uuid from datetime import datetime, timezone from backend import firestore_client as db from backend import orgs _COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") _MAX_NAME = 80 _MAX_HTML = 20_000 def _now() -> str: return datetime.now(timezone.utc).isoformat() def _prefix(org_id: str) -> str: return f"orgs/{org_id}/customizations/" def _path(org_id: str, customization_id: str) -> str: return f"{_prefix(org_id)}{customization_id}" def _clean(name: str, brand_color: str, logo_url: str, header_text: str, footer_html: str, footer_bg: str) -> dict: name = name.strip() if not name or len(name) > _MAX_NAME: raise ValueError(f"Name is required and must be at most {_MAX_NAME} characters") if not _COLOR_RE.match(brand_color): raise ValueError("Brand color must be a hex color like #c0392b") if not _COLOR_RE.match(footer_bg): raise ValueError("Footer background must be a hex color like #f4f4f4") logo_url = logo_url.strip() if logo_url and not logo_url.lower().startswith("https://"): raise ValueError("Logo URL must start with https://") if len(footer_html) > _MAX_HTML or len(header_text) > 200: raise ValueError("Header or footer is too long") return { "name": name, "brand_color": brand_color, "logo_url": logo_url, "header_text": header_text.strip(), "footer_html": footer_html, "footer_bg": footer_bg, } def create_customization(org_id: str, actor_uid: str, name: str, brand_color: str, logo_url: str, header_text: str, footer_html: str, footer_bg: str = "#f4f4f4") -> str: orgs.require_permission(org_id, actor_uid, "edit_emails") data = _clean(name, brand_color, logo_url, header_text, footer_html, footer_bg) customization_id = uuid.uuid4().hex db.set_doc(_path(org_id, customization_id), data | {"created_at": _now(), "updated_at": _now()}) return customization_id def update_customization(org_id: str, actor_uid: str, customization_id: str, name: str, brand_color: str, logo_url: str, header_text: str, footer_html: str, footer_bg: str = "#f4f4f4") -> None: orgs.require_permission(org_id, actor_uid, "edit_emails") get_customization(org_id, customization_id) data = _clean(name, brand_color, logo_url, header_text, footer_html, footer_bg) db.update_doc(_path(org_id, customization_id), data | {"updated_at": _now()}) def delete_customization(org_id: str, actor_uid: str, customization_id: str) -> None: orgs.require_permission(org_id, actor_uid, "edit_emails") get_customization(org_id, customization_id) db.delete_doc(_path(org_id, customization_id)) def get_customization(org_id: str, customization_id: str) -> dict: doc = db.get_doc(_path(org_id, customization_id)) if doc is None: raise orgs.NotFoundError(f"No customization {customization_id!r} in org {org_id!r}") return doc def list_customizations(org_id: str, actor_uid: str) -> dict[str, dict]: orgs.require_permission(org_id, actor_uid) prefix = _prefix(org_id) return {path[len(prefix):]: data for path, data in db.query_prefix(prefix).items()}