receipter-pro / backend /customizations.py
M8ion deploy
Deploy backend
45dc2a1
Raw History Blame Contribute Delete
3.69 kB
"""
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()}