Repository navigation
Conversation
send() takes scheduled_at, an aware datetime or an ISO 8601 string with an offset, and refuses a naive datetime before any request. Emails and AsyncEmails gain update(), cancel() and list(). Cancel and update retry like a send; list never retries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p parser Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
posthog-python Compliance ReportDate: 2026-11-12T16:45:47.862512+00:00 ✅ All Tests Passed!270/270 tests passed Capture_V1 Tests✅ 95/95 tests passed View Details
Capture_Ai Tests✅ 5/5 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
Feature_Flags_V4 Tests✅ 149/149 tests passed View Details
Feature_Flags_Local_Evaluation Tests✅ 4/4 tests passed View Details
|
SDK performance: posthog-python1 change worth reviewing: 1 observed improvement. Flags require at least a 5% change in the median and all six pairs agreeing on direction. This is a noise filter, not proof of a performance change or a CI gate.
View all results
Values are medians of paired measurements. Not flagged does not mean unchanged. Unavailable means a measurement could not be provided, not zero. Estimated allocation measurements retain their instrumentation uncertainty. RSS measures the whole process, not just SDK allocations; observed peaks are sampled checkpoints. Paired ranges in the diagnostics describe variability, not confidence intervals. Each shard uses its own runner; compare Before PR against With PR within a row, not across shards. Compared 102 metric rows; 102 rows shown in all results. Omitted or shortened data remain in |
…onflicts - A scheduled send that a PostHog without scheduled sends answers with no status now carries the scheduling_unsupported warning, as posthog-node does, so the caller sees the email went out now. - list() raises api_error when a page's next cursor is missing or not a nonempty string, instead of ending a paging loop early. - cancel() raises api_error unless PostHog answers status canceled. - A 409 already_sending or canceled is named validation_error, like template_outdated, as posthog-node names it: a retry can't resolve it. - list() takes only status="scheduled", typed Literal["scheduled"]. - update() documents that a lost answer to a move to now can retry into already_sending. Drop the unused cancel path constant and typing.List. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orted Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #1216 (tracking and sensitive on a code send). The conflicts were additions on both sides: the optional code fields, the strict type smoke lines, and the test imports. All are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
💡 Motivation and Context
A team moving from Resend keeps call sites that schedule an email with
scheduledAt, and move or cancel it withemails.updateandemails.cancel. posthog-python can only send now, so those call sites stay on Resend. This PR adds scheduled sends toposthog.emails, onEmailsandAsyncEmails:The wire contract is the scheduled-sends section of the transactional email contract, PostHog/posthog#120921, shared with the Django stack and posthog-node. Part of map Silthus/personal#292, ticket Silthus/personal#306.
Impact
Closes one Resend call-site gap. A migration from Resend no longer leaves scheduled call sites behind. This pays into the code-sending and switched-organization metrics of the transactional email work. The expected move is small and up. Whether it worked shows in the share of code-sending organizations that schedule at least one send in the first 60 days after GA. That needs
scheduledand a lead-time bucket on the send event, which the server stack adds.Caution
🔴 Merge risk: high · one-way door once released: the new keyword
scheduled_at, the methodsupdate,cancelandlist, and the result types become a public contract that docs and callers build on.scheduled_at, or callupdate,cancelorlist: new paths, in both regions. Until the server's scheduled sends deploy, the server answers them with a field error or a404.get, or catch errors: same request bodies, headers, retries and errors asmain. The unchanged email tests and a shadow diff of request bodies and results cover this. Send results gainstatus("queued"when the server says nothing) andscheduled_at(None) as trailing fields with defaults.scheduled_atand sends now. The result then carries ascheduling_unsupportedwarning, which is logged, as in posthog-node, so the mistake shows.Rollout
mainputs the changeset up for the next Slack-approved release, and nothing is released before then. The minimum version forscheduled_atgoes in the docs at release.emails.batchitems takescheduled_atin the batch work.Changes:
send(..., scheduled_at=...)on both forms. An awaredatetimegoes as UTC ISO 8601 withZ. A string must parse as an ISO 8601 time with an offset, and goes as given. A naivedatetime, or a string without an offset, raisesValueErrorbefore any request. Another type raisesTypeError.Nonecounts as left out. There is no local 30-day check: the server ownsinvalid_scheduled_at.SendEmailResultgainsstatusandscheduled_at. A send withscheduled_atwhose reply has nostatus(a PostHog without scheduled sends, which sent it now) adds thescheduling_unsupportedwarning.update(id, *, scheduled_at)sendsPATCH messaging/emails/{id}/and returnsUpdateEmailResult.cancel(id)sendsPOST messaging/emails/{id}/cancel/and returnsCancelEmailResult, and raisesapi_errorunless PostHog answerscanceled. Both retry like a send, with no idempotency key. A move to a later time or a cancel ends the same however often it lands. A move to now whose answer is lost can retry intoalready_sending, which the docstring says.list(*, status="scheduled", cursor=None, limit=None)sendsGET messaging/emails/?status=scheduledand returnsEmailListofScheduledEmail. It never retries.statusisLiteral["scheduled"], andlimitis checked as 1 to 100 locally. A row without an ID, recipient or either time raisesapi_errorinstead of being dropped, and so does a page whosenextis missing or not a nonempty string, since losing it would end a paging loop early.409withalready_sendingorcanceledgets the error namevalidation_error, liketemplate_outdated: a retry can't resolve it.EmailStatusdocumentsscheduledandcanceled;getpasses statuses through unchanged, as before.Cross-SDK notes against PostHog/sdk-specs
openspec/specs/emails/spec.md:getthe retry policy of a send, sogetkeeps its retries. The map's spec saidgetnever retries; this PR follows the published sdk-spec.409withalready_sendingorcanceledgets the error namevalidation_error, as posthog-node names it. The sdk-spec names every409buttemplate_outdatedconcurrent_idempotent_requests, a rule that predates these codes. Both SDKs extend itstemplate_outdatedexception the same way.scheduling_unsupportedwarning, thenextandcanceledchecks, and the list arguments match it. Extending the sdk-spec with scheduling is a follow-up once the server is live.scheduled_atstring goes through Python'sdatetime.fromisoformat, so it also accepts a space forT, which is whatstr(datetime)prints. posthog-node takes onlyT. Both refuse a time without an offset, so neither sends at a time the caller did not pick.💚 How did you test it?
Test seams:
EmailsandAsyncEmailson a real client with the HTTP transport faked (requestsadapter,httpx.MockTransport), and.github/scripts/check_strict_types.shunder pyright strict for the new public types.posthog/test/test_emails.pyfail onmain(53 failed) and pass on this head. The review fixes added 13 more that failed on8a365d2and pass onef74081. One assertion was added in5239890after a mutant that drops thestatuscheck survived. With that assertion, the mutant fails 5 tests. The suite passes on Python 3.11 (full: 6811 passed) and 3.10 (email tests).ruff format --check,ruff check, mypy with the baseline,check_public_api.py,check_strict_types.sh,python -W error -c "import posthog"and the full pytest run, all green.Owed proofs (
fleet proof):f01179a, after mergingmain(with feat(emails): let a code send set its tracking and sensitivity #1216). 18 send, get and error scenarios throughEmailswith a fake transport,mainatac25b4bagainst this head. The differences are the same as at5239890againstc0b0230. Requests, errors, retries andgetresults are the same. Send results gainstatus("queued") andscheduled_at(None) when the server answers without them, and read them when it answers with them.canceled, against a local dev stack at the Django stack's D5 head. Waits for that head.scheduled_atwithTypeError. Waits for the D5 head.📝 Checklist
If releasing new changes
sampo addto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
claude-opus-5-5), as Stack Y of map Silthus/personal#292, directed by Michael.067e51d(reuse the status timestamp parser forscheduled_atstrings instead of a second ISO 8601 grammar).8a365d2, eight lenses on GPT-6.1 Sol and Claude Opus 5.5, with 11 findings. Fixed inef74081: thenextcheck, thescheduling_unsupportedwarning, theliststatus type, the docs for a move to now, dead code and test cleanups. A check against posthog-node then turned up the409naming and thecanceledcheck, fixed in the same commit. Answered without a change: a prod-shape proof (the SDK stores nothing, and no scheduled emails exist in production yet) and extending the sdk-spec (a follow-up). Round 2 on the fixes found one missing test, added in5239890. The final read is carried, since the delta was test-only.8a365d2and5239890: no findings.Decisions
send()only; batch items move to the batch workgetretries?409?validation_error, as posthog-node names it; both SDKs extend the sdk-spec'stemplate_outdatedexceptionscheduled_at?scheduling_unsupported(as posthog-node does), not raise: the email already went outstatuswhen the server gives none?"queued"Involvement: one design pass and two review rounds (GPT-6.1 Sol, Claude Opus 5.5), two PostHog Review runs. The live run and the cross-version run wait for the server's D5 head.
🤖 Generated with Claude Code