Skip to content

Email API: Django creates and owns the hidden API sends workflow #469

Closed

Description

@Silthus

Map: Map: send email from code with one PostHog call
Spec: Spec: email send API

Cast: Opus 5.5, high
Repository: PostHog/posthog

Question

Can trusted backend code create a team's one API sends workflow, keep it out of the Workflows list, and keep people from editing it?

Delivers end to end. get_or_create_api_sends_flow(team) returns the team's one active API sends flow, creating it on first call: trigger {"type": "api_send"} → one transactional email step on the existing email template → exit, billing function_email. The email step inputs read variables.email.* with Liquid | raw as the spec shows. Concurrent first calls converge on one row. A graph version mismatch rewrites the graph. api_send joins the owned workflow types, so the Workflows list leaves the flow out and ?type=api_send lists it. The hog flow update path refuses edits to an API sends flow's graph and trigger; pausing its email sending still works. Spec section: "The hidden API sends workflow".

No sends yet: nothing calls this function until the CDP route ticket.

Test seams.

  • The service function, called directly: creates once, returns the same row on the second call, converges under two concurrent calls, rewrites an outdated graph.
  • The hog flow list endpoint: the flow is absent without a type filter matching it and present with ?type=api_send.
  • The hog flow update endpoint: a graph or trigger edit answers 400; an email pause toggle succeeds.

Write scope. A new products/workflows/backend/services/api_sends_flow.py, products/workflows/backend/services/hog_flow_reads.py (OWNED_WORKFLOW_TYPES), the update guard in products/workflows/backend/presentation/views/hog_flow.py, the facade export if another product needs it, their tests.

Proof.

  • Red: a list call shows the API sends flow among a team's workflows, and an update call changes its graph.
  • Green: the list hides it, the update is refused, and the function returns one row per team.
  • Local: from a Django shell, call the function for a dev team, then open /project/<id>/workflows/<flow id>; the flow renders with its one email step.

Acceptance criteria

  • One call or many, concurrent or not, leave one API sends flow per team.
  • The flow's email step is transactional and every input reads variables.email.* with | raw.
  • The Workflows list leaves the flow out; ?type=api_send returns it.
  • Editing its graph or trigger is refused; pausing its email sending works.
  • A flow with an outdated graph version is rewritten on the next call.

How to build (map #461)

  • /tdd red-green-refactor, outside-in, at the seams named above. Real Postgres, Valkey and ClickHouse; mock only what the seam section allows.
  • Repo skills apply where the diff touches their area: /writing-tests, /improving-drf-endpoints, /django-migrations, /adding-api-scopes, /writing-ui-components, /writing-kea-logics, /writing-user-facing-copy, /writing-code-comments.
  • Public repo: no usage numbers, customer names, internal metrics, Slack content or security reasoning in code, commits or the PR. Fixture addresses use example.com. The private note the map points at is background only; never quote it.
  • Shipped means a draft PR handed off for review (see the map's Notes for remotes and branches). Never mark ready, enqueue or merge.

Activity

  1. self-assigned this
    on Oct 14, 2026
  2. Silthus commented on Oct 14, 2026

    @Silthus
    OwnerAuthor

    Runtime: Claude Code background Agent
    Worker: implement-469-hidden-api-sends-workflow
    Cast: Opus 5.5, high. Wall-time cap: 120 minutes.
    Lands on integration branch spec/email-send-api.

  3. Silthus commented on Oct 14, 2026

    @Silthus
    OwnerAuthor

    Claimed by conductor dispatch. Runtime: Claude Code background Agent, worker implement-469-hidden-api-sends-workflow, Opus 5.5 high. Integration branch: spec/email-send-api.

  4. Silthus commented on Oct 14, 2026

    @Silthus
    OwnerAuthor

    Landed on spec/email-send-api as a fast-forward from ce0843adff0.

    Landed commit: 38d8566764f (feat(workflows): create and own the hidden api sends workflow)

    What landed

    • products/workflows/backend/services/api_sends_flow.py: get_or_create_api_sends_flow(team). The graph is trigger {"type": "api_send"} → one function_email step on template-email with message_category_type: "transactional" → exit. It bills function_email. Every email input reads variables.email.* with | raw, except from.integrationId, which is {{ variables.email.integration_id }} as the spec shows. The flow is created active, named "API sends", with no created_by.
    • hog_flow_reads.py: api_send joins WORKFLOW_TYPES and OWNED_WORKFLOW_TYPES. The Workflows page filter (messaging,automation,loop) leaves the flow out. ?type=api_send returns it. The type parameter description names api_send, so the frontend and MCP generated files were regenerated.
    • presentation/views/hog_flow.py: HogFlowSerializer.to_internal_value refuses edits to the API sends flow's content fields with a 400. It checks before field validation. Update, graph, actions/<id>/email, publish and proposals all validate through this serializer.

    Proof

    Artifacts are on branch assets/email-api-469.

    • Red, base tree ce0843adff0 with the new tests: red-service-base.log (no module) and red-list-and-guard-base.log. In the second log, messaging and the Workflows page filter list "API sends", and ?type=api_send answers 400. A variables update answers 200. The graph edits fail only on unrelated checks, such as the unknown trigger type.
    • Red, one piece removed: red-concurrency-without-race-handling.log shows the second caller hitting the unique_api_sends_flow_per_team IntegrityError. red-rewrite-without-graph-check.log shows an outdated graph kept as it was. red-update-guard.log shows the edits without the guard.
    • Green on the landed tree: green-full.log. 508 passed across the new service tests, tests/api/test_hog_flow.py, test_workflow_email_pause.py, test_hog_flow_draft_publish.py and test/test_hog_flow.py.
    • Gate: the pre-push hogli ci:preflight --strict passed (0 failures) on the push to spec/email-send-api. mypy on the new module and test reports no issues. ty check passed in the commit hook.
    • Local: flow.png. The function ran from a Django shell for dev team 2. Two calls returned the same row at version 1. /project/2/workflows/<flow id>/workflow renders the trigger "Email API send" and one "Send email" step. Opening the editor wrote nothing to the row.

    Test seams

    • get_or_create_api_sends_flow, called directly (test/test_api_sends_flow.py): it creates once, returns the same row and version on the second call, matches the spec's input literal, and rewrites an outdated graph. A NonAtomicBaseTest holds the first insert open and waits on pg_locks until the second insert blocks, so the race runs every time.
    • The hog flow list endpoint: an api_send case and the API sends flow added to the existing test_list_filter_by_workflow_type matrix.
    • The hog flow update endpoint: test_api_sends_flow_refuses_graph_edits covers the graph, the trigger, variables, the graph endpoint and the email step endpoint. test_api_sends_flow_can_be_renamed_and_resume_email_sending checks that a rename and resume_email_sending still work.

    Calls I made

    • "Graph version" is the graph itself. The function compares the stored trigger, actions, edges and billable types with the code-built graph. On a mismatch it rewrites them under a row lock and bumps version. No separate version stamp is stored. This needs no new column, and no constant has to be bumped by hand. A second call compares equal, which the tests check, so it does not write on every send.
    • Concurrency: I used the brief's IntegrityError-then-read inside a savepoint. It also works inside a caller's open transaction.
    • Refused fields: every draft content field (actions, edges, trigger, trigger_masking, conversion, exit_condition, abort_action, variables) except email_sending_rate_limit. Flow-level variables could shadow the per-send variables.email, and a conversion goal could exit a run before it sends. Name, description, status and the rate limit stay editable.
    • "Pause toggle": customers cannot pause a workflow's email themselves. The detector and staff do that through the ORM. The customer action is resume_email_sending, so the test covers resume. The guard never touches the pause fields.
    • No status repair: the function returns the existing row whatever its status. A person can still disable the flow, and the CDP route then answers 409, as the spec says.
    • No facade export and no revision row: nothing outside workflows calls the function yet.
    • I did not add api_send to the Django TRIGGER_TYPES mirror or to the Node trigger schema. Those belong to the CDP route ticket.

    Follow-ups

    • CDP route ticket: Django's trigger validation and the Node HogFlowTriggerSchema do not know api_send yet. Until they do, the editor shows a warning on the trigger, and the Node schema's trigger union has no api_send member. I did not check how the flow manager handles a flow that fails that schema.
    • UI: the builder still shows Save draft and Publish for this flow. Each save now answers 400 with the guard's message. A read-only builder, or hiding those buttons for origin_product = api_sends, belongs with the /workflows/api-sends route.
    • Test runs, from Email API: API sends origin and the one-flow-per-team index #468: a builder test invocation that posts the flow as configuration with origin_product: "api_sends" still hits Email API: API sends origin and the one-flow-per-team index #468's create guard (validate_origin_product). The new edit guard skips that path because it checks only self.instance.
    • The proof flow 01a13a4d-4990-0000-4299-735d5eb00ae8 stays on dev team 2 in the local dev database.
    • No review ran in this ticket. Per the map, the ship ticket Ship the email send API as one reviewed draft PR #477 reviews the branch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions