Repository navigation
feat(emails): send transactional email with client.Emails - #365
Conversation
Add IPostHogClient.Emails, a port of posthog-node's and posthog-python's emails API. SendAsync sends a template made in PostHog by its ID, or content the app rendered, named by a template key, as the flat body of POST /api/projects/@current/messaging/send/email/. GetAsync reads an email's status. Every attempt of a send carries the same idempotency key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 0557879: 2 Must fix, 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.
See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.
posthog-dotnet Compliance ReportDate: 2026-11-05T11:03:43.230835+00:00 ✅ All Tests Passed!47/47 tests passed Capture Tests✅ 30/30 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
- Serialize read-only dictionaries without recursing into the same converter, so ReadOnlyDictionary template data no longer overflows the stack. - Give IPostHogClient.Emails a default implementation where the target allows it, as EvaluateFlagsAsync does, so custom implementers keep compiling. - Drain email requests while the event batch flushes, instead of before it. - ConfigureAwait(false) on every await in EmailSender, so a synchronous Dispose can't deadlock on a captured context. - Name an invalid AppHost in the missing_app_host message. - Drop the unused jitter seam, the unreachable body-read fallback and the inline comments; use UUID message IDs in the send fixtures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copy the entries into a Dictionary and let System.Text.Json's own dictionary converter write them, so non-string keys keep their JSON property-name format. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Write one property per entry the source enumerates, with the key type's own JSON property-name converter, instead of copying into a Dictionary whose default comparer could merge keys the source keeps apart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
System.Text.Json's string converter already applies DictionaryKeyPolicy, so a custom string key converter keeps working with a naming policy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decisions made while building this PR:
|
Adding Emails to IPostHogClient broke custom implementers on netstandard2.0, which has no default interface members. Emails now lives on PostHogClient, and AddPostHog registers the client's IPostHogEmails for injection. Dispose() runs DisposeAsync off the caller's synchronization context, so a synchronous dispose on a single-threaded context can't deadlock while an email request or the event flush is still running. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael This decision replaces the earlier
|
…omments Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New commits were pushed — dismissing the stamphog approval from an earlier head. This PR no longer qualifies for automatic review.
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
… reads Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New commits were pushed — dismissing the stamphog approval from an earlier head. This PR no longer qualifies for automatic review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SDK performance: posthog-dotnetBenchmarks completed. No changes flagged for review. 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. Compared 108 metric rows; 108 rows shown in all results. Omitted or shortened data remain in |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
💡 Motivation and Context
.NET apps can't send transactional email through PostHog without hand-writing HTTP calls. They need the idempotency keys, retries and error mapping that the Node and Python SDKs already handle. This PR adds
PostHogClient.Emailswith the same contract as posthog-node and posthog-python (contract: PostHog/posthog#120921). There are two ways to send:SendTemplateEmailOptions { TemplateId, TemplateData }.SendCodeEmailOptions { TemplateKey, Subject, Html and/or Text, From, ReplyTo, CaptureEngagement }. React Email users render to HTML themselves and passHtml. The SDK takes no React or rendering dependency.GetAsync(messageId)reads the email's status.Impact
This serves Grow Workflows. The metric is emails sent from code, which the
workflows_emailsusage line counts (API sends included) on the Workflows growth dashboard in the internal project. In scope: messaging (email). Expected move: small and upward, as one more server SDK that can send. Run cost: n/a, because these sends use the same server path as the other SDKs.Merge risk
Warning
Medium risk, one-way door (public API). This adds
PostHogClient.Emails, a new namespacePostHog.Emails, anIPostHogEmailsregistration inAddPostHog, and five newPostHogOptionsproperties. Once released, these names are API that customers compile against. The change is additive:IPostHogClientis unchanged, so custom implementers and test doubles keep compiling. Two existing paths change:DisposeAsyncalso waits for in-flight email requests, alongside the event flush.Dispose()now runsDisposeAsyncon the thread pool, so a synchronous dispose on a single-threaded context (WPF, WinForms, classic ASP.NET) can no longer deadlock.SynchronousDisposeOnABlockedContextWaitsForASendWithoutDeadlockingstarts a send and disposes on a context that never runs callbacks. It times out against the oldDisposeand passes with the new one.Release order. The code form (
SendCodeEmailOptions) needs the server's code send, PostHog/posthog#127123 (Django) and #127122 (cdp-api), to be deployed. Until then a code send gets a400. Don't merge or release this until code sends are live and usable for customers, which is later than the server deploy. The template form already works against production.What it does
Send.
POST {AppHost}/api/projects/@current/messaging/send/email/with the flat body:template_id,to,message_data,distinct_id;template_key,to,subject,html,text,from,reply_to,capture_engagement,distinct_id.The request carries
Authorization: Bearer <SecretKey>(a project secret API key) and anIdempotency-Keyon every attempt. The SDK generates a UUID v4 key when you pass none, and reuses it on retries. WithMigratedFromset, it also sendsX-PostHog-Migrated-From.Status.
GET {AppHost}/api/projects/@current/messaging/emails/{id}/returns anEmailStatuswithStatus,CreatedAt,StatusAtandReason.App host. Cloud ingestion hosts map to their app host (
us.i,us,app→us.posthog.com;eu.i,eu→eu.posthog.com). Any otherHostUrlmight be a reverse proxy for ingestion, which must never receive the secret key. So email requests needAppHostset there, and fail withmissing_app_hostbefore any request when it isn't.AppHostmust use https, because every email request carries the secret key. An httpAppHostfails withinsecure_app_hostbefore any request, unlessAllowInsecureAppHostis set for local development.Retries.
network_error,409 idempotency_key_in_flight,429(exceptquota_limited) and503are retried up toEmailRequestMaxRetries(default 2), orMaxRetriesper send. The delay is2^ns with ±20% jitter, capped at 30 s, orRetry-Afterwhen that is longer. ARetry-Afterover 30 s fails at once instead of blocking the caller. Each attempt times out afterEmailRequestTimeout(default 15 s).Errors. Invalid options throw
ArgumentExceptionbefore any request. Every request or SDK failure throwsEmailExceptionwith:Code: the server's or SDK's code, such asidempotency_key_reusedormissing_app_host;Name: the cross-SDK category:validation_error,invalid_permission,not_found,concurrent_idempotent_requests,invalid_idempotent_request,rate_limit_exceeded,monthly_quota_exceeded,service_unavailable,missing_api_keyorapplication_error;StatusCode,Why,Fix,DocsUrl,FieldandRetryAfter.Where it lives. Without DI, it's
new PostHogClient(options).Emails. With DI,AddPostHog()registersIPostHogEmails, so a service injects it directly.IPostHogClientgets no new member.Lifecycle. A disabled client throws
client_disabled, and a disposed one throwsclient_closed. Dispose waits for in-flight sends, including ones in backoff, before the HTTP client goes away.Warnings. The server's
warningscome back onSendEmailResult.Warnings, and each one is logged at Warning level.Divergence from sdk-specs.
openspec/specs/emails/spec.mdin PostHog/sdk-specs covers only the template form. The code form follows posthog-node (PostHog/posthog-js#5509) and posthog-python (PostHog/posthog-python#1171), which add it. Option names are .NET style (TemplateData,ReplyTo,EmailRequestMaxRetries). The wire names match the other SDKs.💚 How did you test it?
Everything below ran at head
3fc597c. The live runs use a local http Django, so the probe setsAllowInsecureAppHost.Live run against the real server code send. The run used feat(messaging): send an email with inline html from code posthog#127123 (Django,
51409d9b86b), #127122 (cdp-api and email workers,b5889c9eeca), a mail catcher, and a recording proxy in front of Django. A console app drove this branch'sPostHogClient. Every case matched:202. The replay with the same key returnsReplayed = trueand the same ID, and one email lands.Html,Text,From,ReplyTo,CaptureEngagement = false)202. The caught mail has the expected subject, from, reply-to, HTML and text.GetAsyncon both IDssent422 idempotency_key_reused→invalid_idempotent_request404 not_foundFrom400 sender_not_verified→validation_error401→missing_api_key404 not_foundHostUrlwithoutAppHostmissing_app_host, and no request is sentAppHostwithoutAllowInsecureAppHostinsecure_app_hoston send and status read, and no request is sentDjango received
User-Agent: posthog-dotnet/2.18.1 (...)and theIdempotency-Key. The storage readback found the queued Cyclotron jobs, theemail_statusrows, and the idempotency keys in Valkey. A tenth case resolvedIPostHogEmailsfromAddPostHog(with thePostHogconfig section bound), sent through it, and got the same instance asPostHogClient.Emails. A synchronousprovider.Dispose()afterwards returned at once.Cross-version: new SDK against today's master server (Django
b8095bf610d, no code send). The template send, replay, status read and every error case behave as above. The code send is refused with400 requiredontemplate_id, which maps tovalidation_error. No mail goes out and no job is queued. This is the accepted pre-deploy behavior, and the reason for the release order above.Server-side observations, not SDK defects.
202, the status route answers404for about 4–10 s, until the first status row lands. The first status the SDK sees issent.phs_key gets a401whose detail mentions a personal API key.CI is green on
3fc597c: build, format, tests on netcoreapp3.1 and net8.0, packaging, the SDK compliance tests, CodeQL and semgrep. The first head,0557879, had one failing test: a timing-dependent assertion in the shutdown test that review round 1 also flagged. It is removed.Lane gate on
3fc597c, with the CI steps run locally:dotnet restore --locked-mode,bin/fmt --check,dotnet build -c Release,dotnet test -c Release. Exit 0.Test seams.
tests/UnitTests/Emails/EmailsTests.csdrives a realPostHogClientthrough its publicEmailsAPI. Only the edges are fake: a recordingHttpMessageHandler, aTimeProviderthat records and can hold backoff delays, and a fake logger. The tests cover:Retry-After;Code/Namemapping;Dispose()on a blocked synchronization context;AddPostHogresolvingIPostHogEmailsas the client'sEmails;TemplateData, including non-string keys.tests/UnitTests/Json/ReadOnlyDictionaryJsonConverterTests.cscovers the dictionary converter fix, the one shared-code change: writing a read-only dictionary used to recurse forever.📝 Checklist
Examples.csholds the doc snippet. Usage docs live on posthog.com, and the API docs page is docs(workflows): send content from your code on the transactional email API, Node.js and Python pages posthog.com#21819.)If releasing new changes
pnpm changesetto generate a changeset file (.changeset/emails-send.md, minor)🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Built by Claude Opus 5.5 (
claude-opus-5-5, high effort) in Claude Code, directed by Michael as one slice of the transactional-email-from-code work. Reviewed by a cross-model panel: GPT-6.1 Sol on correctness, risk, design and anti-overengineering, Claude Opus 5.5 on fresh eyes, then 7 fixes rounds on GPT-6.1 Sol. Every valid finding was fixed. The fixes were the read-only dictionary serialization (3 rounds of edge cases), keepingIPostHogClientunchanged (the proof judge showed that a default interface member still breaks netstandard2.0 implementers), running the email drain alongside the event flush, a deadlock-free synchronousDispose(),ConfigureAwait(false), and https forAppHost(from a security review thread). Rejected: a client-sidephs_prefix check (neither reference SDK has one), and prod-shape and shadow-diff proofs (the SDK reads no stored data, and the change is additive).template_key. No local templates, content hash or uploadHtml. No React dependency in the SDKArgumentExceptionfor invalid options, oneEmailExceptionwithCodeandNamefor everything elseGuid TemplateId. The message ID stays astring, because the status read takes what send returnedSecretKeyphs_check, matching posthog-node and posthog-python. The server checks the key's scopesPostHogSdkfacadeEmailslivesPostHogClient, plusIPostHogEmailsin DI. Not onIPostHogClient, because netstandard2.0 has no default interface members and a new member there breaks implementers. This replaced theEvaluateFlagsAsync-style default member from round 1Dispose()runsDisposeAsyncon the thread pool, so a single-threaded context can't deadlockAppHostinsecure_app_host. Plain http needs the explicitAllowInsecureAppHostopt-in for local developmentInvolvement: no grilling rounds, no prototypes, 8 review rounds (one 5-lens panel and 7 fixes reads), and live-run plus cross-version proof captured.
🤖 Generated with Claude Code