Repository navigation
Email API: Django creates and owns the hidden API sends workflow #469
Description
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_sendreturns 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)
/tddred-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
Silthus commentedon Oct 14, 2026
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.Silthus commentedon Oct 14, 2026
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.
Silthus commentedon Oct 14, 2026
Landed on
spec/email-send-apias a fast-forward fromce0843adff0.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 istrigger {"type": "api_send"}→ onefunction_emailstep ontemplate-emailwithmessage_category_type: "transactional"→ exit. It billsfunction_email. Every email input readsvariables.email.*with| raw, exceptfrom.integrationId, which is{{ variables.email.integration_id }}as the spec shows. The flow is createdactive, named "API sends", with nocreated_by.hog_flow_reads.py:api_sendjoinsWORKFLOW_TYPESandOWNED_WORKFLOW_TYPES. The Workflows page filter (messaging,automation,loop) leaves the flow out.?type=api_sendreturns it. Thetypeparameter description namesapi_send, so the frontend and MCP generated files were regenerated.presentation/views/hog_flow.py:HogFlowSerializer.to_internal_valuerefuses 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.ce0843adff0with the new tests: red-service-base.log (no module) and red-list-and-guard-base.log. In the second log,messagingand the Workflows page filter list "API sends", and?type=api_sendanswers 400. Avariablesupdate answers 200. The graph edits fail only on unrelated checks, such as the unknown trigger type.unique_api_sends_flow_per_teamIntegrityError. red-rewrite-without-graph-check.log shows an outdated graph kept as it was. red-update-guard.log shows the edits without the guard.tests/api/test_hog_flow.py,test_workflow_email_pause.py,test_hog_flow_draft_publish.pyandtest/test_hog_flow.py.hogli ci:preflight --strictpassed (0 failures) on the push tospec/email-send-api.mypyon the new module and test reports no issues.ty checkpassed in the commit hook./project/2/workflows/<flow id>/workflowrenders 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. ANonAtomicBaseTestholds the first insert open and waits onpg_locksuntil the second insert blocks, so the race runs every time.api_sendcase and the API sends flow added to the existingtest_list_filter_by_workflow_typematrix.test_api_sends_flow_refuses_graph_editscovers the graph, the trigger, variables, thegraphendpoint and the email step endpoint.test_api_sends_flow_can_be_renamed_and_resume_email_sendingchecks that a rename andresume_email_sendingstill work.Calls I made
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.actions,edges,trigger,trigger_masking,conversion,exit_condition,abort_action,variables) exceptemail_sending_rate_limit. Flow-levelvariablescould shadow the per-sendvariables.email, and a conversion goal could exit a run before it sends. Name, description, status and the rate limit stay editable.resume_email_sending, so the test covers resume. The guard never touches the pause fields.api_sendto the DjangoTRIGGER_TYPESmirror or to the Node trigger schema. Those belong to the CDP route ticket.Follow-ups
HogFlowTriggerSchemado not knowapi_sendyet. Until they do, the editor shows a warning on the trigger, and the Node schema's trigger union has noapi_sendmember. I did not check how the flow manager handles a flow that fails that schema.origin_product = api_sends, belongs with the/workflows/api-sendsroute.configurationwithorigin_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 onlyself.instance.01a13a4d-4990-0000-4299-735d5eb00ae8stays on dev team 2 in the local dev database.