Spaces:
Sleeping
Sleeping
Download backend/app.py from Success-dev/receipter-pro: direct link, hf CLI and curl.
- Browser
- Download file 35.3 kB
-
https://huggingface.co/spaces/Success-dev/receipter-pro/resolve/main/backend/app.py
- Command line
-
hf download hf://spaces/Success-dev/receipter-pro/backend/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Success-dev/receipter-pro/resolve/main/backend/app.py
35.3 kB
| """ | |
| The HTTP layer. Auth goes through backend.auth.current_uid — a real Firebase ID token | |
| (REAL_AUTH_ENABLED=true) or the X-Dev-Uid header bypass (false, the default in tests — see | |
| tests/conftest.py). Only exposes what the built screens actually need — grows as more | |
| screens are built, not speculatively ahead of them. | |
| """ | |
| from __future__ import annotations | |
| import hmac | |
| import logging | |
| import os | |
| import sys | |
| import threading | |
| from contextlib import asynccontextmanager | |
| from pathlib import Path | |
| from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, Header, HTTPException, UploadFile | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel | |
| from backend import accounts, automations, brands, delivery, dns_check, identity, invites, collect, customizations, databases, local_store, mailboxes, orgs, overview, presets, sending, settings, storage, timing, unsubscribe | |
| from backend.auth import current_email, current_uid, lookup_user | |
| from backend.core.constants import PERMISSIONS | |
| from backend.collect_api import router as collect_router | |
| from backend.unsubscribe_api import router as unsubscribe_router | |
| def _tick(stop: threading.Event, every: float = 30) -> None: | |
| while not stop.wait(every): | |
| try: | |
| timing.run_due() | |
| except Exception: # one bad run must not stop the timer | |
| logging.getLogger("m8ion").exception("Scheduled run failed") | |
| async def _lifespan(app: FastAPI): | |
| # backend/tests/conftest.py does this per-test with a throwaway file; a real running | |
| # process needs it done once, against a persistent file, before any request lands. | |
| local_store.init_local_store(os.getenv("LOCAL_STORE_DB_PATH", "local_store.db")) | |
| # Locally there is no cloud timer, so a small thread plays that part. In production a | |
| # scheduler calls POST /internal/run-due instead and nothing stays running between calls. | |
| stop = threading.Event() | |
| if os.getenv("LOCAL_TICKER", "true" if "pytest" not in sys.modules else "false").lower() == "true": | |
| threading.Thread(target=_tick, args=(stop,), daemon=True).start() | |
| yield | |
| stop.set() | |
| app = FastAPI(title="M8ion API", lifespan=_lifespan) | |
| # The public sign-up endpoints (docs/blueprint/adr/0006) set their own per-key CORS headers. | |
| app.include_router(collect_router) | |
| app.include_router(unsubscribe_router) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| # Dev-only: any localhost port, since the Vite dev server's port can shift if 5173 is | |
| # already taken (strictPort: false in vite.config.ts). | |
| allow_origin_regex=r"http://localhost:\d+", | |
| # The deployed web app. FRONTEND_URL can list several addresses, separated by commas. | |
| allow_origins=[o.strip().rstrip("/") for o in os.getenv("FRONTEND_URL", "").split(",") if o.strip()], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| def member_uid(org_id: str, uid: str = Depends(current_uid)) -> str: | |
| """current_uid, for routes under /orgs/{org_id}: refuses anyone outside the org. | |
| Finer checks (what the person's role allows) happen in each feature module.""" | |
| _map_errors(orgs.require_permission, org_id, uid) | |
| _map_errors(accounts.check_org_access, uid, _map_errors(orgs.get_org, org_id)) | |
| return uid | |
| def org_only_uid(org_id: str, uid: str = Depends(member_uid)) -> str: | |
| """member_uid, for the parts an individual's personal org doesn't have: team, roles, | |
| invites, brands, organisation details and DNS. Refused on the server, not just hidden.""" | |
| if accounts.is_personal(org_id): | |
| raise HTTPException(status_code=403, detail="This is for organisation accounts") | |
| return uid | |
| def _map_errors(fn, *args, **kwargs): | |
| try: | |
| return fn(*args, **kwargs) | |
| except orgs.NotFoundError as exc: | |
| raise HTTPException(status_code=404, detail=str(exc)) from exc | |
| except orgs.PermissionError_ as exc: | |
| raise HTTPException(status_code=403, detail=str(exc)) from exc | |
| except (mailboxes.ValidationError, databases.ValidationError, accounts.AccountError, ValueError) as exc: | |
| raise HTTPException(status_code=400, detail=str(exc)) from exc | |
| # --- Account type ----------------------------------------------------------- | |
| class RegisterAccountBody(BaseModel): | |
| account_type: str | |
| plan: str | None = None | |
| name: str = "" | |
| def get_account(uid: str = Depends(current_uid)): | |
| """Which kind of account this is, so the app can send the person to the right area.""" | |
| return accounts.describe(uid) | |
| def register_account(body: RegisterAccountBody, uid: str = Depends(current_uid)): | |
| """Chooses the account type, once. After this nothing here can change it.""" | |
| return _map_errors(accounts.register, uid, body.account_type, body.plan, body.name) | |
| class IdentityBody(BaseModel): | |
| values: dict[str, str] = {} | |
| def get_identity(uid: str = Depends(current_uid)): | |
| return _map_errors(identity.describe, uid) | |
| def submit_identity(body: IdentityBody, uid: str = Depends(current_uid)): | |
| return _map_errors(identity.submit, uid, body.values) | |
| # --- Orgs ------------------------------------------------------------------- | |
| class CreateOrgBody(BaseModel): | |
| name: str | |
| def create_org(body: CreateOrgBody, uid: str = Depends(current_uid)): | |
| _map_errors(accounts.require_organization, uid) | |
| accounts.ensure_organization(uid) | |
| org_id = orgs.create_org(owner_uid=uid, name=body.name) | |
| return {"org_id": org_id, "role": "owner"} | |
| def list_orgs(uid: str = Depends(current_uid)): | |
| return [ | |
| {"org_id": org_id, "role": role, "name": (org := orgs.get_org(org_id))["name"], | |
| "kind": org.get("kind", "organization"), | |
| "permissions": sorted(orgs.permissions_for(org_id, role))} | |
| for org_id, role in orgs.list_orgs_for_user(uid).items() | |
| ] | |
| # --- Mailbox connections ------------------------------------------------------ | |
| class ConnectMailboxBody(BaseModel): | |
| kind: str | |
| credentials: dict | |
| def _public_mailbox(connection_id: str, data: dict) -> dict: | |
| """Drops encrypted_credentials — the API has no business returning that blob at all, | |
| encrypted or not.""" | |
| return {"connection_id": connection_id, **{k: v for k, v in data.items() if k != "encrypted_credentials"}} | |
| def connect_mailbox(org_id: str, body: ConnectMailboxBody, uid: str = Depends(member_uid)): | |
| connection_id = _map_errors(mailboxes.connect_mailbox, org_id, uid, body.kind, body.credentials) | |
| return _public_mailbox(connection_id, mailboxes.get_mailbox(org_id, connection_id)) | |
| def verify_mailbox(org_id: str, connection_id: str, uid: str = Depends(member_uid)): | |
| _map_errors(orgs.require_permission, org_id, uid, "manage_mailboxes") | |
| status = _map_errors(mailboxes.verify_mailbox, org_id, connection_id) | |
| connection = mailboxes.get_mailbox(org_id, connection_id) | |
| return {"connection_id": connection_id, "status": status, "status_message": connection["status_message"]} | |
| class DismissMailboxBody(BaseModel): | |
| dismissed: bool = True | |
| def dismiss_mailbox(org_id: str, connection_id: str, body: DismissMailboxBody, uid: str = Depends(member_uid)): | |
| _map_errors(mailboxes.set_dismissed, org_id, uid, connection_id, body.dismissed) | |
| return {"connection_id": connection_id, "dismissed": body.dismissed} | |
| def list_mailboxes(org_id: str, uid: str = Depends(member_uid)): | |
| return [_public_mailbox(cid, data) for cid, data in mailboxes.list_mailboxes(org_id).items()] | |
| def get_mailbox(org_id: str, connection_id: str, uid: str = Depends(member_uid)): | |
| connection = _map_errors(mailboxes.get_mailbox, org_id, connection_id) | |
| detail = _public_mailbox(connection_id, connection) | { | |
| "settings": mailboxes.mailbox_settings(connection), | |
| "sent_today": sending.sent_today(org_id, connection_id), | |
| "next_credential_change": mailboxes.next_credential_change(connection), | |
| } | |
| if connection["kind"] == "smtp": | |
| # Server details without the password, so the change form can start from them. | |
| creds = mailboxes.get_decrypted_credentials(org_id, connection_id) | |
| detail["smtp"] = {k: creds.get(k) for k in ("host", "port", "username")} | |
| # The incoming side, also without its password. | |
| detail["imap"] = { | |
| "host": creds.get("imap_host") or "", | |
| "port": creds.get("imap_port") or 993, | |
| "username": creds.get("imap_username") or "", | |
| } | |
| return detail | |
| class MailboxSettingsBody(BaseModel): | |
| values: dict | |
| def update_mailbox_settings(org_id: str, connection_id: str, body: MailboxSettingsBody, uid: str = Depends(member_uid)): | |
| return _map_errors(mailboxes.update_settings, org_id, uid, connection_id, body.values) | |
| class MailboxCredentialsBody(BaseModel): | |
| credentials: dict | |
| def change_mailbox_credentials(org_id: str, connection_id: str, body: MailboxCredentialsBody, uid: str = Depends(member_uid)): | |
| return _map_errors(mailboxes.change_credentials, org_id, uid, connection_id, body.credentials) | |
| def remove_mailbox(org_id: str, connection_id: str, uid: str = Depends(member_uid)): | |
| _map_errors(mailboxes.remove_mailbox, org_id, uid, connection_id) | |
| return {"ok": True} | |
| # --- Audience databases ------------------------------------------------------- | |
| def _save_upload_copy(org_id: str, database_id: str, ext: str, file_bytes: bytes) -> None: | |
| """Keeps the raw uploaded file, per the product requirement that an upload is never just | |
| parsed-and-discarded. Supabase Storage when configured, a local folder otherwise.""" | |
| try: | |
| storage.save_upload(org_id, database_id, ext, file_bytes) | |
| except storage.StorageError as err: | |
| raise HTTPException(503, str(err)) from err | |
| async def create_database( | |
| org_id: str, name: str = Form(...), file: UploadFile = File(...), uid: str = Depends(member_uid), | |
| ): | |
| file_bytes = await file.read() | |
| database_id = _map_errors( | |
| databases.create_database_from_upload, | |
| org_id, uid, name, file_bytes, file.filename or "upload", | |
| save_copy=lambda database_id, ext, data: _save_upload_copy(org_id, database_id, ext, data), | |
| ) | |
| return databases.get_database(org_id, database_id) | {"database_id": database_id} | |
| def list_databases(org_id: str, uid: str = Depends(member_uid)): | |
| return [{"database_id": did, **data} for did, data in databases.list_databases(org_id).items()] | |
| def get_database(org_id: str, database_id: str, uid: str = Depends(member_uid)): | |
| database = _map_errors(databases.get_database, org_id, database_id) | |
| rows = databases.list_rows(org_id, database_id) | |
| return {"database_id": database_id, **database, "rows": rows} | |
| class AddRowBody(BaseModel): | |
| values: dict | |
| def add_row(org_id: str, database_id: str, body: AddRowBody, uid: str = Depends(member_uid)): | |
| row_id = _map_errors(databases.add_row, org_id, uid, database_id, body.values) | |
| return {"row_id": row_id} | |
| class BulkSetValuesBody(BaseModel): | |
| updates: dict[str, dict] | |
| # Registered BEFORE /rows/{row_id} below: Starlette matches path templates in registration | |
| # order, and {row_id} would otherwise happily capture the literal segment "batch", silently | |
| # routing every call here into update_row instead (misrouted to the wrong handler/body model, | |
| # not a 404 — caught via a 422 on the wrong required field, "tags", during manual testing). | |
| def bulk_set_values(org_id: str, database_id: str, body: BulkSetValuesBody, uid: str = Depends(member_uid)): | |
| updated = _map_errors(databases.bulk_set_values, org_id, uid, database_id, body.updates) | |
| return {"updated_row_ids": updated} | |
| class UpdateRowBody(BaseModel): | |
| tags: dict | |
| def update_row(org_id: str, database_id: str, row_id: str, body: UpdateRowBody, uid: str = Depends(member_uid)): | |
| _map_errors(databases.update_row, org_id, uid, database_id, row_id, body.tags) | |
| return {"row_id": row_id} | |
| class BulkUpdateRowsBody(BaseModel): | |
| row_ids: list[str] | |
| tags: dict | |
| def bulk_update_rows(org_id: str, database_id: str, body: BulkUpdateRowsBody, uid: str = Depends(member_uid)): | |
| updated = _map_errors(databases.bulk_update_rows, org_id, uid, database_id, body.row_ids, body.tags) | |
| return {"updated_row_ids": updated} | |
| class BulkAppendTagBody(BaseModel): | |
| row_ids: list[str] | |
| tag: str | |
| tag_column: str = "Tags" | |
| def bulk_append_tag(org_id: str, database_id: str, body: BulkAppendTagBody, uid: str = Depends(member_uid)): | |
| updated = _map_errors( | |
| databases.bulk_append_tag, org_id, uid, database_id, body.row_ids, body.tag, body.tag_column, | |
| ) | |
| return {"updated_row_ids": updated} | |
| class DeleteRowsBody(BaseModel): | |
| row_ids: list[str] | |
| def delete_rows(org_id: str, database_id: str, body: DeleteRowsBody, uid: str = Depends(member_uid)): | |
| deleted = _map_errors(databases.delete_rows, org_id, uid, database_id, body.row_ids) | |
| return {"deleted": deleted} | |
| def get_lookup_table(org_id: str, database_id: str, lookup_name: str, uid: str = Depends(member_uid)): | |
| return _map_errors(databases.get_lookup_table, org_id, database_id, lookup_name) | |
| class SegmentPreviewBody(BaseModel): | |
| conditions: list[dict] | |
| def preview_segment(org_id: str, database_id: str, body: SegmentPreviewBody, uid: str = Depends(member_uid)): | |
| return _map_errors(databases.preview_segment, org_id, database_id, body.conditions) | |
| class ColumnListBody(BaseModel): | |
| delimiter: str | None = None # None dismisses the "this looks like a list" suggestion | |
| def set_column_list(org_id: str, database_id: str, column: str, body: ColumnListBody, uid: str = Depends(member_uid)): | |
| _map_errors(databases.set_column_list, org_id, uid, database_id, column, body.delimiter) | |
| return {"column": column, "delimiter": body.delimiter} | |
| class RenderPreviewBody(BaseModel): | |
| subject: str = "" | |
| html_body: str = "" | |
| segment: list[dict] = [] | |
| index: int = 0 | |
| email_column: str | None = None | |
| def render_preview(org_id: str, database_id: str, body: RenderPreviewBody, uid: str = Depends(member_uid)): | |
| return _map_errors(sending.preview_render, org_id, database_id, body.subject, body.html_body, body.segment, body.index, body.email_column) | |
| class UpdateColumnTypeBody(BaseModel): | |
| type: str | |
| def update_column_type(org_id: str, database_id: str, column: str, body: UpdateColumnTypeBody, uid: str = Depends(member_uid)): | |
| flagged_row_ids = _map_errors(databases.update_column_type, org_id, database_id, column, body.type) | |
| return {"column": column, "type": body.type, "flagged_row_ids": flagged_row_ids} | |
| class RenameColumnBody(BaseModel): | |
| name: str | |
| def column_rename_suggestions(org_id: str, database_id: str, uid: str = Depends(member_uid)): | |
| return {"suggestions": _map_errors(databases.column_rename_suggestions, org_id, database_id)} | |
| def column_type_suggestions(org_id: str, database_id: str, uid: str = Depends(member_uid)): | |
| return {"suggestions": _map_errors(databases.column_type_suggestions, org_id, database_id)} | |
| def rename_column(org_id: str, database_id: str, column: str, body: RenameColumnBody, uid: str = Depends(member_uid)): | |
| name = _map_errors(databases.rename_column, org_id, uid, database_id, column, body.name) | |
| return {"column": column, "name": name} | |
| def remove_column(org_id: str, database_id: str, column: str, uid: str = Depends(member_uid)): | |
| return _map_errors(databases.remove_column, org_id, uid, database_id, column) | |
| class RestoreColumnBody(BaseModel): | |
| column: dict | |
| values_by_row_id: dict[str, str] | |
| def restore_column(org_id: str, database_id: str, body: RestoreColumnBody, uid: str = Depends(member_uid)): | |
| _map_errors(databases.restore_column, org_id, uid, database_id, body.column, body.values_by_row_id) | |
| return {"column": body.column["name"]} | |
| class RenameDatabaseBody(BaseModel): | |
| name: str | |
| def rename_database(org_id: str, database_id: str, body: RenameDatabaseBody, uid: str = Depends(member_uid)): | |
| _map_errors(databases.rename_database, org_id, uid, database_id, body.name) | |
| return {"database_id": database_id, "name": body.name.strip()} | |
| class SetDisabledBody(BaseModel): | |
| disabled: bool | |
| def set_database_disabled(org_id: str, database_id: str, body: SetDisabledBody, uid: str = Depends(member_uid)): | |
| _map_errors(databases.set_database_disabled, org_id, uid, database_id, body.disabled) | |
| return {"database_id": database_id, "disabled": body.disabled} | |
| def delete_database(org_id: str, database_id: str, uid: str = Depends(member_uid)): | |
| _map_errors(databases.delete_database, org_id, uid, database_id) | |
| return {"deleted": database_id} | |
| class DuplicateDatabaseBody(BaseModel): | |
| new_name: str | |
| def duplicate_database(org_id: str, database_id: str, body: DuplicateDatabaseBody, uid: str = Depends(member_uid)): | |
| new_id = _map_errors(databases.duplicate_database, org_id, uid, database_id, body.new_name) | |
| return databases.get_database(org_id, new_id) | {"database_id": new_id} | |
| class MergeDatabasesBody(BaseModel): | |
| database_id_a: str | |
| database_id_b: str | |
| new_name: str | |
| def merge_databases(org_id: str, body: MergeDatabasesBody, uid: str = Depends(member_uid)): | |
| database_id = _map_errors( | |
| databases.merge_databases, org_id, uid, body.database_id_a, body.database_id_b, body.new_name, | |
| ) | |
| return databases.get_database(org_id, database_id) | {"database_id": database_id} | |
| # --- Customizations (saved header/footer/brand) --------------------------------- | |
| class CustomizationBody(BaseModel): | |
| name: str | |
| brand_color: str = "#c0392b" | |
| logo_url: str = "" | |
| header_text: str = "" | |
| footer_html: str = "" | |
| footer_bg: str = "#f4f4f4" | |
| def list_customizations(org_id: str, uid: str = Depends(member_uid)): | |
| items = _map_errors(customizations.list_customizations, org_id, uid) | |
| return [v | {"customization_id": k} for k, v in sorted(items.items(), key=lambda kv: kv[1]["name"].lower())] | |
| def create_customization(org_id: str, body: CustomizationBody, uid: str = Depends(member_uid)): | |
| cid = _map_errors( | |
| customizations.create_customization, org_id, uid, body.name, body.brand_color, | |
| body.logo_url, body.header_text, body.footer_html, body.footer_bg, | |
| ) | |
| return customizations.get_customization(org_id, cid) | {"customization_id": cid} | |
| def update_customization(org_id: str, customization_id: str, body: CustomizationBody, uid: str = Depends(member_uid)): | |
| _map_errors( | |
| customizations.update_customization, org_id, uid, customization_id, body.name, | |
| body.brand_color, body.logo_url, body.header_text, body.footer_html, body.footer_bg, | |
| ) | |
| return customizations.get_customization(org_id, customization_id) | {"customization_id": customization_id} | |
| def delete_customization(org_id: str, customization_id: str, uid: str = Depends(member_uid)): | |
| _map_errors(customizations.delete_customization, org_id, uid, customization_id) | |
| return {"deleted": customization_id} | |
| # --- Campaigns (manual send) --------------------------------------------------- | |
| class SendCampaignBody(BaseModel): | |
| subject: str | |
| html_body: str | |
| mailbox_connection_id: str | |
| database_id: str | |
| segment: list[dict] | None = None | |
| attachments: list[dict] | None = None | |
| certificate: dict | None = None | |
| email_column: str | None = None | |
| mode: str | None = None | |
| design: dict | None = None | |
| scheduled_at: str | None = None | |
| window: dict | None = None | |
| def send_campaign(org_id: str, body: SendCampaignBody, background: BackgroundTasks, uid: str = Depends(member_uid)): | |
| send_id = _map_errors( | |
| sending.queue_campaign, org_id, uid, body.subject, body.html_body, | |
| body.mailbox_connection_id, body.database_id, body.segment, | |
| body.attachments, body.certificate, body.email_column, body.mode, body.design, | |
| body.scheduled_at, body.window, | |
| ) | |
| # The page gets its answer now; the emails go out in the background. | |
| background.add_task(sending.process_send, org_id, send_id) | |
| return sending.get_send(org_id, send_id) | {"send_id": send_id} | |
| # --- Settings --------------------------------------------------------------------- | |
| class ProfileBody(BaseModel): | |
| display_name: str = "" | |
| job_title: str | None = None | |
| phone: str | None = None | |
| about: str | None = None | |
| class OrgNameBody(BaseModel): | |
| name: str | |
| profile: dict | None = None | |
| def get_settings(org_id: str, uid: str = Depends(member_uid)): | |
| return _map_errors(settings.get_settings, org_id, uid) | |
| def update_profile(org_id: str, body: ProfileBody, uid: str = Depends(member_uid)): | |
| return _map_errors(settings.update_profile, org_id, uid, body.display_name, body.model_dump(exclude={"display_name"}, exclude_none=True)) | |
| def update_notifications(org_id: str, body: dict, uid: str = Depends(member_uid)): | |
| return _map_errors(settings.update_notifications, org_id, uid, body) | |
| def rename_org(org_id: str, body: OrgNameBody, uid: str = Depends(org_only_uid)): | |
| return _map_errors(settings.rename_org, org_id, uid, body.name, body.profile) | |
| def update_email_settings(org_id: str, body: dict, uid: str = Depends(member_uid)): | |
| return _map_errors(settings.update_emails, org_id, uid, body) | |
| def list_sends(org_id: str, uid: str = Depends(member_uid)): | |
| return _map_errors(sending.list_sends, org_id, uid) | |
| def check_delivery(org_id: str, send_id: str, uid: str = Depends(member_uid)): | |
| """Reads the mailbox's IMAP side now for delays, bounces and replies to this send.""" | |
| _map_errors(orgs.require_permission, org_id, uid, "send") | |
| return _map_errors(delivery.check_send, org_id, send_id) | |
| def control_send(org_id: str, send_id: str, action: str, uid: str = Depends(member_uid)): | |
| if action not in ("pause", "resume", "cancel"): | |
| raise HTTPException(404, "Unknown action") | |
| return _map_errors(sending.control_send, org_id, uid, send_id, action) | |
| def list_suppressions(org_id: str, uid: str = Depends(member_uid)): | |
| return _map_errors(unsubscribe.list_suppressed, org_id, uid) | |
| class SuppressionBody(BaseModel): | |
| email: str | |
| def add_suppression(org_id: str, body: SuppressionBody, uid: str = Depends(member_uid)): | |
| _map_errors(unsubscribe.add_by_hand, org_id, uid, body.email) | |
| return {"email": body.email.strip().lower()} | |
| def remove_suppression(org_id: str, email: str, uid: str = Depends(member_uid)): | |
| _map_errors(unsubscribe.remove, org_id, uid, email) | |
| return {"removed": email} | |
| class AutomationBody(BaseModel): | |
| kind: str | |
| name: str | |
| mailbox_connection_id: str | |
| database_id: str | |
| email_column: str | None = None | |
| segment: list[dict] | None = None | |
| start_at: str | None = None | |
| end_at: str | None = None | |
| steps: list[dict] | None = None | |
| subject: str | None = None | |
| html_body: str | None = None | |
| every: dict | None = None | |
| date_column: str | None = None | |
| offset_days: int = 0 | |
| yearly: bool = False | |
| def list_automations(org_id: str, uid: str = Depends(member_uid)): | |
| return _map_errors(automations.list_for, org_id, uid) | |
| def create_automation(org_id: str, body: AutomationBody, uid: str = Depends(member_uid)): | |
| return _map_errors(automations.create, org_id, uid, body.model_dump()) | |
| def control_automation(org_id: str, automation_id: str, action: str, uid: str = Depends(member_uid)): | |
| return _map_errors(automations.control, org_id, uid, automation_id, action) | |
| def delete_automation(org_id: str, automation_id: str, uid: str = Depends(member_uid)): | |
| _map_errors(automations.delete, org_id, uid, automation_id) | |
| return {"deleted": automation_id} | |
| def run_due(x_cron_secret: str = Header(default="")): | |
| """Called by the cloud scheduler (every minute or so) to carry out what is due. Closed | |
| unless CRON_SECRET is set, and the caller must send it.""" | |
| secret = os.getenv("CRON_SECRET", "") | |
| if not secret or not hmac.compare_digest(x_cron_secret, secret): | |
| raise HTTPException(404, "Not found") | |
| return timing.run_due() | |
| def get_send_details(org_id: str, send_id: str, uid: str = Depends(member_uid)): | |
| return _map_errors(sending.get_send_details, org_id, uid, send_id) | |
| class CertificatePreviewBody(BaseModel): | |
| database_id: str | |
| certificate: dict | |
| segment: list[dict] | None = None | |
| index: int = 0 | |
| email_column: str | None = None | |
| def preview_certificate(org_id: str, body: CertificatePreviewBody, uid: str = Depends(member_uid)): | |
| _map_errors(orgs.require_role, org_id, uid, "operator") | |
| return _map_errors( | |
| sending.preview_certificate, org_id, body.database_id, body.certificate, body.segment, body.index, body.email_column, | |
| ) | |
| # --- Presets (saved emails to start a campaign from) ---------------------------- | |
| class PresetBody(BaseModel): | |
| name: str | |
| subject: str = "" | |
| mode: str | |
| design: dict | None = None | |
| html: str = "" | |
| def list_presets(org_id: str, uid: str = Depends(member_uid)): | |
| return _map_errors(presets.list_presets, org_id, uid) | |
| def create_preset(org_id: str, body: PresetBody, uid: str = Depends(member_uid)): | |
| preset_id = _map_errors(presets.create_preset, org_id, uid, body.name, body.subject, body.mode, body.design, body.html) | |
| return presets.get_preset(org_id, preset_id) | {"preset_id": preset_id} | |
| def update_preset(org_id: str, preset_id: str, body: PresetBody, uid: str = Depends(member_uid)): | |
| _map_errors(presets.update_preset, org_id, uid, preset_id, body.name, body.subject, body.mode, body.design, body.html) | |
| return presets.get_preset(org_id, preset_id) | {"preset_id": preset_id} | |
| def delete_preset(org_id: str, preset_id: str, uid: str = Depends(member_uid)): | |
| _map_errors(presets.delete_preset, org_id, uid, preset_id) | |
| return {"deleted": preset_id} | |
| # --- Overview (the dashboard's numbers) ------------------------------------------ | |
| def get_overview(org_id: str, uid: str = Depends(member_uid)): | |
| return _map_errors(overview.get_overview, org_id, uid) | |
| # --- Collect (sign-up forms for a list; the public side is backend/collect_api.py) ------ | |
| class CollectKeyBody(BaseModel): | |
| name: str | |
| database_id: str | |
| allowed_origins: list[str] = [] | |
| fields: list[str] | None = None | |
| double_opt_in: bool = True | |
| mailbox_connection_id: str | None = None | |
| daily_cap: int = 500 | |
| welcome: dict | None = None | |
| def list_collect_keys(org_id: str, uid: str = Depends(member_uid)): | |
| return _map_errors(collect.list_keys, org_id, uid) | |
| def create_collect_key(org_id: str, body: CollectKeyBody, uid: str = Depends(member_uid)): | |
| return _map_errors( | |
| collect.create_key, org_id, uid, body.name, body.database_id, body.allowed_origins, | |
| body.fields, body.double_opt_in, body.mailbox_connection_id, body.daily_cap, body.welcome, | |
| ) | |
| class WelcomeBody(BaseModel): | |
| welcome: dict | None = None | |
| def set_collect_welcome(org_id: str, key_id: str, body: WelcomeBody, uid: str = Depends(member_uid)): | |
| return {"welcome": _map_errors(collect.set_welcome, org_id, uid, key_id, body.welcome)} | |
| def revoke_collect_key(org_id: str, key_id: str, uid: str = Depends(member_uid)): | |
| _map_errors(collect.revoke_key, org_id, uid, key_id) | |
| return {"revoked": key_id} | |
| # --- Team: members, roles and invites ------------------------------------------ | |
| def get_team(org_id: str, uid: str = Depends(org_only_uid)): | |
| role = orgs.get_role(org_id, uid) | |
| can_manage = "manage_team" in orgs.permissions_for(org_id, role) | |
| members = [] | |
| for member_uid_, data in orgs.list_members(org_id).items(): | |
| known = {} if data.get("email") else lookup_user(member_uid_) | |
| members.append({ | |
| "uid": member_uid_, "role": data["role"], "added_at": data.get("added_at"), | |
| "email": data.get("email") or known.get("email", ""), "name": known.get("name", ""), | |
| "you": member_uid_ == uid, | |
| }) | |
| return { | |
| "role": role, | |
| "can_manage": can_manage, | |
| "permissions": PERMISSIONS, | |
| "roles": orgs.list_roles(org_id), | |
| "members": members, | |
| "invites": invites.list_invites(org_id, uid) if can_manage else [], | |
| } | |
| class InviteBody(BaseModel): | |
| email: str | |
| role: str | |
| link_base: str | |
| inviter: str = "" | |
| def create_invite(org_id: str, body: InviteBody, uid: str = Depends(org_only_uid)): | |
| invite = _map_errors(invites.create_invite, org_id, uid, body.email, body.role) | |
| link = f"{body.link_base.rstrip('/')}/invite/{invite['token']}" | |
| emailed = invites.send_invite_email(org_id, invite, link, body.inviter) | |
| return {**invite, "link": link, "emailed": emailed} | |
| def revoke_invite(org_id: str, token: str, uid: str = Depends(org_only_uid)): | |
| _map_errors(invites.revoke_invite, org_id, uid, token) | |
| return {"ok": True} | |
| class MemberRoleBody(BaseModel): | |
| role: str | |
| def change_member_role(org_id: str, target_uid: str, body: MemberRoleBody, uid: str = Depends(org_only_uid)): | |
| _map_errors(orgs.change_member_role, org_id, uid, target_uid, body.role) | |
| return {"ok": True} | |
| def remove_member(org_id: str, target_uid: str, uid: str = Depends(org_only_uid)): | |
| _map_errors(orgs.remove_member, org_id, uid, target_uid) | |
| return {"ok": True} | |
| class RoleBody(BaseModel): | |
| name: str = "" | |
| permissions: list[str] | |
| def create_role(org_id: str, body: RoleBody, uid: str = Depends(org_only_uid)): | |
| return {"key": _map_errors(orgs.save_role, org_id, uid, None, body.name, body.permissions)} | |
| def update_role(org_id: str, key: str, body: RoleBody, uid: str = Depends(org_only_uid)): | |
| return {"key": _map_errors(orgs.save_role, org_id, uid, key, body.name, body.permissions)} | |
| def delete_role(org_id: str, key: str, uid: str = Depends(org_only_uid)): | |
| _map_errors(orgs.delete_role, org_id, uid, key) | |
| return {"ok": True} | |
| def describe_invite(token: str, uid: str = Depends(current_uid)): | |
| return _map_errors(invites.describe, token) | |
| def accept_invite(token: str, uid: str = Depends(current_uid), email: str = Depends(current_email)): | |
| _map_errors(accounts.can_join_org, uid) | |
| return {"org_id": _map_errors(invites.accept, token, uid, email)} | |
| def leave_org(org_id: str, uid: str = Depends(org_only_uid)): | |
| _map_errors(orgs.leave, org_id, uid) | |
| return {"ok": True} | |
| # --- Brands -------------------------------------------------------------------- | |
| def list_brands(org_id: str, uid: str = Depends(org_only_uid)): | |
| return brands.list_brands(org_id) | |
| def create_brand(org_id: str, body: dict, uid: str = Depends(org_only_uid)): | |
| brand_id = _map_errors(brands.create_brand, org_id, uid, body) | |
| return {"brand_id": brand_id, **brands.get_brand(org_id, brand_id)} | |
| def update_brand(org_id: str, brand_id: str, body: dict, uid: str = Depends(org_only_uid)): | |
| return _map_errors(brands.update_brand, org_id, uid, brand_id, body) | |
| def delete_brand(org_id: str, brand_id: str, uid: str = Depends(org_only_uid)): | |
| _map_errors(brands.delete_brand, org_id, uid, brand_id) | |
| return {"ok": True} | |
| # --- Domain records -------------------------------------------------------------- | |
| def check_dns(org_id: str, domain: str, uid: str = Depends(org_only_uid)): | |
| return _map_errors(dns_check.check_domain, domain) | |